diff --git a/executor.go b/executor.go index 7d1ed3b..6494efd 100644 --- a/executor.go +++ b/executor.go @@ -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 } } diff --git a/executor_test.go b/executor_test.go index 705ded6..39f9a9e 100644 --- a/executor_test.go +++ b/executor_test.go @@ -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 diff --git a/parser.go b/parser.go index e7f26af..9d72ecb 100644 --- a/parser.go +++ b/parser.go @@ -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 diff --git a/types_test.go b/types_test.go index 5f2e920..8b5bf28 100644 --- a/types_test.go +++ b/types_test.go @@ -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