diff --git a/async_features.go b/async_features.go new file mode 100644 index 0000000..763a099 --- /dev/null +++ b/async_features.go @@ -0,0 +1,479 @@ +package ansible + +import ( + "context" + "crypto/rand" + "encoding/hex" + "time" +) + +// newAsyncJobID creates a best-effort unique identifier for detached task state. +func newAsyncJobID() string { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} + +// launchDetachedAsyncTask starts an async task on an isolated executor clone. +func (e *Executor) launchDetachedAsyncTask(ctx context.Context, host string, hosts []string, task *Task, play *Play) { + if e == nil || task == nil { + return + } + + baseCtx := ctx + timeout := time.Duration(task.Async) * time.Second + + clone := e.cloneAsyncExecutor() + cloneTask := cloneTaskForAsync(task) + cloneTask.Async = 0 + cloneTask.Poll = 0 + clonePlay := clonePlayForAsync(play) + cloneHosts := append([]string(nil), hosts...) + + go func() { + asyncCtx := baseCtx + if timeout > 0 { + var cancel context.CancelFunc + asyncCtx, cancel = context.WithTimeout(baseCtx, timeout) + defer cancel() + } + + _ = clone.runTaskOnHost(asyncCtx, host, cloneHosts, &cloneTask, clonePlay) + }() +} + +// cloneAsyncExecutor snapshots executor state for detached task execution. +func (e *Executor) cloneAsyncExecutor() *Executor { + if e == nil { + return nil + } + + clone := &Executor{ + parser: cloneParser(e.parser), + inventory: cloneInventory(e.inventory), + inventoryPath: e.inventoryPath, + vars: cloneAnyMap(e.vars), + hostVars: cloneHostVarsMap(e.hostVars), + hostFacts: cloneHostVarsMap(e.hostFacts), + facts: cloneFactsMap(e.facts), + results: cloneResultsMap(e.results), + handlers: cloneTaskHandlersMap(e.handlers), + loadedRoleHandlers: cloneBoolMap(e.loadedRoleHandlers), + notified: cloneBoolMap(e.notified), + clients: cloneClientMap(e.clients), + batchFailedHosts: cloneBoolMap(e.batchFailedHosts), + endedHosts: cloneBoolMap(e.endedHosts), + Limit: e.Limit, + Tags: append([]string(nil), e.Tags...), + SkipTags: append([]string(nil), e.SkipTags...), + CheckMode: e.CheckMode, + Diff: e.Diff, + Verbose: e.Verbose, + } + + return clone +} + +// cloneClientMap recreates cached clients without sharing task-level state. +func cloneClientMap(src map[string]sshExecutorClient) map[string]sshExecutorClient { + if len(src) == 0 { + return nil + } + + dst := make(map[string]sshExecutorClient, len(src)) + for key, client := range src { + if clone := cloneExecutorClient(client); clone != nil { + dst[key] = clone + } + } + return dst +} + +// cloneExecutorClient copies immutable connection settings into a fresh client. +func cloneExecutorClient(client sshExecutorClient) sshExecutorClient { + switch cached := client.(type) { + case *localClient: + return newLocalClient() + case *SSHClient: + cached.mu.Lock() + cfg := SSHConfig{ + Host: cached.host, + Port: cached.port, + User: cached.user, + Password: cached.password, + KeyFile: cached.keyFile, + Timeout: cached.timeout, + } + cached.mu.Unlock() + + clone, err := NewSSHClient(cfg) + if err != nil { + return nil + } + return clone + case *environmentSSHClient: + inner := cloneExecutorClient(cached.sshExecutorClient) + if inner == nil { + return nil + } + return &environmentSSHClient{sshExecutorClient: inner, prefix: cached.prefix} + default: + return nil + } +} + +// cloneParser copies parser configuration used by async task rendering. +func cloneParser(parser *Parser) *Parser { + if parser == nil { + return nil + } + + clone := &Parser{ + basePath: parser.basePath, + medium: parser.configuredMedium(), + vars: cloneAnyMap(parser.vars), + } + return clone +} + +// clonePlayForAsync deep-copies play fields that async tasks may read. +func clonePlayForAsync(play *Play) *Play { + if play == nil { + return nil + } + + clone := *play + clone.Vars = cloneAnyMap(play.Vars) + clone.ModuleDefaults = cloneModuleDefaults(play.ModuleDefaults) + clone.PreTasks = cloneTaskSlice(play.PreTasks) + clone.Tasks = cloneTaskSlice(play.Tasks) + clone.PostTasks = cloneTaskSlice(play.PostTasks) + clone.Roles = cloneRoleRefSlice(play.Roles) + clone.Handlers = cloneTaskSlice(play.Handlers) + clone.Tags = append([]string(nil), play.Tags...) + clone.Environment = cloneStringMap(play.Environment) + clone.Serial = cloneAnyValue(play.Serial) + if play.GatherFacts != nil { + value := *play.GatherFacts + clone.GatherFacts = &value + } + if play.VarsFiles != nil { + clone.VarsFiles = cloneAnyValue(play.VarsFiles) + } + + return &clone +} + +// cloneTaskForAsync deep-copies task fields before detached execution. +func cloneTaskForAsync(task *Task) Task { + if task == nil { + return Task{} + } + + clone := *task + clone.Args = cloneAnyMap(task.Args) + clone.Vars = cloneAnyMap(task.Vars) + clone.Environment = cloneStringMap(task.Environment) + clone.Tags = append([]string(nil), task.Tags...) + clone.Block = cloneTaskSlice(task.Block) + clone.Rescue = cloneTaskSlice(task.Rescue) + clone.Always = cloneTaskSlice(task.Always) + clone.Loop = cloneAnyValue(task.Loop) + clone.Notify = cloneAnyValue(task.Notify) + clone.Listen = cloneAnyValue(task.Listen) + clone.IncludeRole = cloneRoleRefPtr(task.IncludeRole) + clone.ImportRole = cloneRoleRefPtr(task.ImportRole) + clone.Apply = cloneTaskApplyPtr(task.Apply) + clone.LoopControl = cloneLoopControlPtr(task.LoopControl) + return clone +} + +// cloneTaskSlice deep-copies a task slice. +func cloneTaskSlice(tasks []Task) []Task { + if len(tasks) == 0 { + return nil + } + + clone := make([]Task, len(tasks)) + for i := range tasks { + clone[i] = cloneTaskForAsync(&tasks[i]) + } + return clone +} + +// cloneRoleRefSlice deep-copies role references. +func cloneRoleRefSlice(roles []RoleRef) []RoleRef { + if len(roles) == 0 { + return nil + } + + clone := make([]RoleRef, len(roles)) + for i := range roles { + clone[i] = cloneRoleRef(roles[i]) + } + return clone +} + +// cloneRoleRefPtr deep-copies an optional role reference. +func cloneRoleRefPtr(role *RoleRef) *RoleRef { + if role == nil { + return nil + } + clone := cloneRoleRef(*role) + return &clone +} + +// cloneRoleRef deep-copies mutable fields in a role reference. +func cloneRoleRef(role RoleRef) RoleRef { + clone := role + clone.Vars = cloneAnyMap(role.Vars) + clone.Tags = append([]string(nil), role.Tags...) + clone.Apply = cloneTaskApplyPtr(role.Apply) + clone.When = cloneAnyValue(role.When) + return clone +} + +// cloneTaskApplyPtr deep-copies include-role apply options. +func cloneTaskApplyPtr(apply *TaskApply) *TaskApply { + if apply == nil { + return nil + } + clone := *apply + clone.Tags = append([]string(nil), apply.Tags...) + clone.Vars = cloneAnyMap(apply.Vars) + clone.Environment = cloneStringMap(apply.Environment) + clone.When = cloneAnyValue(apply.When) + return &clone +} + +// cloneLoopControlPtr copies loop-control settings. +func cloneLoopControlPtr(control *LoopControl) *LoopControl { + if control == nil { + return nil + } + clone := *control + return &clone +} + +// cloneModuleDefaults deep-copies module default arguments. +func cloneModuleDefaults(src map[string]map[string]any) map[string]map[string]any { + if len(src) == 0 { + return nil + } + + dst := make(map[string]map[string]any, len(src)) + for key, value := range src { + dst[key] = cloneAnyMap(value) + } + return dst +} + +// cloneInventory deep-copies inventory state for async host lookups. +func cloneInventory(inv *Inventory) *Inventory { + if inv == nil { + return nil + } + + clone := &Inventory{ + All: cloneInventoryGroup(inv.All), + HostVars: cloneHostVarsMap(inv.HostVars), + } + return clone +} + +// cloneInventoryGroup deep-copies an inventory group tree. +func cloneInventoryGroup(group *InventoryGroup) *InventoryGroup { + if group == nil { + return nil + } + + clone := &InventoryGroup{ + Vars: cloneAnyMap(group.Vars), + } + if len(group.Hosts) > 0 { + clone.Hosts = make(map[string]*Host, len(group.Hosts)) + for name, host := range group.Hosts { + clone.Hosts[name] = cloneHost(host) + } + } + if len(group.Children) > 0 { + clone.Children = make(map[string]*InventoryGroup, len(group.Children)) + for name, child := range group.Children { + clone.Children[name] = cloneInventoryGroup(child) + } + } + return clone +} + +// cloneHost deep-copies host variables. +func cloneHost(host *Host) *Host { + if host == nil { + return nil + } + + clone := *host + clone.Vars = cloneAnyMap(host.Vars) + return &clone +} + +// cloneHostVarsMap deep-copies host-scoped variable maps. +func cloneHostVarsMap(src map[string]map[string]any) map[string]map[string]any { + if len(src) == 0 { + return nil + } + + dst := make(map[string]map[string]any, len(src)) + for key, value := range src { + dst[key] = cloneAnyMap(value) + } + return dst +} + +// cloneStringMap copies string maps. +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + + dst := make(map[string]string, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + +// cloneAnyMap deep-copies supported dynamic map values. +func cloneAnyMap(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = cloneAnyValue(value) + } + return dst +} + +// cloneBoolMap copies boolean maps. +func cloneBoolMap(src map[string]bool) map[string]bool { + if len(src) == 0 { + return nil + } + + dst := make(map[string]bool, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + +// cloneFactsMap deep-copies gathered fact snapshots. +func cloneFactsMap(src map[string]*Facts) map[string]*Facts { + if len(src) == 0 { + return nil + } + + dst := make(map[string]*Facts, len(src)) + for key, value := range src { + if value == nil { + dst[key] = nil + continue + } + clone := *value + dst[key] = &clone + } + return dst +} + +// cloneResultsMap deep-copies registered task results by host. +func cloneResultsMap(src map[string]map[string]*TaskResult) map[string]map[string]*TaskResult { + if len(src) == 0 { + return nil + } + + dst := make(map[string]map[string]*TaskResult, len(src)) + for host, hostResults := range src { + if len(hostResults) == 0 { + continue + } + clonedHostResults := make(map[string]*TaskResult, len(hostResults)) + for name, result := range hostResults { + clonedHostResults[name] = cloneTaskResult(result) + } + dst[host] = clonedHostResults + } + return dst +} + +// cloneTaskHandlersMap deep-copies handler registrations. +func cloneTaskHandlersMap(src map[string][]Task) map[string][]Task { + if len(src) == 0 { + return nil + } + + dst := make(map[string][]Task, len(src)) + for name, tasks := range src { + dst[name] = cloneTaskSlice(tasks) + } + return dst +} + +// cloneTaskResult deep-copies a task result and nested loop results. +func cloneTaskResult(result *TaskResult) *TaskResult { + if result == nil { + return nil + } + + clone := *result + if len(result.Results) > 0 { + clone.Results = make([]TaskResult, len(result.Results)) + for i := range result.Results { + cloned := cloneTaskResult(&result.Results[i]) + if cloned != nil { + clone.Results[i] = *cloned + } + } + } + if len(result.Data) > 0 { + clone.Data = cloneAnyMap(result.Data) + } + return &clone +} + +// cloneAnyValue deep-copies supported dynamic values used by play state. +func cloneAnyValue(value any) any { + switch v := value.(type) { + case nil: + return nil + case string, bool, + int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return value + case map[string]any: + return cloneAnyMap(v) + case map[any]any: + dst := make(map[any]any, len(v)) + for key, item := range v { + dst[key] = cloneAnyValue(item) + } + return dst + case []any: + dst := make([]any, len(v)) + for i, item := range v { + dst[i] = cloneAnyValue(item) + } + return dst + case []string: + return append([]string(nil), v...) + case []Task: + return cloneTaskSlice(v) + case []RoleRef: + return cloneRoleRefSlice(v) + default: + return value + } +} diff --git a/cmd/ansible/ansible.go b/cmd/ansible/ansible.go index 2282a17..12b8549 100644 --- a/cmd/ansible/ansible.go +++ b/cmd/ansible/ansible.go @@ -9,9 +9,9 @@ import ( "time" "dappco.re/go/core" - "dappco.re/go/core/ansible" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + "dappco.re/go/ansible" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" "gopkg.in/yaml.v3" ) diff --git a/executor.go b/executor.go index 26b49cc..9080af4 100644 --- a/executor.go +++ b/executor.go @@ -4,7 +4,6 @@ import ( "context" "crypto/rand" "crypto/sha256" - "encoding/base64" "encoding/binary" "errors" "io" @@ -22,8 +21,8 @@ import ( "sync" "time" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" "gopkg.in/yaml.v3" ) @@ -53,10 +52,18 @@ type environmentSSHClient struct { prefix string } +// Run executes cmd over the wrapped SSH client with the configured +// environment prefix prepended. Returns stdout, stderr, exit code, error. +// +// stdout, stderr, code, err := c.Run(ctx, "uname -a") func (c *environmentSSHClient) Run(ctx context.Context, cmd string) (string, string, int, error) { return c.sshExecutorClient.Run(ctx, c.prefix+cmd) } +// RunScript executes script over the wrapped SSH client with the configured +// environment prefix prepended. Returns stdout, stderr, exit code, error. +// +// stdout, stderr, code, err := c.RunScript(ctx, "#!/bin/bash\necho hi") func (c *environmentSSHClient) RunScript(ctx context.Context, script string) (string, string, int, error) { return c.sshExecutorClient.RunScript(ctx, c.prefix+script) } @@ -159,6 +166,18 @@ func (e *Executor) SetVar(key string, value any) { e.vars[key] = value } +// SetMedium configures the storage medium used by the embedded parser. +// +// Example: +// +// executor.SetMedium(coreio.Local) +func (e *Executor) SetMedium(medium coreio.Medium) { + if e == nil || e.parser == nil { + return + } + e.parser.SetMedium(medium) +} + func (e *Executor) setHostVars(host string, values map[string]any) { if host == "" || len(values) == 0 { return @@ -604,7 +623,7 @@ func (e *Executor) loadPlayVarsFiles(play *Play) error { merged := make(map[string]any) for _, file := range files { resolved := e.resolveLocalPath(e.templateString(file, "", nil)) - data, err := coreio.Local.Read(resolved) + data, err := e.parser.readFile(resolved) if err != nil { return coreerr.E("Executor.loadPlayVarsFiles", "read vars file", err) } @@ -1067,6 +1086,40 @@ func (e *Executor) runTaskOnHost(ctx context.Context, host string, hosts []strin e.results[host] = make(map[string]*TaskResult) } + execCtx := ctx + if task.Async > 0 && task.Poll > 0 { + timeout := time.Duration(task.Async) * time.Second + if timeout > 0 { + var cancel context.CancelFunc + execCtx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + } + + if task.Async > 0 && task.Poll <= 0 { + result := &TaskResult{ + Changed: false, + Msg: "async task started", + Data: map[string]any{ + "ansible_job_id": newAsyncJobID(), + "started": 1, + "finished": 0, + }, + } + if task.Register != "" { + e.results[host][task.Register] = result + } + displayResult := result + if task.NoLog { + displayResult = redactTaskResult(result) + } + if e.OnTaskEnd != nil { + e.OnTaskEnd(host, task, displayResult) + } + e.launchDetachedAsyncTask(ctx, host, hosts, task, play) + return nil + } + hasLoop := task.Loop != nil || task.WithFile != nil || task.WithFileGlob != nil || task.WithSequence != nil || task.WithTogether != nil || task.WithSubelements != nil // Check when condition before allocating a client for simple tasks. Loop @@ -1107,7 +1160,7 @@ func (e *Executor) runTaskOnHost(ctx context.Context, host string, hosts []strin // Handle loops, including legacy with_file, with_fileglob, with_sequence, // with_together, and with_subelements syntax. if hasLoop { - return e.runLoop(ctx, host, client, task, play, start) + return e.runLoop(execCtx, host, client, task, play, start) } // Check when conditions for non-loop tasks after loop-only variables have @@ -1126,8 +1179,8 @@ func (e *Executor) runTaskOnHost(ctx context.Context, host string, hosts []strin } // Execute the task, honouring retries/until when configured. - result, err := e.runTaskWithRetries(ctx, host, task, play, func() (*TaskResult, error) { - return e.executeModule(ctx, host, client, task, play) + result, err := e.runTaskWithRetries(execCtx, host, task, play, func() (*TaskResult, error) { + return e.executeModule(execCtx, host, client, task, play) }) if err != nil { result = &TaskResult{Failed: true, Msg: err.Error()} @@ -2586,28 +2639,8 @@ func (e *Executor) evalConditionWithLocals(cond string, host string, task *Task, return !e.evalConditionWithLocals(corexTrimPrefix(cond, "not "), host, task, locals) } - // Handle equality/inequality - if contains(cond, "==") { - parts := splitN(cond, "==", 2) - if len(parts) == 2 { - left, leftOK := e.resolveConditionOperand(parts[0], host, task, locals) - right, rightOK := e.resolveConditionOperand(parts[1], host, task, locals) - if !leftOK || !rightOK { - return true - } - return left == right - } - } - if contains(cond, "!=") { - parts := splitN(cond, "!=", 2) - if len(parts) == 2 { - left, leftOK := e.resolveConditionOperand(parts[0], host, task, locals) - right, rightOK := e.resolveConditionOperand(parts[1], host, task, locals) - if !leftOK || !rightOK { - return true - } - return left != right - } + if result, handled := e.evalBinaryCondition(cond, host, task, locals); handled { + return result } // Handle boolean literals @@ -2838,13 +2871,235 @@ func isConditionBoundary(ch byte) bool { } } -func (e *Executor) lookupConditionValue(name string, host string, task *Task, locals map[string]any) (any, bool) { - name = corexTrimSpace(name) +// evalBinaryCondition evaluates recognised binary when-expression operators. +func (e *Executor) evalBinaryCondition(cond string, host string, task *Task, locals map[string]any) (bool, bool) { + ops := []string{"not in", "contains", "<=", ">=", "==", "!=", "<", ">", "in"} + for _, op := range ops { + left, right, ok := splitBinaryCondition(cond, op) + if !ok { + continue + } - if value, ok := e.hostMagicVars(host)[name]; ok { + leftValue, leftOK := e.resolveConditionOperandValue(left, host, task, locals) + rightValue, rightOK := e.resolveConditionOperandValue(right, host, task, locals) + if !leftOK || !rightOK { + return false, true + } + + switch op { + case "==": + return templateConditionEqual(leftValue, rightValue), true + case "!=": + return !templateConditionEqual(leftValue, rightValue), true + case "<": + return templateConditionCompare(leftValue, rightValue) < 0, true + case ">": + return templateConditionCompare(leftValue, rightValue) > 0, true + case "<=": + return templateConditionCompare(leftValue, rightValue) <= 0, true + case ">=": + return templateConditionCompare(leftValue, rightValue) >= 0, true + case "in": + return templateConditionContains(rightValue, leftValue), true + case "not in": + return !templateConditionContains(rightValue, leftValue), true + case "contains": + return templateConditionContains(leftValue, rightValue), true + } + } + + return false, false +} + +// splitBinaryCondition splits a condition around an operator outside quotes. +func splitBinaryCondition(cond, op string) (string, string, bool) { + depth := 0 + inSingle := false + inDouble := false + escaped := false + wordOp := isWordConditionOperator(op) + + for i := 0; i <= len(cond)-len(op); i++ { + ch := cond[i] + if escaped { + escaped = false + continue + } + if ch == '\\' && (inSingle || inDouble) { + escaped = true + continue + } + if ch == '\'' && !inDouble { + inSingle = !inSingle + continue + } + if ch == '"' && !inSingle { + inDouble = !inDouble + continue + } + if inSingle || inDouble { + continue + } + switch ch { + case '(': + depth++ + continue + case ')': + if depth > 0 { + depth-- + } + continue + } + + if depth != 0 || !strings.HasPrefix(cond[i:], op) { + continue + } + + end := i + len(op) + if wordOp { + if i > 0 && !isConditionBoundary(cond[i-1]) { + continue + } + if end < len(cond) && !isConditionBoundary(cond[end]) { + continue + } + } + + left := corexTrimSpace(cond[:i]) + right := corexTrimSpace(cond[end:]) + if left == "" || right == "" { + continue + } + return left, right, true + } + + return "", "", false +} + +// isWordConditionOperator reports whether an operator needs token boundaries. +func isWordConditionOperator(op string) bool { + for i := 0; i < len(op); i++ { + ch := op[i] + if ch == '_' || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') { + return true + } + } + return false +} + +// resolveConditionOperandValue resolves a condition operand to a native value. +func (e *Executor) resolveConditionOperandValue(expr string, host string, task *Task, locals map[string]any) (any, bool) { + expr = corexTrimSpace(expr) + if expr == "" { + return "", true + } + + switch expr { + case "true", "True": + return true, true + case "false", "False": + return false, true + } + + if len(expr) >= 2 { + if (expr[0] == '\'' && expr[len(expr)-1] == '\'') || (expr[0] == '"' && expr[len(expr)-1] == '"') { + return expr[1 : len(expr)-1], true + } + } + + if i, err := strconv.Atoi(expr); err == nil { + return i, true + } + if f, err := strconv.ParseFloat(expr, 64); err == nil { + return f, true + } + + if value, ok := e.lookupConditionValue(expr, host, task, locals); ok { return value, true } + return expr, false +} + +// templateConditionEqual compares condition operands using numeric coercion first. +func templateConditionEqual(left, right any) bool { + if leftFloat, leftOK := templateFloat(left); leftOK { + if rightFloat, rightOK := templateFloat(right); rightOK { + return leftFloat == rightFloat + } + } + + return reflect.DeepEqual(left, right) || templateStringify(left) == templateStringify(right) +} + +// templateConditionCompare orders condition operands numerically or lexically. +func templateConditionCompare(left, right any) int { + if leftFloat, leftOK := templateFloat(left); leftOK { + if rightFloat, rightOK := templateFloat(right); rightOK { + switch { + case leftFloat < rightFloat: + return -1 + case leftFloat > rightFloat: + return 1 + default: + return 0 + } + } + } + + leftStr := templateStringify(left) + rightStr := templateStringify(right) + switch { + case leftStr < rightStr: + return -1 + case leftStr > rightStr: + return 1 + default: + return 0 + } +} + +// templateConditionContains implements Ansible-style membership checks. +func templateConditionContains(container, item any) bool { + if container == nil { + return false + } + + if containerStr, ok := container.(string); ok { + return strings.Contains(containerStr, templateStringify(item)) + } + + if items, ok := anySliceFromValue(container); ok { + for _, candidate := range items { + if templateConditionEqual(candidate, item) || templateStringify(candidate) == templateStringify(item) { + return true + } + } + return false + } + + rv := reflect.ValueOf(container) + if !rv.IsValid() { + return false + } + if rv.Kind() == reflect.Map { + for _, key := range rv.MapKeys() { + if templateStringify(key.Interface()) == templateStringify(item) { + return true + } + if templateConditionEqual(key.Interface(), item) { + return true + } + } + return false + } + + return strings.Contains(templateStringify(container), templateStringify(item)) +} + +func (e *Executor) lookupConditionValue(name string, host string, task *Task, locals map[string]any) (any, bool) { + name = corexTrimSpace(name) + if locals != nil { if val, ok := locals[name]; ok { return val, true @@ -2870,10 +3125,6 @@ func (e *Executor) lookupConditionValue(name string, host string, task *Task, lo return result, true } - if val, ok := e.vars[name]; ok { - return val, true - } - if task != nil { if val, ok := task.Vars[name]; ok { return val, true @@ -2886,6 +3137,10 @@ func (e *Executor) lookupConditionValue(name string, host string, task *Task, lo } } + if val, ok := e.vars[name]; ok { + return val, true + } + if e.inventory != nil { hostVars := GetHostVars(e.inventory, host) if val, ok := hostVars[name]; ok { @@ -2893,12 +3148,11 @@ func (e *Executor) lookupConditionValue(name string, host string, task *Task, lo } } - if value, ok := e.lookupMagicScopeValue(name, host); ok { - return value, true - } - if facts, ok := e.facts[host]; ok { if name == "ansible_facts" { + if merged := e.hostFactsMap(host); len(merged) > 0 { + return merged, true + } return factsToMap(facts), true } switch name { @@ -2929,23 +3183,18 @@ func (e *Executor) lookupConditionValue(name string, host string, task *Task, lo } } + if value, ok := e.lookupMagicScopeValue(name, host); ok { + return value, true + } + if value, ok := e.hostMagicVars(host)[name]; ok { + return value, true + } + if contains(name, ".") { parts := splitN(name, ".", 2) base := parts[0] path := parts[1] - if magic, ok := e.hostMagicVars(host)[base]; ok { - if nested, ok := lookupNestedValue(magic, path); ok { - return nested, true - } - } - - if magic, ok := e.lookupMagicScopeValue(base, host); ok { - if nested, ok := lookupNestedValue(magic, path); ok { - return nested, true - } - } - if locals != nil { if val, ok := locals[base]; ok { if nested, ok := lookupNestedValue(val, path); ok { @@ -2955,6 +3204,11 @@ func (e *Executor) lookupConditionValue(name string, host string, task *Task, lo } if base == "ansible_facts" { + if merged := e.hostFactsMap(host); len(merged) > 0 { + if nested, ok := lookupNestedValue(merged, path); ok { + return nested, true + } + } if facts, ok := e.facts[host]; ok { if nested, ok := lookupNestedValue(factsToMap(facts), path); ok { return nested, true @@ -2962,9 +3216,11 @@ func (e *Executor) lookupConditionValue(name string, host string, task *Task, lo } } - if val, ok := e.vars[base]; ok { - if nested, ok := lookupNestedValue(val, path); ok { - return nested, true + if task != nil { + if val, ok := task.Vars[base]; ok { + if nested, ok := lookupNestedValue(val, path); ok { + return nested, true + } } } @@ -2976,11 +3232,9 @@ func (e *Executor) lookupConditionValue(name string, host string, task *Task, lo } } - if task != nil { - if val, ok := task.Vars[base]; ok { - if nested, ok := lookupNestedValue(val, path); ok { - return nested, true - } + if val, ok := e.vars[base]; ok { + if nested, ok := lookupNestedValue(val, path); ok { + return nested, true } } @@ -2992,6 +3246,18 @@ func (e *Executor) lookupConditionValue(name string, host string, task *Task, lo } } } + + if magic, ok := e.lookupMagicScopeValue(base, host); ok { + if nested, ok := lookupNestedValue(magic, path); ok { + return nested, true + } + } + + if magic, ok := e.hostMagicVars(host)[base]; ok { + if nested, ok := lookupNestedValue(magic, path); ok { + return nested, true + } + } } return nil, false @@ -3027,23 +3293,11 @@ func taskResultField(value any, field string) (any, bool) { } func (e *Executor) resolveConditionOperand(expr string, host string, task *Task, locals map[string]any) (string, bool) { - expr = corexTrimSpace(expr) - - if expr == "true" || expr == "True" || expr == "false" || expr == "False" { - return expr, true - } - if len(expr) > 0 && expr[0] >= '0' && expr[0] <= '9' { - return expr, true - } - if (len(expr) >= 2 && expr[0] == '\'' && expr[len(expr)-1] == '\'') || (len(expr) >= 2 && expr[0] == '"' && expr[len(expr)-1] == '"') { - return expr[1 : len(expr)-1], true - } - - if value, ok := e.lookupConditionValue(expr, host, task, locals); ok { - return sprintf("%v", value), true + value, ok := e.resolveConditionOperandValue(expr, host, task, locals) + if !ok { + return corexTrimSpace(expr), false } - - return expr, false + return templateStringify(value), true } func (e *Executor) applyTaskResultConditions(host string, task *Task, result *TaskResult) { @@ -3107,126 +3361,17 @@ func (e *Executor) templateString(s string, host string, task *Task) string { // resolveExpr resolves a template expression. func (e *Executor) resolveExpr(expr string, host string, task *Task) string { - parts := splitTemplatePipeline(expr) - if len(parts) == 0 { - return "" - } - - value := e.resolveExprBase(parts[0], host, task) - for _, filter := range parts[1:] { - value = e.applyFilter(value, filter) - } - return value + value, _ := e.resolveExprValue(expr, host, task) + return templateStringify(value) } // resolveExprBase resolves a single templating expression without applying filters. func (e *Executor) resolveExprBase(expr string, host string, task *Task) string { - expr = corexTrimSpace(expr) - if expr == "" { - return "" - } - - // Handle lookups - if corexHasPrefix(expr, "lookup(") { - return e.handleLookup(expr, host, task) - } - - // Handle registered vars - if contains(expr, ".") { - parts := splitN(expr, ".", 2) - if result := e.getRegisteredVar(host, parts[0]); result != nil { - switch parts[1] { - case "stdout": - return result.Stdout - case "stderr": - return result.Stderr - case "rc": - return sprintf("%d", result.RC) - case "changed": - return sprintf("%t", result.Changed) - case "failed": - return sprintf("%t", result.Failed) - } - } - } - - if value, ok := e.hostMagicVars(host)[expr]; ok { - return sprintf("%v", value) - } - - // 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) - } - - // Check task vars - if task != nil { - if val, ok := task.Vars[expr]; ok { - return sprintf("%v", val) - } - } - - if hostVars := e.hostScopedVars(host); hostVars != nil { - if val, ok := hostVars[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 { - if expr == "ansible_facts" { - if merged := e.hostFactsMap(host); len(merged) > 0 { - return sprintf("%v", merged) - } - return sprintf("%v", factsToMap(facts)) - } - switch expr { - case "ansible_hostname": - return facts.Hostname - case "ansible_fqdn": - return facts.FQDN - case "ansible_os_family": - return facts.OS - case "ansible_memtotal_mb": - return sprintf("%d", facts.Memory) - case "ansible_processor_vcpus": - return sprintf("%d", facts.CPUs) - case "ansible_default_ipv4_address": - return facts.IPv4 - 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 - case "ansible_virtualization_role": - return facts.VirtualizationRole - case "ansible_virtualization_type": - return facts.VirtualizationType - } + value, ok := e.resolveExprBaseValue(expr, host, task) + if !ok { + return "{{ " + corexTrimSpace(expr) + " }}" } - - return "{{ " + expr + " }}" // Return as-is if unresolved + return templateStringify(value) } // splitTemplatePipeline splits a template expression into a base expression @@ -3345,13 +3490,6 @@ func shellQuote(value string) string { // 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 value, ok := e.lookupMagicScopeValue(name, host); ok { - return value, true - } - - if val, ok := e.vars[name]; ok { - return val, true - } if task != nil { if val, ok := task.Vars[name]; ok { return val, true @@ -3362,6 +3500,9 @@ func (e *Executor) lookupExprValue(name string, host string, task *Task) (any, b return val, true } } + if val, ok := e.vars[name]; ok { + return val, true + } if e.inventory != nil { hostVars := GetHostVars(e.inventory, host) if val, ok := hostVars[name]; ok { @@ -3375,6 +3516,42 @@ func (e *Executor) lookupExprValue(name string, host string, task *Task) (any, b } } + if facts, ok := e.facts[host]; ok { + switch name { + case "ansible_hostname": + return facts.Hostname, true + case "ansible_fqdn": + return facts.FQDN, true + case "ansible_os_family": + return facts.OS, true + case "ansible_memtotal_mb": + return facts.Memory, true + case "ansible_processor_vcpus": + return facts.CPUs, true + case "ansible_default_ipv4_address": + return facts.IPv4, true + case "ansible_distribution": + return facts.Distribution, true + case "ansible_distribution_version": + return facts.Version, true + case "ansible_architecture": + return facts.Architecture, true + case "ansible_kernel": + return facts.Kernel, true + case "ansible_virtualization_role": + return facts.VirtualizationRole, true + case "ansible_virtualization_type": + return facts.VirtualizationType, true + } + } + + if value, ok := e.lookupMagicScopeValue(name, host); ok { + return value, true + } + if value, ok := e.hostMagicVars(host)[name]; ok { + return value, true + } + if contains(name, ".") { parts := splitN(name, ".", 2) base := parts[0] @@ -3395,6 +3572,32 @@ func (e *Executor) lookupExprValue(name string, host string, task *Task) (any, b } } } + if task != nil { + if val, ok := task.Vars[base]; ok { + if nested, ok := lookupNestedValue(val, path); ok { + return nested, true + } + } + } + if val, ok := e.vars[base]; ok { + if nested, ok := lookupNestedValue(val, path); ok { + return nested, true + } + } + if e.inventory != nil { + hostVars := GetHostVars(e.inventory, host) + if val, ok := hostVars[base]; ok { + if nested, ok := lookupNestedValue(val, path); ok { + return nested, true + } + } + } + + if value, ok := e.lookupMagicScopeValue(base, host); ok { + if nested, ok := lookupNestedValue(value, path); ok { + return nested, true + } + } } return nil, false } @@ -3455,66 +3658,7 @@ func lookupNestedValue(value any, path string) (any, bool) { // applyFilter applies a Jinja2 filter. func (e *Executor) applyFilter(value, filter string) string { - filter = corexTrimSpace(filter) - - // Handle default filter - if corexHasPrefix(filter, "default(") { - if value == "" || isUnresolvedTemplateValue(value) { - // Extract default value - re := regexp.MustCompile(`default\(([^)]*)\)`) - if match := re.FindStringSubmatch(filter); len(match) > 1 { - return trimCutset(match[1], "'\"") - } - } - return value - } - - // Handle bool filter - if filter == "bool" { - lowered := lower(value) - if lowered == "true" || lowered == "yes" || lowered == "1" { - return "true" - } - return "false" - } - - // Handle trim - if filter == "trim" { - return corexTrimSpace(value) - } - - // Handle regex_replace - if corexHasPrefix(filter, "regex_replace(") { - pattern, replacement, ok := parseRegexReplaceFilter(filter) - if !ok { - return value - } - - compiled, err := regexp.Compile(pattern) - if err != nil { - return value - } - return compiled.ReplaceAllString(value, replacement) - } - - // Handle b64decode - if filter == "b64decode" { - decoded, err := base64.StdEncoding.DecodeString(value) - if err == nil { - return string(decoded) - } - if decoded, err := base64.RawStdEncoding.DecodeString(value); err == nil { - return string(decoded) - } - return value - } - - // Handle b64encode - if filter == "b64encode" { - return base64.StdEncoding.EncodeToString([]byte(value)) - } - - return value + return templateStringify(e.applyFilterValue(value, filter)) } func parseRegexReplaceFilter(filter string) (string, string, bool) { @@ -4196,8 +4340,15 @@ func (e *Executor) runNotifiedHandlers(ctx context.Context, hosts []string, play return nil } + executed := make(map[string]bool) for _, handler := range play.Handlers { if handlerMatchesNotifications(&handler, pending) { + if handler.Name != "" { + if executed[handler.Name] { + continue + } + executed[handler.Name] = true + } if err := e.runTaskOnHosts(ctx, hosts, &handler, play); err != nil { return err } diff --git a/executor_extra_test.go b/executor_extra_test.go index 213bb14..bb7ba59 100644 --- a/executor_extra_test.go +++ b/executor_extra_test.go @@ -1724,6 +1724,19 @@ func TestExecutorExtra_ResolveExpr_Good_RegisteredVarFields(t *testing.T) { assert.Equal(t, "false", e.resolveExpr("cmd_result.failed", "host1", nil)) } +func TestExecutorExtra_ResolveExpr_Good_BareRegisteredVar(t *testing.T) { + e := NewExecutor("/tmp") + result := &TaskResult{Stdout: "output text", RC: 0, Changed: true} + e.results["host1"] = map[string]*TaskResult{ + "cmd_result": result, + } + + value, ok := e.resolveExprValue("cmd_result", "host1", nil) + + require.True(t, ok) + assert.Same(t, result, value) +} + func TestExecutorExtra_ResolveExpr_Good_TaskVars(t *testing.T) { e := NewExecutor("/tmp") task := &Task{ @@ -1797,6 +1810,24 @@ func TestExecutorExtra_EvalCondition_Good_UndefinedCheck(t *testing.T) { assert.True(t, e.evalCondition("missing_var is undefined", "host1")) } +func TestExecutorExtra_EvalCondition_Good_BinaryOperators(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["count"] = 2 + e.vars["limit"] = 5 + e.vars["roles"] = []string{"web", "api"} + + assert.True(t, e.evalCondition("count < limit", "host1")) + assert.True(t, e.evalCondition("count <= 2", "host1")) + assert.True(t, e.evalCondition("count<=2", "host1")) + assert.True(t, e.evalCondition("count!=limit", "host1")) + assert.True(t, e.evalCondition("limit >= count", "host1")) + assert.True(t, e.evalCondition("'web' in roles", "host1")) + assert.True(t, e.evalCondition("roles contains 'api'", "host1")) + assert.True(t, e.evalCondition("'db' not in roles", "host1")) + assert.False(t, e.evalCondition("count > limit", "host1")) + assert.False(t, e.evalCondition("count < missing_limit", "host1")) +} + // --- resolveExpr with filter pipe --- func TestExecutorExtra_ResolveExpr_Good_WithFilter(t *testing.T) { @@ -1807,6 +1838,29 @@ func TestExecutorExtra_ResolveExpr_Good_WithFilter(t *testing.T) { assert.Equal(t, "trimmed", result) } +func TestExecutorExtra_ResolveExpr_Good_CommonFilters(t *testing.T) { + e := NewExecutor("/tmp") + e.vars["name"] = "Hello" + e.vars["path"] = "/tmp/example/nginx.conf" + e.vars["parts"] = []string{"a", "b"} + e.vars["csv"] = "a,b,c" + e.vars["count"] = "42" + e.vars["negative"] = -7 + e.vars["values"] = []any{3, 1, 2} + + assert.Equal(t, "HELLO", e.resolveExpr("name | upper", "host1", nil)) + assert.Equal(t, "hello", e.resolveExpr("name | lower", "host1", nil)) + assert.Equal(t, "nginx.conf", e.resolveExpr("path | basename", "host1", nil)) + assert.Equal(t, "/tmp/example", e.resolveExpr("path | dirname", "host1", nil)) + assert.Equal(t, "a,b", e.resolveExpr("parts | join(',')", "host1", nil)) + assert.Equal(t, "[a b c]", e.resolveExpr("csv | split(',')", "host1", nil)) + assert.Equal(t, "42", e.resolveExpr("count | int", "host1", nil)) + assert.Equal(t, "7", e.resolveExpr("negative | abs", "host1", nil)) + assert.Equal(t, "1", e.resolveExpr("values | min", "host1", nil)) + assert.Equal(t, "3", e.resolveExpr("values | max", "host1", nil)) + assert.Equal(t, "3", e.resolveExpr("values | length", "host1", nil)) +} + func TestExecutorExtra_ResolveExpr_Good_WithB64Encode(t *testing.T) { e := NewExecutor("/tmp") e.vars["raw_value"] = "hello" diff --git a/go.mod b/go.mod index b0ea219..e4fa872 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,23 @@ -module dappco.re/go/core/ansible +module dappco.re/go/ansible go 1.26.0 require ( dappco.re/go/core v0.8.0-alpha.1 - dappco.re/go/core/io v0.2.0 - dappco.re/go/core/log v0.1.0 + dappco.re/go/io v0.8.0-alpha.1 + dappco.re/go/log v0.8.0-alpha.1 github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - forge.lthn.ai/core/go-log v0.0.4 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.43.0 // indirect +) + +replace ( + dappco.re/go/io => github.com/dAppCore/go-io v0.8.0-alpha.1 + dappco.re/go/log => github.com/dAppCore/go-log v0.8.0-alpha.1 ) diff --git a/go.sum b/go.sum index 47503e9..4961f40 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,9 @@ dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= -dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= -dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= -dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= -dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +github.com/dAppCore/go-io v0.8.0-alpha.1 h1:DWmaksBY7FpwoQnRHYDN/yPCGLMR794D8OcNdk0RyB8= +github.com/dAppCore/go-io v0.8.0-alpha.1/go.mod h1:491Lt0LOTK4/88EGWVWhrACuXAoxPXvXYu/iIwYc9C0= +github.com/dAppCore/go-log v0.8.0-alpha.1 h1:yAhNK38/QHBaxXgz0Db5HUkrgX16SkxbsVYJ1DvsQn4= +github.com/dAppCore/go-log v0.8.0-alpha.1/go.mod h1:IC04Em9SfVTcXiWc1BqZDQfa1MtOuMDEermZkQcTz9c= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -18,12 +16,12 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/local_client.go b/local_client.go index a2d5932..24ad31a 100644 --- a/local_client.go +++ b/local_client.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "sync" + + coreerr "dappco.re/go/log" ) // localClient executes commands and file operations on the controller host. @@ -34,12 +36,22 @@ func newLocalClient() *localClient { return &localClient{} } +// BecomeState returns the current become flag, user, and password under +// the lock. Used by playbook tasks to decide whether to wrap commands in +// sudo. +// +// become, user, pass := c.BecomeState() func (c *localClient) BecomeState() (bool, string, string) { c.mu.Lock() defer c.mu.Unlock() return c.become, c.becomeUser, c.becomePass } +// SetBecome updates the become flag and credentials. When become is false, +// any stored user/password is cleared. Empty user/password arguments are +// ignored when become is true (so callers can update fields incrementally). +// +// c.SetBecome(true, "ansible", "secret") func (c *localClient) SetBecome(become bool, user, password string) { c.mu.Lock() defer c.mu.Unlock() @@ -57,10 +69,18 @@ func (c *localClient) SetBecome(become bool, user, password string) { } } +// Close is a no-op for the local client — there is no remote connection +// to tear down. Kept on the interface to match SSH variants. +// +// _ = c.Close() func (c *localClient) Close() error { return nil } +// Run executes cmd via local shell, optionally wrapped in sudo when +// become is enabled. Returns stdout/stderr/exit-code/error. +// +// out, _, code, err := c.Run(ctx, "uname -a") func (c *localClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) { c.mu.Lock() become, becomeUser, becomePass := c.becomeStateLocked() @@ -77,26 +97,50 @@ func (c *localClient) Run(ctx context.Context, cmd string) (stdout, stderr strin return runLocalShell(ctx, command, "") } +// RunScript executes a multi-line shell script as a local heredoc. +// +// out, _, code, err := c.RunScript(ctx, "#!/bin/bash\necho hi") func (c *localClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) { return c.Run(ctx, "bash <<'ANSIBLE_SCRIPT_EOF'\n"+script+"\nANSIBLE_SCRIPT_EOF") } +// Upload writes the contents of localReader into a file at remote with the +// given permission mode. Creates parent directories as needed. +// +// _ = c.Upload(ctx, bytes.NewReader(data), "/etc/foo.conf", 0o644) func (c *localClient) Upload(_ context.Context, localReader io.Reader, remote string, mode os.FileMode) error { content, err := io.ReadAll(localReader) if err != nil { - return err + return coreerr.E("localClient.Upload", "read upload content", err) } if err := os.MkdirAll(filepath.Dir(remote), 0o755); err != nil { - return err + return coreerr.E("localClient.Upload", "create remote directory", err) } - return os.WriteFile(remote, content, mode) + if err := os.WriteFile(remote, content, mode); err != nil { + return coreerr.E("localClient.Upload", "write remote file", err) + } + return nil } +// Download reads the bytes of a local file path. Despite the name, the +// "remote" is local — the localClient implements the SSHClient interface +// transparently so playbooks targeting `connection: local` work unchanged. +// +// data, err := c.Download(ctx, "/etc/foo.conf") func (c *localClient) Download(_ context.Context, remote string) ([]byte, error) { - return os.ReadFile(remote) + data, err := os.ReadFile(remote) + if err != nil { + return nil, coreerr.E("localClient.Download", "read remote file", err) + } + return data, nil } +// FileExists reports whether the given local path exists. Returns +// (false, nil) for non-existent paths and (false, err) for stat failures +// other than not-exist. +// +// exists, err := c.FileExists(ctx, "/etc/foo.conf") func (c *localClient) FileExists(_ context.Context, path string) (bool, error) { _, err := os.Stat(path) if err == nil { @@ -105,16 +149,21 @@ func (c *localClient) FileExists(_ context.Context, path string) (bool, error) { if os.IsNotExist(err) { return false, nil } - return false, err + return false, coreerr.E("localClient.FileExists", "stat path", err) } +// Stat returns a map describing the path: at minimum {"exists": bool}, plus +// "isdir" / "size" / "mode" / "mtime" when the file exists. Used by the +// `stat` Ansible module. +// +// info, err := c.Stat(ctx, "/etc/foo.conf") func (c *localClient) Stat(_ context.Context, path string) (map[string]any, error) { info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return map[string]any{"exists": false}, nil } - return nil, err + return nil, coreerr.E("localClient.Stat", "stat path", err) } return map[string]any{ "exists": true, @@ -127,7 +176,7 @@ func (c *localClient) becomeStateLocked() (bool, string, string) { } func runLocalShell(ctx context.Context, command, password string) (stdout, stderr string, exitCode int, err error) { - cmd := exec.CommandContext(ctx, "bash", "-lc", command) + cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", "-lc", command) var stdoutBuf, stderrBuf bytes.Buffer cmd.Stdout = &stdoutBuf @@ -136,7 +185,7 @@ func runLocalShell(ctx context.Context, command, password string) (stdout, stder if password != "" { stdin, stdinErr := cmd.StdinPipe() if stdinErr != nil { - return "", "", -1, stdinErr + return "", "", -1, coreerr.E("localClient.runLocalShell", "open stdin", stdinErr) } go func() { defer func() { _ = stdin.Close() }() @@ -155,7 +204,7 @@ func runLocalShell(ctx context.Context, command, password string) (stdout, stder return stdout, stderr, exitErr.ExitCode(), nil } - return stdout, stderr, -1, err + return stdout, stderr, -1, coreerr.E("localClient.runLocalShell", "execute command", err) } func wrapLocalBecomeCommand(command, user, password string) string { diff --git a/local_client_test.go b/local_client_test.go index a507132..7b45cdc 100644 --- a/local_client_test.go +++ b/local_client_test.go @@ -94,3 +94,19 @@ func TestLocalClient_Good_SetBecomeResetsStateWhenDisabled(t *testing.T) { assert.Empty(t, user) assert.Empty(t, password) } + +func TestAsyncClone_Good_DoesNotShareLocalClientState(t *testing.T) { + client := newLocalClient() + client.SetBecome(true, "admin", "secret") + + cloned := cloneClientMap(map[string]sshExecutorClient{"host1": client}) + + clonedClient, ok := cloned["host1"].(*localClient) + require.True(t, ok) + assert.NotSame(t, client, clonedClient) + + become, user, password := clonedClient.BecomeState() + assert.False(t, become) + assert.Empty(t, user) + assert.Empty(t, password) +} diff --git a/modules.go b/modules.go index 15a9686..c1f28af 100644 --- a/modules.go +++ b/modules.go @@ -23,8 +23,8 @@ import ( "strings" "time" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" "gopkg.in/yaml.v3" ) diff --git a/modules_infra_test.go b/modules_infra_test.go index ff835fd..10f0ac4 100644 --- a/modules_infra_test.go +++ b/modules_infra_test.go @@ -1380,10 +1380,9 @@ func TestModulesInfra_TemplateArgs_Good_InventoryHostname(t *testing.T) { assert.Equal(t, "web1", result["hostname"]) } -func TestModulesInfra_EvalCondition_Good_UnknownDefaultsTrue(t *testing.T) { +func TestModulesInfra_EvalCondition_Good_UnknownComparisonFailsClosed(t *testing.T) { e := NewExecutor("/tmp") - // Unknown conditions default to true (permissive) - assert.True(t, e.evalCondition("some_complex_expression == 'value'", "host1")) + assert.False(t, e.evalCondition("some_complex_expression == 'value'", "host1")) } func TestModulesInfra_GetRegisteredVar_Good_DottedAccess(t *testing.T) { diff --git a/parser.go b/parser.go index 610b9f8..60fca2f 100644 --- a/parser.go +++ b/parser.go @@ -1,13 +1,18 @@ package ansible import ( + "io/fs" "iter" "maps" + "path/filepath" + "reflect" "slices" + "sort" "strings" + "sync" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" "gopkg.in/yaml.v3" ) @@ -18,6 +23,8 @@ import ( // parser := NewParser("/workspace/playbooks") type Parser struct { basePath string + mediumMu sync.RWMutex + medium coreio.Medium vars map[string]any } @@ -33,6 +40,20 @@ func NewParser(basePath string) *Parser { } } +// SetMedium configures the storage medium used for reading parser inputs. +// +// Example: +// +// parser.SetMedium(io.Local) +func (p *Parser) SetMedium(medium coreio.Medium) { + if p == nil { + return + } + p.mediumMu.Lock() + defer p.mediumMu.Unlock() + p.medium = medium +} + // ParsePlaybook parses an Ansible playbook file. // // Example: @@ -66,7 +87,7 @@ func (p *Parser) parsePlaybook(path string, seen map[string]bool) ([]Play, error seen[cleanedPath] = true defer delete(seen, cleanedPath) - data, err := coreio.Local.Read(path) + data, err := p.readFile(path) if err != nil { return nil, coreerr.E("Parser.ParsePlaybook", "read playbook", err) } @@ -154,7 +175,7 @@ func (p *Parser) ParsePlaybookIter(path string) (iter.Seq[Play], error) { func (p *Parser) ParseInventory(path string) (*Inventory, error) { path = p.resolveInventoryPath(path) - data, err := coreio.Local.Read(path) + data, err := p.readFile(path) if err != nil { return nil, coreerr.E("Parser.ParseInventory", "read inventory", err) } @@ -170,13 +191,13 @@ func (p *Parser) ParseInventory(path string) (*Inventory, error) { // resolveInventoryPath resolves inventory directories to a concrete file. func (p *Parser) resolveInventoryPath(path string) string { path = p.resolvePath(path) - if path == "" || !coreio.Local.Exists(path) || !coreio.Local.IsDir(path) { + if path == "" || !p.exists(path) || !p.isDir(path) { return path } for _, name := range []string{"inventory.yml", "hosts.yml", "inventory.yaml", "hosts.yaml"} { candidate := joinPath(path, name) - if coreio.Local.Exists(candidate) { + if p.exists(candidate) { return candidate } } @@ -192,7 +213,7 @@ func (p *Parser) resolveInventoryPath(path string) string { func (p *Parser) ParseTasks(path string) ([]Task, error) { path = p.resolvePath(path) - data, err := coreio.Local.Read(path) + data, err := p.readFile(path) if err != nil { return nil, coreerr.E("Parser.ParseTasks", "read tasks", err) } @@ -211,6 +232,113 @@ func (p *Parser) ParseTasks(path string) ([]Task, error) { return tasks, nil } +// ParseTasksFromDir loads tasks from a directory, falling back to main.yml. +// +// Example: +// +// tasks, err := parser.ParseTasksFromDir("/workspace/roles/web/tasks") +func (p *Parser) ParseTasksFromDir(dir string) ([]Task, error) { + dir = p.resolvePath(dir) + if dir == "" { + return nil, coreerr.E("Parser.ParseTasksFromDir", "directory required", nil) + } + + if !p.exists(dir) { + return nil, coreerr.E("Parser.ParseTasksFromDir", "tasks directory not found", nil) + } + + if !p.isDir(dir) { + return p.ParseTasks(dir) + } + + for _, name := range []string{"main.yml", "main.yaml", "tasks.yml", "tasks.yaml"} { + candidate := joinPath(dir, name) + if p.exists(candidate) { + return p.ParseTasks(candidate) + } + } + + return nil, coreerr.E("Parser.ParseTasksFromDir", "no task file found in directory", nil) +} + +// ParseVarsFiles loads and merges vars from one or more files. +// +// Example: +// +// vars, err := parser.ParseVarsFiles("/workspace/group_vars/*.yml") +func (p *Parser) ParseVarsFiles(pattern string) (map[string]any, error) { + pattern = p.resolvePath(pattern) + if pattern == "" { + return nil, nil + } + + matches, err := p.expandFilePattern(pattern) + if err != nil { + return nil, err + } + if len(matches) == 0 { + if !strings.ContainsAny(pattern, "*?[") { + matches = []string{pattern} + } else { + return nil, coreerr.E("Parser.ParseVarsFiles", "no vars files matched pattern", nil) + } + } + + merged := make(map[string]any) + for _, file := range matches { + data, err := p.readFile(file) + if err != nil { + return nil, coreerr.E("Parser.ParseVarsFiles", "read vars file", err) + } + + var vars map[string]any + if err := yaml.Unmarshal([]byte(data), &vars); err != nil { + return nil, coreerr.E("Parser.ParseVarsFiles", "parse vars file", err) + } + mergeVars(merged, vars, false) + } + + return merged, nil +} + +// ParseRoles loads role definitions from a roles directory. +// +// Example: +// +// roles, err := parser.ParseRoles("/workspace/roles") +func (p *Parser) ParseRoles(roleDir string) (map[string]*Role, error) { + roleDir = p.resolvePath(roleDir) + if roleDir == "" || !p.exists(roleDir) || !p.isDir(roleDir) { + return nil, coreerr.E("Parser.ParseRoles", "role directory not found", nil) + } + + entries, err := p.listDir(roleDir) + if err != nil { + return nil, coreerr.E("Parser.ParseRoles", "list role directory", err) + } + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + names = append(names, entry.Name()) + } + } + sort.Strings(names) + + roles := make(map[string]*Role, len(names)) + for _, name := range names { + role, err := p.parseRoleAtPath(joinPath(roleDir, name), name) + if err != nil { + return nil, err + } + if role != nil { + roles[name] = role + } + } + + return roles, nil +} + // resolvePath resolves a possibly relative path against the parser base path. func (p *Parser) resolvePath(path string) string { if path == "" || pathIsAbs(path) || p.basePath == "" { @@ -222,7 +350,7 @@ func (p *Parser) resolvePath(path string) string { path, } for _, candidate := range candidates { - if coreio.Local.Exists(candidate) { + if p.exists(candidate) { return candidate } } @@ -230,6 +358,121 @@ func (p *Parser) resolvePath(path string) string { return joinPath(p.basePath, path) } +// mediumOrLocal returns the configured storage medium or the OS filesystem. +func (p *Parser) mediumOrLocal() coreio.Medium { + if medium := p.configuredMedium(); medium != nil { + return medium + } + return coreio.Local +} + +// configuredMedium returns the parser medium under read lock. +func (p *Parser) configuredMedium() coreio.Medium { + if p == nil { + return nil + } + p.mediumMu.RLock() + defer p.mediumMu.RUnlock() + return p.medium +} + +// readFile reads a file through the configured medium. +func (p *Parser) readFile(path string) (string, error) { + medium := p.mediumOrLocal() + if medium == nil { + return "", coreerr.E("Parser.readFile", "no storage medium configured", nil) + } + return coreio.Read(medium, path) +} + +// exists checks for a path through the configured medium. +func (p *Parser) exists(path string) bool { + medium := p.mediumOrLocal() + if medium == nil { + return false + } + return medium.Exists(path) +} + +// isDir reports whether a path is a directory in the configured medium. +func (p *Parser) isDir(path string) bool { + medium := p.mediumOrLocal() + if medium == nil { + return false + } + return medium.IsDir(path) +} + +// listDir lists directory entries through the configured medium. +func (p *Parser) listDir(path string) ([]fs.DirEntry, error) { + medium := p.mediumOrLocal() + if medium == nil { + return nil, coreerr.E("Parser.listDir", "no storage medium configured", nil) + } + + entries, err := medium.List(path) + if err != nil { + return nil, err + } + + return entries, nil +} + +// expandFilePattern expands wildcard paths that can be safely resolved locally. +func (p *Parser) expandFilePattern(pattern string) ([]string, error) { + if !strings.ContainsAny(pattern, "*?[") { + return []string{pattern}, nil + } + + if medium := p.configuredMedium(); medium != nil && !isDefaultLocalMedium(medium) { + return nil, coreerr.E("Parser.expandFilePattern", "wildcard patterns require the local filesystem medium", nil) + } + + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, coreerr.E("Parser.expandFilePattern", "expand pattern", err) + } + sort.Strings(matches) + return matches, nil +} + +// isDefaultLocalMedium reports whether a medium is the package-level local medium. +func isDefaultLocalMedium(medium coreio.Medium) bool { + if medium == nil || coreio.Local == nil { + return medium == nil && coreio.Local == nil + } + + mediumValue := reflect.ValueOf(medium) + localValue := reflect.ValueOf(coreio.Local) + if !mediumValue.IsValid() || !localValue.IsValid() || mediumValue.Type() != localValue.Type() { + return false + } + if mediumValue.Kind() != reflect.Pointer { + return false + } + if mediumValue.IsNil() || localValue.IsNil() { + return mediumValue.IsNil() && localValue.IsNil() + } + return mediumValue.Pointer() == localValue.Pointer() +} + +// parseRoleAtPath loads a role from a concrete role directory. +func (p *Parser) parseRoleAtPath(rolePath, roleName string) (*Role, error) { + tasks, defaults, roleVars, handlers, err := p.loadRoleDataFromPath(rolePath, "main.yml", "main.yml", "main.yml", "main.yml") + if err != nil { + return nil, err + } + + return &Role{ + Name: roleName, + Path: rolePath, + Tasks: tasks, + Defaults: defaults, + Vars: roleVars, + Handlers: handlers, + }, nil +} + // templatePath renders a path-like string against the parser's variable scope. func (p *Parser) templatePath(value string) string { if value == "" { @@ -305,7 +548,7 @@ func (p *Parser) loadRoleData(name string, tasksFrom string, defaultsFrom string defaults := make(map[string]any) // Load role defaults defaultsPath := joinPath(pathDir(pathDir(tasksPath)), "defaults", defaultsFrom) - if data, err := coreio.Local.Read(defaultsPath); err == nil { + if data, err := p.readFile(defaultsPath); err == nil { if yaml.Unmarshal([]byte(data), &defaults) != nil { defaults = make(map[string]any) } @@ -314,7 +557,7 @@ func (p *Parser) loadRoleData(name string, tasksFrom string, defaultsFrom string roleVars := make(map[string]any) // Load role vars varsPath := joinPath(pathDir(pathDir(tasksPath)), "vars", varsFrom) - if data, err := coreio.Local.Read(varsPath); err == nil { + if data, err := p.readFile(varsPath); err == nil { if yaml.Unmarshal([]byte(data), &roleVars) != nil { roleVars = make(map[string]any) } @@ -328,6 +571,57 @@ func (p *Parser) loadRoleData(name string, tasksFrom string, defaultsFrom string return tasks, defaults, roleVars, tasksPath, nil } +// loadRoleDataFromPath loads role files from a concrete directory path. +func (p *Parser) loadRoleDataFromPath(rolePath string, tasksFrom string, defaultsFrom string, varsFrom string, handlersFrom string) ([]Task, map[string]any, map[string]any, []Task, error) { + if rolePath == "" { + return nil, nil, nil, nil, coreerr.E("Parser.ParseRoles", "role path required", nil) + } + + if tasksFrom == "" { + tasksFrom = "main.yml" + } + if defaultsFrom == "" { + defaultsFrom = "main.yml" + } + if varsFrom == "" { + varsFrom = "main.yml" + } + + tasks := make([]Task, 0) + var err error + taskPath := joinPath(rolePath, "tasks", tasksFrom) + if !p.exists(taskPath) { + taskPath = joinPath(rolePath, "tasks") + } + if p.exists(taskPath) { + if p.isDir(taskPath) { + tasks, err = p.ParseTasksFromDir(taskPath) + } else { + tasks, err = p.ParseTasks(taskPath) + } + if err != nil { + return nil, nil, nil, nil, err + } + } + + defaults := make(map[string]any) + if data, err := p.readFile(joinPath(rolePath, "defaults", defaultsFrom)); err == nil { + _ = yaml.Unmarshal([]byte(data), &defaults) + } + + roleVars := make(map[string]any) + if data, err := p.readFile(joinPath(rolePath, "vars", varsFrom)); err == nil { + _ = yaml.Unmarshal([]byte(data), &roleVars) + } + + handlers, err := p.loadRoleHandlersFromPath(rolePath, handlersFrom) + if err != nil { + return nil, nil, nil, nil, err + } + + return tasks, defaults, roleVars, handlers, nil +} + func (p *Parser) loadRoleHandlers(name string, handlersFrom string) ([]Task, error) { if handlersFrom == "" { handlersFrom = "main.yml" @@ -338,7 +632,7 @@ func (p *Parser) loadRoleHandlers(name string, handlersFrom string) ([]Task, err return nil, nil } - data, err := coreio.Local.Read(handlersPath) + data, err := p.readFile(handlersPath) if err != nil { return nil, coreerr.E("Parser.loadRoleHandlers", "read role handlers", err) } @@ -357,6 +651,31 @@ func (p *Parser) loadRoleHandlers(name string, handlersFrom string) ([]Task, err return handlers, nil } +// loadRoleHandlersFromPath loads handler tasks from a concrete role directory. +func (p *Parser) loadRoleHandlersFromPath(rolePath string, handlersFrom string) ([]Task, error) { + if handlersFrom == "" { + handlersFrom = "main.yml" + } + handlersPath := joinPath(rolePath, "handlers", handlersFrom) + if !p.exists(handlersPath) { + return nil, nil + } + data, err := p.readFile(handlersPath) + if err != nil { + return nil, coreerr.E("Parser.loadRoleHandlersFromPath", "read role handlers", err) + } + var handlers []Task + if err := yaml.Unmarshal([]byte(data), &handlers); err != nil { + return nil, coreerr.E("Parser.loadRoleHandlersFromPath", "parse role handlers", err) + } + for i := range handlers { + if err := p.extractModule(&handlers[i]); err != nil { + return nil, coreerr.E("Parser.loadRoleHandlersFromPath", sprintf("handler %d", i), err) + } + } + return handlers, nil +} + func (p *Parser) findRoleFilePath(name string, subdir string, filename string) string { searchPaths := []string{ joinPath(p.basePath, "roles", name, subdir, filename), @@ -470,6 +789,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { "block": true, "rescue": true, "always": true, "notify": true, "listen": true, "module_defaults": true, "retries": true, "delay": true, "until": true, + "async": true, "poll": true, "action": true, "local_action": true, "ansible.builtin.action": true, "ansible.legacy.action": true, "ansible.builtin.local_action": true, "ansible.legacy.local_action": true, @@ -1228,10 +1548,21 @@ func hasHost(group *InventoryGroup, name string) bool { // hostVars := GetHostVars(inventory, "web1") func GetHostVars(inventory *Inventory, hostname string) map[string]any { vars := make(map[string]any) + if inventory == nil { + return vars + } // Collect vars from all levels collectHostVars(inventory.All, hostname, vars) + if inventory != nil && len(inventory.HostVars) > 0 { + if hostVars, ok := inventory.HostVars[hostname]; ok { + for key, value := range hostVars { + vars[key] = value + } + } + } + return vars } diff --git a/parser_test.go b/parser_test.go index 261dbab..d77a47e 100644 --- a/parser_test.go +++ b/parser_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + coreio "dappco.re/go/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -732,6 +733,37 @@ all: assert.Equal(t, 2222, inv.All.Children["production"].Hosts["prod1"].AnsiblePort) } +func TestParser_ParseInventory_Good_HostVars(t *testing.T) { + dir := t.TempDir() + path := joinPath(dir, "inventory.yml") + + yaml := `--- +all: + hosts: + web1: + ansible_host: 192.168.1.10 + vars: + env: prod +host_vars: + web1: + env: staging + owner: ops +` + require.NoError(t, writeTestFile(path, []byte(yaml), 0644)) + + p := NewParser(dir) + inv, err := p.ParseInventory(path) + + require.NoError(t, err) + require.NotNil(t, inv.HostVars) + assert.Equal(t, "staging", inv.HostVars["web1"]["env"]) + + vars := GetHostVars(inv, "web1") + assert.Equal(t, "staging", vars["env"]) + assert.Equal(t, "ops", vars["owner"]) + assert.Equal(t, "192.168.1.10", vars["ansible_host"]) +} + func TestParser_ParseInventory_Bad_InvalidYAML(t *testing.T) { dir := t.TempDir() path := joinPath(dir, "bad.yml") @@ -766,6 +798,10 @@ func TestParser_ParseTasks_Good_TaskFile(t *testing.T) { copy: src: /tmp/a dest: /tmp/b +- name: Async task + shell: sleep 5 + async: 30 + poll: 0 ` require.NoError(t, writeTestFile(path, []byte(yaml), 0644)) @@ -773,11 +809,14 @@ func TestParser_ParseTasks_Good_TaskFile(t *testing.T) { tasks, err := p.ParseTasks(path) require.NoError(t, err) - require.Len(t, tasks, 2) + require.Len(t, tasks, 3) assert.Equal(t, "shell", tasks[0].Module) assert.Equal(t, "echo first", tasks[0].Args["_raw_params"]) assert.Equal(t, "copy", tasks[1].Module) assert.Equal(t, "/tmp/a", tasks[1].Args["src"]) + assert.Equal(t, "shell", tasks[2].Module) + assert.Equal(t, 30, tasks[2].Async) + assert.Equal(t, 0, tasks[2].Poll) } func TestParser_ParseTasks_Bad_InvalidYAML(t *testing.T) { @@ -792,6 +831,92 @@ func TestParser_ParseTasks_Bad_InvalidYAML(t *testing.T) { assert.Error(t, err) } +func TestParser_ParseTasksFromDir_Good_MainFallback(t *testing.T) { + dir := t.TempDir() + taskDir := joinPath(dir, "tasks") + path := joinPath(taskDir, "main.yml") + + require.NoError(t, os.MkdirAll(taskDir, 0o755)) + require.NoError(t, writeTestFile(path, []byte(`--- +- name: From dir + debug: + msg: "ok" +`), 0o644)) + + p := NewParser(dir) + tasks, err := p.ParseTasksFromDir(taskDir) + + require.NoError(t, err) + require.Len(t, tasks, 1) + assert.Equal(t, "From dir", tasks[0].Name) + assert.Equal(t, "debug", tasks[0].Module) +} + +func TestParser_ParseVarsFiles_Good_GlobMerge(t *testing.T) { + dir := t.TempDir() + varsDir := joinPath(dir, "vars") + require.NoError(t, os.MkdirAll(varsDir, 0o755)) + require.NoError(t, writeTestFile(joinPath(varsDir, "01.yml"), []byte("a: 1\nb: one\n"), 0o644)) + require.NoError(t, writeTestFile(joinPath(varsDir, "02.yml"), []byte("b: two\nc: 3\n"), 0o644)) + + p := NewParser(dir) + vars, err := p.ParseVarsFiles(joinPath(varsDir, "*.yml")) + + require.NoError(t, err) + assert.Equal(t, 1, vars["a"]) + assert.Equal(t, "two", vars["b"]) + assert.Equal(t, 3, vars["c"]) +} + +func TestParser_ParseVarsFiles_Bad_WildcardWithNonLocalMedium(t *testing.T) { + medium := coreio.NewMemoryMedium() + require.NoError(t, medium.EnsureDir("vars")) + require.NoError(t, medium.Write("vars/01.yml", "a: 1\n")) + + p := NewParser("") + p.SetMedium(medium) + _, err := p.ParseVarsFiles("vars/*.yml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "wildcard patterns require") +} + +func TestParser_ParseRoles_Good_RoleDirectory(t *testing.T) { + dir := t.TempDir() + roleDir := joinPath(dir, "roles", "web") + require.NoError(t, os.MkdirAll(joinPath(roleDir, "tasks"), 0o755)) + require.NoError(t, os.MkdirAll(joinPath(roleDir, "defaults"), 0o755)) + require.NoError(t, os.MkdirAll(joinPath(roleDir, "vars"), 0o755)) + require.NoError(t, os.MkdirAll(joinPath(roleDir, "handlers"), 0o755)) + require.NoError(t, writeTestFile(joinPath(roleDir, "tasks", "main.yml"), []byte(`--- +- name: Role task + debug: + msg: "role" +`), 0o644)) + require.NoError(t, writeTestFile(joinPath(roleDir, "defaults", "main.yml"), []byte("role_default: true\n"), 0o644)) + require.NoError(t, writeTestFile(joinPath(roleDir, "vars", "main.yml"), []byte("role_var: 42\n"), 0o644)) + require.NoError(t, writeTestFile(joinPath(roleDir, "handlers", "main.yml"), []byte(`--- +- name: Role handler + debug: + msg: "handler" +`), 0o644)) + + p := NewParser(dir) + roles, err := p.ParseRoles("roles") + + require.NoError(t, err) + role, ok := roles["web"] + require.True(t, ok) + require.NotNil(t, role) + assert.Equal(t, "web", role.Name) + require.Len(t, role.Tasks, 1) + assert.Equal(t, "Role task", role.Tasks[0].Name) + assert.Equal(t, true, role.Defaults["role_default"]) + assert.Equal(t, 42, role.Vars["role_var"]) + require.Len(t, role.Handlers, 1) + assert.Equal(t, "Role handler", role.Handlers[0].Name) +} + func TestParser_ParseRole_Good_LoadsRoleVarsIntoParserContext(t *testing.T) { dir := t.TempDir() diff --git a/ssh.go b/ssh.go index 8d679e2..6d734af 100644 --- a/ssh.go +++ b/ssh.go @@ -9,8 +9,8 @@ import ( "sync" "time" - coreio "dappco.re/go/core/io" - coreerr "dappco.re/go/core/log" + coreio "dappco.re/go/io" + coreerr "dappco.re/go/log" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" ) diff --git a/template_features.go b/template_features.go new file mode 100644 index 0000000..f310cf4 --- /dev/null +++ b/template_features.go @@ -0,0 +1,442 @@ +package ansible + +import ( + "encoding/base64" + "path" + "reflect" + "regexp" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +// resolveExprValue evaluates a template expression and preserves native values. +func (e *Executor) resolveExprValue(expr string, host string, task *Task) (any, bool) { + parts := splitTemplatePipeline(expr) + if len(parts) == 0 { + return nil, false + } + + value, ok := e.resolveExprBaseValue(parts[0], host, task) + if !ok { + value = "{{ " + corexTrimSpace(parts[0]) + " }}" + } + + for _, filter := range parts[1:] { + value = e.applyFilterValue(value, filter) + } + + return value, true +} + +// resolveExprBaseValue resolves the first expression segment before filters run. +func (e *Executor) resolveExprBaseValue(expr string, host string, task *Task) (any, bool) { + expr = corexTrimSpace(expr) + if expr == "" { + return nil, false + } + + if corexHasPrefix(expr, "lookup(") { + if value, ok := e.lookupValue(expr, host, task); ok { + return value, true + } + } + + if contains(expr, ".") { + parts := splitN(expr, ".", 2) + if result := e.getRegisteredVar(host, parts[0]); result != nil { + if value, ok := taskResultField(result, parts[1]); ok { + return value, true + } + } + } + + if result := e.getRegisteredVar(host, expr); result != nil { + return result, true + } + + if value, ok := e.lookupExprValue(expr, host, task); ok { + return value, true + } + + return nil, false +} + +// applyFilterValue applies a supported Jinja-style filter to a native value. +func (e *Executor) applyFilterValue(value any, filter string) any { + filter = corexTrimSpace(filter) + + if corexHasPrefix(filter, "default(") { + if isEmptyTemplateValue(value) { + if raw, ok := parseTemplateFilterLiteral(filter, "default"); ok { + return raw + } + } + return value + } + + if filter == "upper" { + return strings.ToUpper(templateStringify(value)) + } + if filter == "lower" { + return lower(templateStringify(value)) + } + if filter == "trim" { + return corexTrimSpace(templateStringify(value)) + } + if corexHasPrefix(filter, "basename") { + return path.Base(templateStringify(value)) + } + if corexHasPrefix(filter, "dirname") { + return path.Dir(templateStringify(value)) + } + if corexHasPrefix(filter, "join(") { + separator := "" + if raw, ok := parseTemplateFilterLiteral(filter, "join"); ok { + separator = templateStringify(raw) + } + + items, ok := anySliceFromValue(value) + if !ok { + return templateStringify(value) + } + + parts := make([]string, 0, len(items)) + for _, item := range items { + parts = append(parts, templateStringify(item)) + } + return strings.Join(parts, separator) + } + if filter == "split" || corexHasPrefix(filter, "split(") { + separator := "" + if raw, ok := parseTemplateFilterLiteral(filter, "split"); ok { + separator = templateStringify(raw) + } + + text := templateStringify(value) + if separator == "" { + parts := strings.Fields(text) + items := make([]any, len(parts)) + for i, part := range parts { + items[i] = part + } + return items + } + + parts := strings.Split(text, separator) + items := make([]any, len(parts)) + for i, part := range parts { + items[i] = part + } + return items + } + if filter == "bool" { + return templateBool(value) + } + if filter == "int" { + if n, ok := templateInt(value); ok { + return n + } + return 0 + } + if filter == "abs" { + if n, ok := templateFloat(value); ok { + if n < 0 { + n = -n + } + if float64(int64(n)) == n { + return int(n) + } + return n + } + return value + } + if corexHasPrefix(filter, "min") || corexHasPrefix(filter, "max") { + wantMax := corexHasPrefix(filter, "max") + if result, ok := templateMinMax(value, wantMax); ok { + return result + } + return value + } + if filter == "length" { + return templateLength(value) + } + if corexHasPrefix(filter, "regex_replace(") { + pattern, replacement, ok := parseRegexReplaceFilter(filter) + if !ok { + return templateStringify(value) + } + + compiled, err := regexp.Compile(pattern) + if err != nil { + return templateStringify(value) + } + return compiled.ReplaceAllString(templateStringify(value), replacement) + } + if filter == "b64decode" { + valueStr := templateStringify(value) + decoded, err := base64.StdEncoding.DecodeString(valueStr) + if err == nil { + return string(decoded) + } + if decoded, err := base64.RawStdEncoding.DecodeString(valueStr); err == nil { + return string(decoded) + } + return valueStr + } + if filter == "b64encode" { + return base64.StdEncoding.EncodeToString([]byte(templateStringify(value))) + } + + return value +} + +// parseTemplateFilterLiteral parses a single literal argument from a filter call. +func parseTemplateFilterLiteral(filter, name string) (any, bool) { + if !corexHasPrefix(filter, name+"(") || !corexHasSuffix(filter, ")") { + return nil, false + } + + raw := strings.TrimSpace(filter[len(name)+1 : len(filter)-1]) + if raw == "" { + return "", true + } + + var value any + if err := yaml.Unmarshal([]byte(raw), &value); err != nil { + return trimCutset(raw, "'\""), true + } + return value, true +} + +// templateStringify converts template values to Ansible-style strings. +func templateStringify(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + case []string: + return sprintf("%v", v) + case []any: + return sprintf("%v", v) + case map[string]any: + return sprintf("%v", v) + case map[any]any: + return sprintf("%v", v) + default: + return sprintf("%v", value) + } +} + +// isEmptyTemplateValue reports whether a value should trigger default filters. +func isEmptyTemplateValue(value any) bool { + switch v := value.(type) { + case nil: + return true + case string: + return v == "" || isUnresolvedTemplateValue(v) + case []any: + return len(v) == 0 + case []string: + return len(v) == 0 + case map[string]any: + return len(v) == 0 + case map[any]any: + return len(v) == 0 + } + + rv := reflect.ValueOf(value) + if !rv.IsValid() { + return true + } + + switch rv.Kind() { + case reflect.Slice, reflect.Array, reflect.Map: + return rv.Len() == 0 + } + + return false +} + +// templateBool coerces common Ansible truthy values to bool. +func templateBool(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + lowered := lower(strings.TrimSpace(v)) + return lowered == "true" || lowered == "yes" || lowered == "1" + case int: + return v != 0 + case int8: + return v != 0 + case int16: + return v != 0 + case int32: + return v != 0 + case int64: + return v != 0 + case uint: + return v != 0 + case uint8: + return v != 0 + case uint16: + return v != 0 + case uint32: + return v != 0 + case uint64: + return v != 0 + case float32: + return v != 0 + case float64: + return v != 0 + default: + return templateStringify(value) != "" + } +} + +// templateInt coerces numeric template values to int. +func templateInt(value any) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case int8: + return int(v), true + case int16: + return int(v), true + case int32: + return int(v), true + case int64: + return int(v), true + case uint: + return int(v), true + case uint8: + return int(v), true + case uint16: + return int(v), true + case uint32: + return int(v), true + case uint64: + return int(v), true + case float32: + return int(v), true + case float64: + return int(v), true + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err == nil { + return n, true + } + } + + if n, ok := templateFloat(value); ok { + return int(n), true + } + + return 0, false +} + +// templateFloat coerces numeric template values to float64. +func templateFloat(value any) (float64, bool) { + switch v := value.(type) { + case int: + return float64(v), true + case int8: + return float64(v), true + case int16: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint8: + return float64(v), true + case uint16: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + case string: + n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err == nil { + return n, true + } + } + + return 0, false +} + +// templateLength returns the length used by the template length filter. +func templateLength(value any) int { + switch v := value.(type) { + case nil: + return 0 + case string: + return len(v) + case []any: + return len(v) + case []string: + return len(v) + case map[string]any: + return len(v) + case map[any]any: + return len(v) + } + + rv := reflect.ValueOf(value) + if !rv.IsValid() { + return 0 + } + + switch rv.Kind() { + case reflect.Slice, reflect.Array, reflect.Map, reflect.String: + return rv.Len() + default: + return len(templateStringify(value)) + } +} + +// templateMinMax returns the smallest or largest item in a slice-like value. +func templateMinMax(value any, wantMax bool) (any, bool) { + items, ok := anySliceFromValue(value) + if !ok || len(items) == 0 { + return nil, false + } + + best := items[0] + for _, item := range items[1:] { + if templateValueGreater(item, best, wantMax) { + best = item + } + } + + return best, true +} + +// templateValueGreater compares values for min and max filters. +func templateValueGreater(candidate, current any, wantMax bool) bool { + candidateFloat, candidateOK := templateFloat(candidate) + currentFloat, currentOK := templateFloat(current) + if candidateOK && currentOK { + if wantMax { + return candidateFloat > currentFloat + } + return candidateFloat < currentFloat + } + + candidateStr := templateStringify(candidate) + currentStr := templateStringify(current) + if wantMax { + return candidateStr > currentStr + } + return candidateStr < currentStr +} diff --git a/test_primitives_test.go b/test_primitives_test.go index ad11b98..1e27af1 100644 --- a/test_primitives_test.go +++ b/test_primitives_test.go @@ -3,7 +3,7 @@ package ansible import ( "io/fs" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) func readTestFile(path string) ([]byte, error) { diff --git a/tests/cli/ansible/Taskfile.yaml b/tests/cli/ansible/Taskfile.yaml new file mode 100644 index 0000000..8355999 --- /dev/null +++ b/tests/cli/ansible/Taskfile.yaml @@ -0,0 +1,26 @@ +version: "3" + +tasks: + default: + deps: + - build + - vet + - test + + build: + desc: Compile every package in go-ansible. + dir: ../../.. + cmds: + - GOWORK=off go build ./... + + vet: + desc: Run go vet across the module. + dir: ../../.. + cmds: + - GOWORK=off go vet ./... + + test: + desc: Run unit tests. + dir: ../../.. + cmds: + - GOWORK=off go test -count=1 ./... diff --git a/types.go b/types.go index 321eb2d..f3fce00 100644 --- a/types.go +++ b/types.go @@ -3,7 +3,7 @@ package ansible import ( "time" - coreerr "dappco.re/go/core/log" + coreerr "dappco.re/go/log" "gopkg.in/yaml.v3" ) @@ -112,6 +112,16 @@ func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error { return nil } +// Role represents a parsed role directory. +type Role struct { + Name string `yaml:"name,omitempty"` + Path string `yaml:"path,omitempty"` + Tasks []Task `yaml:"tasks,omitempty"` + Defaults map[string]any `yaml:"defaults,omitempty"` + Vars map[string]any `yaml:"vars,omitempty"` + Handlers []Task `yaml:"handlers,omitempty"` +} + func directiveValue(fields map[string]any, name string) (any, bool) { if fields == nil { return nil, false @@ -160,6 +170,8 @@ type Task struct { Listen any `yaml:"listen,omitempty"` // string or []string Retries int `yaml:"retries,omitempty"` Delay int `yaml:"delay,omitempty"` + Async int `yaml:"async,omitempty"` + Poll int `yaml:"poll,omitempty"` Until string `yaml:"until,omitempty"` // Include/import directives @@ -234,7 +246,8 @@ type TaskResult struct { // // inventory := Inventory{All: &InventoryGroup{Hosts: map[string]*Host{"web1": {AnsibleHost: "10.0.0.1"}}}} type Inventory struct { - All *InventoryGroup `yaml:"all"` + All *InventoryGroup `yaml:"all"` + HostVars map[string]map[string]any `yaml:"host_vars,omitempty"` } // UnmarshalYAML supports both the explicit `all:` root and inventories that @@ -247,6 +260,7 @@ func (i *Inventory) UnmarshalYAML(unmarshal func(any) error) error { root := &InventoryGroup{} rootInput := make(map[string]any) + hostVars := make(map[string]map[string]any) if all, ok := raw["all"]; ok { group, err := decodeInventoryGroupValue(all) if err != nil { @@ -259,6 +273,21 @@ func (i *Inventory) UnmarshalYAML(unmarshal func(any) error) error { if name == "all" { continue } + if name == "host_vars" { + decoded, err := decodeInventoryHostVarsValue(value) + if err != nil { + return coreerr.E("Inventory.UnmarshalYAML", "decode host_vars", err) + } + for host, vars := range decoded { + if hostVars[host] == nil { + hostVars[host] = make(map[string]any, len(vars)) + } + for key, val := range vars { + hostVars[host][key] = val + } + } + continue + } switch name { case "hosts", "children", "vars": @@ -286,6 +315,7 @@ func (i *Inventory) UnmarshalYAML(unmarshal func(any) error) error { } i.All = root + i.HostVars = hostVars return nil } @@ -334,6 +364,25 @@ func mergeInventoryGroups(dst, src *InventoryGroup) { } } +// decodeInventoryHostVarsValue normalises top-level host_vars into host maps. +func decodeInventoryHostVarsValue(value any) (map[string]map[string]any, error) { + if value == nil { + return nil, nil + } + + data, err := yaml.Marshal(value) + if err != nil { + return nil, err + } + + var hostVars map[string]map[string]any + if err := yaml.Unmarshal(data, &hostVars); err != nil { + return nil, err + } + + return hostVars, nil +} + // InventoryGroup represents a group in inventory. // // Example: