feat(ansible): support indexed loop items

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 22:18:31 +00:00
parent 8130be049a
commit 92634bf561
4 changed files with 94 additions and 7 deletions

View file

@ -1965,12 +1965,26 @@ func lookupNestedValue(value any, path string) (any, bool) {
current := value
for _, segment := range split(path, ".") {
next, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = next[segment]
if !ok {
switch next := current.(type) {
case map[string]any:
var ok bool
current, ok = next[segment]
if !ok {
return nil, false
}
case []any:
index, err := strconv.Atoi(segment)
if err != nil || index < 0 || index >= len(next) {
return nil, false
}
current = next[index]
case []string:
index, err := strconv.Atoi(segment)
if err != nil || index < 0 || index >= len(next) {
return nil, false
}
current = next[index]
default:
return nil, false
}
}

View file

@ -639,6 +639,33 @@ func TestExecutor_RunTaskOnHost_Good_LoopFromWithDictItems(t *testing.T) {
assert.Equal(t, "beta=two", result.Results[1].Msg)
}
func TestExecutor_RunTaskOnHost_Good_LoopFromWithIndexedItems(t *testing.T) {
e := NewExecutor("/tmp")
e.clients["host1"] = NewMockSSHClient()
task := &Task{
Name: "Indexed loop",
Module: "debug",
Args: map[string]any{
"msg": "{{ item.0 }}={{ item.1 }}",
},
Loop: []any{
[]any{0, "apple"},
[]any{1, "banana"},
},
Register: "indexed_loop_result",
}
err := e.runTaskOnHosts(context.Background(), []string{"host1"}, task, &Play{})
require.NoError(t, err)
result := e.results["host1"]["indexed_loop_result"]
require.NotNil(t, result)
require.Len(t, result.Results, 2)
assert.Equal(t, "0=apple", result.Results[0].Msg)
assert.Equal(t, "1=banana", result.Results[1].Msg)
}
func TestExecutor_RunTaskWithRetries_Good_UntilSuccess(t *testing.T) {
e := NewExecutor("/tmp")
attempts := 0

View file

@ -348,7 +348,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
"retries": true, "delay": true, "until": true,
"include_tasks": true, "import_tasks": true,
"include_role": true, "import_role": true,
"with_items": true, "with_dict": true, "with_file": true,
"with_items": true, "with_dict": true, "with_indexed_items": true, "with_file": true,
}
for key, val := range m {
@ -413,6 +413,24 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
}
}
// Handle with_indexed_items as a loop of [index, value] pairs.
if indexed, ok := m["with_indexed_items"]; ok && t.Loop == nil {
switch v := indexed.(type) {
case []any:
items := make([]any, 0, len(v))
for i, item := range v {
items = append(items, []any{i, item})
}
t.Loop = items
case []string:
items := make([]any, 0, len(v))
for i, item := range v {
items = append(items, []any{i, item})
}
t.Loop = items
}
}
// Preserve with_file so the executor can resolve file contents at runtime.
if files, ok := m["with_file"]; ok && t.WithFile == nil {
t.WithFile = files

View file

@ -204,6 +204,34 @@ with_dict:
assert.Equal(t, "two", second["value"])
}
func TestTypes_Task_UnmarshalYAML_Good_WithIndexedItems(t *testing.T) {
input := `
name: Indexed loop
debug:
msg: "{{ item.0 }}={{ item.1 }}"
with_indexed_items:
- apple
- banana
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
items, ok := task.Loop.([]any)
require.True(t, ok)
require.Len(t, items, 2)
first, ok := items[0].([]any)
require.True(t, ok)
assert.Equal(t, 0, first[0])
assert.Equal(t, "apple", first[1])
second, ok := items[1].([]any)
require.True(t, ok)
assert.Equal(t, 1, second[0])
assert.Equal(t, "banana", second[1])
}
func TestTypes_Task_UnmarshalYAML_Good_WithFile(t *testing.T) {
input := `
name: Read files