From 4e0a5f714c3bc5cfe3b13a68b94124d4a7ed6bec Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:32:37 +0000 Subject: [PATCH] feat(ansible): add with_subelements loop support --- executor.go | 86 ++++++++++++++++++++++++++++++++++++++++++++++-- executor_test.go | 35 ++++++++++++++++++++ parser.go | 2 +- types.go | 15 +++++---- types_test.go | 19 +++++++++++ 5 files changed, 147 insertions(+), 10 deletions(-) diff --git a/executor.go b/executor.go index 68b4662..0b8ac6a 100644 --- a/executor.go +++ b/executor.go @@ -676,8 +676,9 @@ func (e *Executor) runTaskOnHost(ctx context.Context, host string, hosts []strin return coreerr.E("Executor.runTaskOnHost", sprintf("get client for %s", executionHost), err) } - // Handle loops, including legacy with_file, with_fileglob, with_sequence, and with_together syntax. - if task.Loop != nil || task.WithFile != nil || task.WithFileGlob != nil || task.WithSequence != nil || task.WithTogether != nil { + // Handle loops, including legacy with_file, with_fileglob, with_sequence, + // with_together, and with_subelements syntax. + if task.Loop != nil || task.WithFile != nil || task.WithFileGlob != nil || task.WithSequence != nil || task.WithTogether != nil || task.WithSubelements != nil { return e.runLoop(ctx, host, client, task, play, start) } @@ -881,6 +882,8 @@ func (e *Executor) runLoop(ctx context.Context, host string, client sshExecutorC items, err = e.resolveWithSequenceLoop(task.WithSequence, host, task) } else if task.WithTogether != nil { items, err = e.resolveWithTogetherLoop(task.WithTogether, host, task) + } else if task.WithSubelements != nil { + items, err = e.resolveWithSubelementsLoop(task.WithSubelements, host, task) } else { items = e.resolveLoopWithTask(task.Loop, host, task) } @@ -1167,6 +1170,85 @@ func (e *Executor) resolveWithTogetherLoop(loop any, host string, task *Task) ([ return items, nil } +func (e *Executor) resolveWithSubelementsLoop(loop any, host string, task *Task) ([]any, error) { + source, subelement, ok := parseSubelementsSpec(loop) + if !ok { + return nil, nil + } + if subelement == "" { + return nil, coreerr.E("Executor.resolveWithSubelementsLoop", "with_subelements requires a subelement name", nil) + } + + parents := e.resolveSubelementsParents(source, host, task) + items := make([]any, 0) + for _, parent := range parents { + for _, subitem := range subelementItems(parent, subelement) { + items = append(items, []any{parent, subitem}) + } + } + + return items, nil +} + +func parseSubelementsSpec(loop any) (any, string, bool) { + switch v := loop.(type) { + case []any: + if len(v) < 2 { + return nil, "", false + } + return v[0], sprintf("%v", v[1]), true + case []string: + if len(v) < 2 { + return nil, "", false + } + return v[0], v[1], true + case string: + parts := strings.Fields(v) + if len(parts) < 2 { + return nil, "", false + } + return parts[0], parts[1], true + default: + return nil, "", false + } +} + +func (e *Executor) resolveSubelementsParents(value any, host string, task *Task) []any { + switch v := value.(type) { + case string: + if items := e.resolveLoopWithTask(v, host, task); len(items) > 0 { + return items + } + if items, ok := anySliceFromValue(e.templateString(v, host, task)); ok { + return items + } + if task != nil { + if items, ok := anySliceFromValue(task.Vars[v]); ok { + return items + } + } + default: + if items, ok := anySliceFromValue(v); ok { + return items + } + } + + return nil +} + +func subelementItems(parent any, path string) []any { + value, ok := lookupNestedValue(parent, path) + if !ok || value == nil { + return nil + } + + if items, ok := anySliceFromValue(value); ok { + return items + } + + return []any{value} +} + func parseSequenceSpec(loop any) (*sequenceSpec, error) { spec := &sequenceSpec{ step: 1, diff --git a/executor_test.go b/executor_test.go index de10d7a..011d8b3 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1003,6 +1003,41 @@ func TestExecutor_RunTaskOnHost_Good_LoopFromWithTogether(t *testing.T) { assert.Equal(t, "blue=large", result.Results[1].Msg) } +func TestExecutor_RunTaskOnHost_Good_LoopFromWithSubelements(t *testing.T) { + e := NewExecutor("/tmp") + e.clients["host1"] = NewMockSSHClient() + e.vars["users"] = []any{ + map[string]any{ + "name": "alice", + "authorized": []any{"ssh-rsa AAA", "ssh-ed25519 BBB"}, + }, + map[string]any{ + "name": "bob", + "authorized": "ssh-rsa CCC", + }, + } + + task := &Task{ + Name: "Subelements loop", + Module: "debug", + Args: map[string]any{ + "msg": "{{ item.0.name }}={{ item.1 }}", + }, + WithSubelements: []any{"{{ users }}", "authorized"}, + Register: "subelements_loop_result", + } + + err := e.runTaskOnHosts(context.Background(), []string{"host1"}, task, &Play{}) + require.NoError(t, err) + + result := e.results["host1"]["subelements_loop_result"] + require.NotNil(t, result) + require.Len(t, result.Results, 3) + assert.Equal(t, "alice=ssh-rsa AAA", result.Results[0].Msg) + assert.Equal(t, "alice=ssh-ed25519 BBB", result.Results[1].Msg) + assert.Equal(t, "bob=ssh-rsa CCC", result.Results[2].Msg) +} + func TestExecutor_RunTaskOnHosts_Good_LoopNotifiesAndCallsCallback(t *testing.T) { e := NewExecutor("/tmp") e.clients["host1"] = NewMockSSHClient() diff --git a/parser.go b/parser.go index 9a35c68..735ed39 100644 --- a/parser.go +++ b/parser.go @@ -357,7 +357,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { "action": true, "local_action": true, "include_tasks": true, "import_tasks": true, "include_role": true, "import_role": true, - "with_items": true, "with_dict": true, "with_indexed_items": true, "with_nested": true, "with_together": true, "with_file": true, "with_fileglob": true, "with_sequence": true, + "with_items": true, "with_dict": true, "with_indexed_items": true, "with_nested": true, "with_together": true, "with_subelements": true, "with_file": true, "with_fileglob": true, "with_sequence": true, } for key, val := range m { diff --git a/types.go b/types.go index 466b635..78a29b1 100644 --- a/types.go +++ b/types.go @@ -117,13 +117,14 @@ type Task struct { Until string `yaml:"until,omitempty"` // Include/import directives - IncludeTasks string `yaml:"include_tasks,omitempty"` - ImportTasks string `yaml:"import_tasks,omitempty"` - WithFile any `yaml:"with_file,omitempty"` - WithFileGlob any `yaml:"with_fileglob,omitempty"` - WithSequence any `yaml:"with_sequence,omitempty"` - WithTogether any `yaml:"with_together,omitempty"` - IncludeRole *struct { + IncludeTasks string `yaml:"include_tasks,omitempty"` + ImportTasks string `yaml:"import_tasks,omitempty"` + WithFile any `yaml:"with_file,omitempty"` + WithFileGlob any `yaml:"with_fileglob,omitempty"` + WithSequence any `yaml:"with_sequence,omitempty"` + WithTogether any `yaml:"with_together,omitempty"` + WithSubelements any `yaml:"with_subelements,omitempty"` + IncludeRole *struct { Name string `yaml:"name"` TasksFrom string `yaml:"tasks_from,omitempty"` DefaultsFrom string `yaml:"defaults_from,omitempty"` diff --git a/types_test.go b/types_test.go index ecaa2a5..48f6d51 100644 --- a/types_test.go +++ b/types_test.go @@ -400,6 +400,25 @@ with_together: assert.Equal(t, []any{"blue", "large"}, second) } +func TestTypes_Task_UnmarshalYAML_Good_WithSubelements(t *testing.T) { + input := ` +name: Subelement loop values +debug: + msg: "{{ item.0.name }} {{ item.1 }}" +with_subelements: + - users + - authorized +` + var task Task + err := yaml.Unmarshal([]byte(input), &task) + + require.NoError(t, err) + require.NotNil(t, task.WithSubelements) + values, ok := task.WithSubelements.([]any) + require.True(t, ok) + assert.Equal(t, []any{"users", "authorized"}, values) +} + func TestTypes_Task_UnmarshalYAML_Good_WithNotify(t *testing.T) { input := ` name: Install package