feat(ansible): add extended loop metadata
Some checks failed
CI / auto-fix (push) Failing after 0s
CI / test (push) Failing after 3s
CI / auto-merge (push) Failing after 0s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 07:00:38 +00:00
parent fafc0febdf
commit a94680d71f
2 changed files with 142 additions and 35 deletions

View file

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

View file

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