diff --git a/executor.go b/executor.go index f12ede9..b1d3903 100644 --- a/executor.go +++ b/executor.go @@ -8,6 +8,7 @@ import ( "io/fs" "path" "path/filepath" + "reflect" "regexp" "slices" "strconv" @@ -867,7 +868,7 @@ func (e *Executor) runLoop(ctx context.Context, host string, client sshExecutorC } else if task.WithTogether != nil { items, err = e.resolveWithTogetherLoop(task.WithTogether, host, task) } else { - items = e.resolveLoop(task.Loop, host) + items = e.resolveLoopWithTask(task.Loop, host, task) } if err != nil { return err @@ -2461,6 +2462,10 @@ func (e *Executor) handleLookup(expr string) string { // resolveLoop resolves loop items. func (e *Executor) resolveLoop(loop any, host string) []any { + return e.resolveLoopWithTask(loop, host, nil) +} + +func (e *Executor) resolveLoopWithTask(loop any, host string, task *Task) []any { switch v := loop.(type) { case []any: return v @@ -2471,10 +2476,17 @@ func (e *Executor) resolveLoop(loop any, host string) []any { } return items case string: + if items, ok := e.resolveLoopExpression(v, host, task); ok { + return items + } + // Template the string and see if it's a var reference - resolved := e.templateString(v, host, nil) - if val, ok := e.vars[resolved]; ok { - if items, ok := val.([]any); ok { + resolved := e.templateString(v, host, task) + if items, ok := anySliceFromValue(e.vars[resolved]); ok { + return items + } + if task != nil { + if items, ok := anySliceFromValue(task.Vars[resolved]); ok { return items } } @@ -2482,6 +2494,65 @@ func (e *Executor) resolveLoop(loop any, host string) []any { return nil } +func (e *Executor) resolveLoopExpression(loop string, host string, task *Task) ([]any, bool) { + expr, ok := extractSingleTemplateExpression(loop) + if !ok { + return nil, false + } + + if value, ok := e.lookupConditionValue(expr, host, task, nil); ok { + if items, ok := anySliceFromValue(value); ok { + return items, true + } + } + + return nil, false +} + +func extractSingleTemplateExpression(value string) (string, bool) { + re := regexp.MustCompile(`^\s*\{\{\s*(.+?)\s*\}\}\s*$`) + match := re.FindStringSubmatch(value) + if len(match) < 2 { + return "", false + } + + inner := strings.TrimSpace(match[1]) + if inner == "" { + return "", false + } + + return inner, true +} + +func anySliceFromValue(value any) ([]any, bool) { + switch v := value.(type) { + case nil: + return nil, false + case []any: + return append([]any(nil), v...), true + case []string: + items := make([]any, len(v)) + for i, item := range v { + items[i] = item + } + return items, true + } + + rv := reflect.ValueOf(value) + if !rv.IsValid() { + return nil, false + } + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + return nil, false + } + + items := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + items[i] = rv.Index(i).Interface() + } + return items, true +} + // matchesTags checks if task tags match execution tags. func (e *Executor) matchesTags(taskTags []string) bool { // Tasks tagged "always" should run even when an explicit include filter is diff --git a/executor_test.go b/executor_test.go index f21c9a7..3f80a59 100644 --- a/executor_test.go +++ b/executor_test.go @@ -1574,6 +1574,31 @@ func TestExecutor_ResolveLoop_Good_Nil(t *testing.T) { assert.Nil(t, items) } +func TestExecutor_RunTaskOnHost_Good_LoopFromTemplatedListVariable(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["items"] = []any{"alpha", "beta"} + e.clients["host1"] = NewMockSSHClient() + + task := &Task{ + Name: "Templated list loop", + Module: "debug", + Args: map[string]any{ + "msg": "{{ item }}", + }, + Loop: "{{ items }}", + Register: "loop_result", + } + + err := e.runTaskOnHosts(context.Background(), []string{"host1"}, task, &Play{}) + require.NoError(t, err) + + result := e.results["host1"]["loop_result"] + require.NotNil(t, result) + require.Len(t, result.Results, 2) + assert.Equal(t, "alpha", result.Results[0].Msg) + assert.Equal(t, "beta", result.Results[1].Msg) +} + // --- templateArgs --- func TestExecutor_TemplateArgs_Good(t *testing.T) { diff --git a/modules_infra_test.go b/modules_infra_test.go index 9372456..6b40e5d 100644 --- a/modules_infra_test.go +++ b/modules_infra_test.go @@ -581,15 +581,12 @@ func TestModulesInfra_ResolveLoop_Good_VarReference(t *testing.T) { e := NewExecutor("/tmp") e.vars["my_list"] = []any{"item1", "item2", "item3"} - // When loop is a string that resolves to a variable containing a list + // A templated loop source should resolve to the underlying list value. items := e.resolveLoop("{{ my_list }}", "host1") - // The template resolves "{{ my_list }}" but the result is a string representation, - // not the original list. The resolveLoop handles this by trying to look up - // the resolved value in vars again. - // Since templateString returns the string "[item1 item2 item3]", and that - // isn't a var name, items will be nil. This tests the edge case. - // The actual var name lookup happens when the loop value is just "my_list". - assert.Nil(t, items) + require.Len(t, items, 3) + assert.Equal(t, "item1", items[0]) + assert.Equal(t, "item2", items[1]) + assert.Equal(t, "item3", items[2]) } func TestModulesInfra_ResolveLoop_Good_MixedTypes(t *testing.T) {