feat(ansible): expand templated loop sources

This commit is contained in:
Virgil 2026-04-01 23:59:45 +00:00
parent 8012570663
commit e1e2b6402e
3 changed files with 105 additions and 12 deletions

View file

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

View file

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

View file

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