Add with_file loop support

This commit is contained in:
Virgil 2026-04-01 21:07:04 +00:00
parent df8a400553
commit 097aeec0d2
5 changed files with 128 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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