From a94680d71f8d18f65d375613b07cb4560775a540 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 07:00:38 +0000 Subject: [PATCH] feat(ansible): add extended loop metadata Co-Authored-By: Virgil --- executor.go | 147 +++++++++++++++++++++++++++++++---------- executor_extra_test.go | 30 +++++++++ 2 files changed, 142 insertions(+), 35 deletions(-) diff --git a/executor.go b/executor.go index 42042e9..d2fd0fd 100644 --- a/executor.go +++ b/executor.go @@ -623,6 +623,10 @@ func (e *Executor) runLoop(ctx context.Context, host string, client *SSHClient, savedVars[indexVar] = v } } + extendedLoop := task.LoopControl != nil && task.LoopControl.Extended + if v, ok := e.vars["ansible_loop"]; ok { + savedVars["ansible_loop"] = v + } var results []TaskResult for i, item := range items { @@ -631,6 +635,11 @@ func (e *Executor) runLoop(ctx context.Context, host string, client *SSHClient, if indexVar != "" { e.vars[indexVar] = i } + if extendedLoop { + e.vars["ansible_loop"] = e.loopMetadata(item, i, len(items), task.LoopControl.Label, host, task) + } else { + delete(e.vars, "ansible_loop") + } result, err := e.executeModule(ctx, host, client, task, play) if err != nil { @@ -660,6 +669,11 @@ func (e *Executor) runLoop(ctx context.Context, host string, client *SSHClient, delete(e.vars, indexVar) } } + if v, ok := savedVars["ansible_loop"]; ok { + e.vars["ansible_loop"] = v + } else { + delete(e.vars, "ansible_loop") + } // Store combined result if task.Register != "" { @@ -1048,6 +1062,96 @@ func (e *Executor) getRegisteredVar(host string, name string) *TaskResult { return nil } +func (e *Executor) lookupTemplateValue(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 + } + } + + switch name { + case "ansible_hostname": + if facts, ok := e.facts[host]; ok { + return facts.Hostname, true + } + case "ansible_fqdn": + if facts, ok := e.facts[host]; ok { + return facts.FQDN, true + } + case "ansible_distribution": + if facts, ok := e.facts[host]; ok { + return facts.Distribution, true + } + case "ansible_distribution_version": + if facts, ok := e.facts[host]; ok { + return facts.Version, true + } + case "ansible_architecture": + if facts, ok := e.facts[host]; ok { + return facts.Architecture, true + } + case "ansible_kernel": + if facts, ok := e.facts[host]; ok { + return facts.Kernel, true + } + } + + return nil, false +} + +func resolveDottedValue(value any, path string) (any, bool) { + current := value + for _, part := range split(path, ".") { + switch v := current.(type) { + case map[string]any: + next, ok := v[part] + if !ok { + return nil, false + } + current = next + case map[string]string: + next, ok := v[part] + if !ok { + return nil, false + } + current = next + default: + return nil, false + } + } + return current, true +} + +func (e *Executor) loopMetadata(item any, index, length int, label string, host string, task *Task) map[string]any { + metadata := map[string]any{ + "index": index + 1, + "index0": index, + "revindex": length - index, + "revindex0": length - index - 1, + "first": index == 0, + "last": index == length-1, + "length": length, + "item": item, + } + + if label != "" { + metadata["label"] = e.templateString(label, host, task) + } + + return metadata +} + // templateString applies Jinja2-like templating. func (e *Executor) templateString(s string, host string, task *Task) string { // Handle {{ var }} syntax @@ -1090,46 +1194,19 @@ func (e *Executor) resolveExpr(expr string, host string, task *Task) string { return sprintf("%t", result.Failed) } } + + if root, ok := e.lookupTemplateValue(parts[0], host, task); ok { + if resolved, ok := resolveDottedValue(root, parts[1]); ok { + return sprintf("%v", resolved) + } + } } - // Check vars - if val, ok := e.vars[expr]; ok { + // Check vars, task vars, and host vars + if val, ok := e.lookupTemplateValue(expr, host, task); ok { return sprintf("%v", val) } - // Check task vars - if task != nil { - if val, ok := task.Vars[expr]; ok { - return sprintf("%v", val) - } - } - - // Check host vars - if e.inventory != nil { - hostVars := GetHostVars(e.inventory, host) - if val, ok := hostVars[expr]; ok { - return sprintf("%v", val) - } - } - - // Check facts - if facts, ok := e.facts[host]; ok { - switch expr { - case "ansible_hostname": - return facts.Hostname - case "ansible_fqdn": - return facts.FQDN - case "ansible_distribution": - return facts.Distribution - case "ansible_distribution_version": - return facts.Version - case "ansible_architecture": - return facts.Architecture - case "ansible_kernel": - return facts.Kernel - } - } - return "{{ " + expr + " }}" // Return as-is if unresolved } diff --git a/executor_extra_test.go b/executor_extra_test.go index d66e725..1ca1693 100644 --- a/executor_extra_test.go +++ b/executor_extra_test.go @@ -459,3 +459,33 @@ func TestExecutorExtra_ResolveExpr_Good_WithFilter(t *testing.T) { result := e.resolveExpr("raw_value | trim", "host1", nil) assert.Equal(t, "trimmed", result) } + +func TestExecutorExtra_LoopMetadata_Good_ExtendedMetadata(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["item"] = "alpha" + + metadata := e.loopMetadata("alpha", 1, 3, "item {{ item }}", "host1", &Task{}) + + assert.Equal(t, 2, metadata["index"]) + assert.Equal(t, 1, metadata["index0"]) + assert.Equal(t, 2, metadata["revindex"]) + assert.Equal(t, 1, metadata["revindex0"]) + assert.False(t, metadata["first"].(bool)) + assert.False(t, metadata["last"].(bool)) + assert.Equal(t, 3, metadata["length"]) + assert.Equal(t, "alpha", metadata["item"]) + assert.Equal(t, "item alpha", metadata["label"]) +} + +func TestExecutorExtra_TemplateString_Good_DottedMapAccess(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["ansible_loop"] = map[string]any{ + "index0": 1, + "first": false, + "last": true, + } + + got := e.templateString("{{ ansible_loop.index0 }} {{ ansible_loop.first }} {{ ansible_loop.last }}", "host1", nil) + + assert.Equal(t, "1 false true", got) +} -- 2.45.3