feat(ansible): add with_subelements loop support

This commit is contained in:
Virgil 2026-04-02 00:32:37 +00:00
parent 9f86b5cb95
commit 4e0a5f714c
5 changed files with 147 additions and 10 deletions

View file

@ -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,

View file

@ -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()

View file

@ -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 {

View file

@ -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"`

View file

@ -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