diff --git a/executor.go b/executor.go index 599485e..7f9b0f7 100644 --- a/executor.go +++ b/executor.go @@ -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) diff --git a/executor_test.go b/executor_test.go index 1209384..d82aab1 100644 --- a/executor_test.go +++ b/executor_test.go @@ -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