diff --git a/executor.go b/executor.go index 279c416..f1c948c 100644 --- a/executor.go +++ b/executor.go @@ -549,8 +549,8 @@ 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 - if task.Loop != nil { + // Handle loops, including legacy with_file syntax. + if task.Loop != nil || task.WithFile != nil { return e.runLoop(ctx, host, client, task, play) } @@ -704,7 +704,18 @@ func shouldRetryTask(task *Task, host string, e *Executor, result *TaskResult) b // runLoop handles task loops. func (e *Executor) runLoop(ctx context.Context, host string, client sshExecutorClient, task *Task, play *Play) error { - items := e.resolveLoop(task.Loop, host) + var ( + items []any + err error + ) + if task.WithFile != nil { + items, err = e.resolveWithFileLoop(task.WithFile, host, task) + } else { + items = e.resolveLoop(task.Loop, host) + } + if err != nil { + return err + } loopVar := "item" if task.LoopControl != nil && task.LoopControl.LoopVar != "" { @@ -817,6 +828,61 @@ func (e *Executor) runLoop(ctx context.Context, host string, client sshExecutorC return nil } +// resolveWithFileLoop resolves legacy with_file loop items into file contents. +func (e *Executor) resolveWithFileLoop(loop any, host string, task *Task) ([]any, error) { + var paths []string + + switch v := loop.(type) { + case []any: + paths = make([]string, 0, len(v)) + for _, item := range v { + s := sprintf("%v", item) + if s = e.templateString(s, host, task); s != "" { + paths = append(paths, s) + } + } + case []string: + paths = make([]string, 0, len(v)) + for _, item := range v { + if s := e.templateString(item, host, task); s != "" { + paths = append(paths, s) + } + } + case string: + if s := e.templateString(v, host, task); s != "" { + paths = []string{s} + } + default: + return nil, nil + } + + items := make([]any, 0, len(paths)) + for _, filePath := range paths { + content, err := e.readLoopFile(filePath) + if err != nil { + return nil, err + } + items = append(items, content) + } + + return items, nil +} + +func (e *Executor) readLoopFile(filePath string) (string, error) { + candidates := []string{filePath} + if e.parser != nil && e.parser.basePath != "" { + candidates = append([]string{joinPath(e.parser.basePath, filePath)}, candidates...) + } + + for _, candidate := range candidates { + if data, err := coreio.Local.Read(candidate); err == nil { + return data, nil + } + } + + return "", coreerr.E("Executor.readLoopFile", "read file "+filePath, nil) +} + // isCheckModeSafeTask reports whether a task can run without changing state // during check mode. func isCheckModeSafeTask(task *Task) bool { diff --git a/executor_test.go b/executor_test.go index 3f45b8e..95f0e54 100644 --- a/executor_test.go +++ b/executor_test.go @@ -231,6 +231,40 @@ func TestExecutor_RunTaskOnHost_Good_DelegateToUsesDelegatedClient(t *testing.T) assert.Equal(t, 1, mock.commandCount()) } +func TestExecutor_RunTaskOnHosts_Good_WithFileUsesFileContents(t *testing.T) { + dir := t.TempDir() + require.NoError(t, writeTestFile(joinPath(dir, "fragments", "hello.txt"), []byte("hello from file"), 0644)) + + e := NewExecutor(dir) + mock := NewMockSSHClient() + e.SetInventoryDirect(&Inventory{ + All: &InventoryGroup{ + Hosts: map[string]*Host{ + "host1": {}, + }, + }, + }) + e.clients["host1"] = mock + + task := &Task{ + Name: "Read file loop", + Module: "debug", + Args: map[string]any{"msg": "{{ item }}"}, + Register: "debug_result", + WithFile: []any{ + "fragments/hello.txt", + }, + } + + err := e.runTaskOnHosts(context.Background(), []string{"host1"}, task, &Play{}) + require.NoError(t, err) + + require.NotNil(t, e.results["host1"]) + require.NotNil(t, e.results["host1"]["debug_result"]) + require.Len(t, e.results["host1"]["debug_result"].Results, 1) + assert.Equal(t, "hello from file", e.results["host1"]["debug_result"].Results[0].Msg) +} + func TestExecutor_ExecuteModule_Good_ShortFormCommunityAlias(t *testing.T) { e := NewExecutor("/tmp") mock := NewMockSSHClient() diff --git a/parser.go b/parser.go index de2a546..064b01c 100644 --- a/parser.go +++ b/parser.go @@ -388,6 +388,11 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { } } + // 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 + } + return nil } diff --git a/types.go b/types.go index fc9cba0..8300a05 100644 --- a/types.go +++ b/types.go @@ -114,6 +114,7 @@ type Task struct { // Include/import directives IncludeTasks string `yaml:"include_tasks,omitempty"` ImportTasks string `yaml:"import_tasks,omitempty"` + WithFile any `yaml:"with_file,omitempty"` IncludeRole *struct { Name string `yaml:"name"` TasksFrom string `yaml:"tasks_from,omitempty"` diff --git a/types_test.go b/types_test.go index dabba99..5f2e920 100644 --- a/types_test.go +++ b/types_test.go @@ -204,6 +204,25 @@ with_dict: assert.Equal(t, "two", second["value"]) } +func TestTypes_Task_UnmarshalYAML_Good_WithFile(t *testing.T) { + input := ` +name: Read files +debug: + msg: "{{ item }}" +with_file: + - templates/a.txt + - templates/b.txt +` + var task Task + err := yaml.Unmarshal([]byte(input), &task) + + require.NoError(t, err) + require.NotNil(t, task.WithFile) + files, ok := task.WithFile.([]any) + require.True(t, ok) + assert.Equal(t, []any{"templates/a.txt", "templates/b.txt"}, files) +} + func TestTypes_Task_UnmarshalYAML_Good_WithNotify(t *testing.T) { input := ` name: Install package