feat(ansible): expose extended loop metadata

This commit is contained in:
Virgil 2026-04-01 20:06:14 +00:00
parent 6cc987ea74
commit 716ad80951
2 changed files with 107 additions and 0 deletions

View file

@ -635,6 +635,12 @@ func (e *Executor) runLoop(ctx context.Context, host string, client sshExecutorC
savedVars[indexVar] = v
}
}
var savedLoopMeta any
if task.LoopControl != nil && task.LoopControl.Extended {
if v, ok := e.vars["ansible_loop"]; ok {
savedLoopMeta = v
}
}
var results []TaskResult
for i, item := range items {
@ -643,6 +649,21 @@ func (e *Executor) runLoop(ctx context.Context, host string, client sshExecutorC
if indexVar != "" {
e.vars[indexVar] = i
}
if task.LoopControl != nil && task.LoopControl.Extended {
loopMeta := map[string]any{
"index": i + 1,
"index0": i,
"first": i == 0,
"last": i == len(items)-1,
"length": len(items),
"revindex": len(items) - i,
"revindex0": len(items) - i - 1,
}
if task.LoopControl.Label != "" {
loopMeta["label"] = e.templateString(task.LoopControl.Label, host, task)
}
e.vars["ansible_loop"] = loopMeta
}
result, err := e.runTaskWithRetries(ctx, host, task, play, func() (*TaskResult, error) {
return e.executeModule(ctx, host, client, task, play)
@ -680,6 +701,13 @@ func (e *Executor) runLoop(ctx context.Context, host string, client sshExecutorC
delete(e.vars, indexVar)
}
}
if task.LoopControl != nil && task.LoopControl.Extended {
if savedLoopMeta != nil {
e.vars["ansible_loop"] = savedLoopMeta
} else {
delete(e.vars, "ansible_loop")
}
}
// Store combined result
if task.Register != "" {
@ -1338,6 +1366,16 @@ func (e *Executor) resolveExpr(expr string, host string, task *Task) string {
}
}
// Resolve nested maps from vars, task vars, or host vars.
if contains(expr, ".") {
parts := splitN(expr, ".", 2)
if val, ok := e.lookupExprValue(parts[0], host, task); ok {
if nested, ok := lookupNestedValue(val, parts[1]); ok {
return sprintf("%v", nested)
}
}
}
// Check vars
if val, ok := e.vars[expr]; ok {
return sprintf("%v", val)
@ -1387,6 +1425,47 @@ func (e *Executor) resolveExpr(expr string, host string, task *Task) string {
return "{{ " + expr + " }}" // Return as-is if unresolved
}
// lookupExprValue resolves the first segment of an expression against the
// executor, task, and inventory scopes.
func (e *Executor) lookupExprValue(name string, host string, task *Task) (any, bool) {
if val, ok := e.vars[name]; ok {
return val, true
}
if task != nil {
if val, ok := task.Vars[name]; ok {
return val, true
}
}
if e.inventory != nil {
hostVars := GetHostVars(e.inventory, host)
if val, ok := hostVars[name]; ok {
return val, true
}
}
return nil, false
}
// lookupNestedValue walks a dotted path through nested maps.
func lookupNestedValue(value any, path string) (any, bool) {
if path == "" {
return value, true
}
current := value
for _, segment := range split(path, ".") {
next, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = next[segment]
if !ok {
return nil, false
}
}
return current, true
}
// applyFilter applies a Jinja2 filter.
func (e *Executor) applyFilter(value, filter string) string {
filter = corexTrimSpace(filter)

View file

@ -291,6 +291,34 @@ func TestExecutor_RunTaskOnHost_Good_LoopControlPause(t *testing.T) {
assert.GreaterOrEqual(t, elapsed, 900*time.Millisecond)
}
func TestExecutor_RunTaskOnHost_Good_LoopControlExtendedExposesMetadata(t *testing.T) {
e := NewExecutor("/tmp")
e.clients["host1"] = NewMockSSHClient()
task := &Task{
Name: "Extended loop metadata",
Module: "debug",
Args: map[string]any{
"msg": "{{ ansible_loop.label }} {{ ansible_loop.index0 }}/{{ ansible_loop.length }} first={{ ansible_loop.first }} last={{ ansible_loop.last }}",
},
Loop: []any{"one", "two"},
LoopControl: &LoopControl{
Extended: true,
Label: "{{ item }}",
},
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, "one 0/2 first=true last=false", result.Results[0].Msg)
assert.Equal(t, "two 1/2 first=false last=true", result.Results[1].Msg)
}
func TestExecutor_RunTaskWithRetries_Good_UntilSuccess(t *testing.T) {
e := NewExecutor("/tmp")
attempts := 0