package ansible import ( "bufio" "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io/fs" "net/url" "os" "path" "path/filepath" "reflect" "regexp" "sort" "strconv" "strings" "time" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" "gopkg.in/yaml.v3" ) type sshFactsRunner interface { Run(ctx context.Context, cmd string) (string, string, int, error) } type commandRunner interface { Run(ctx context.Context, cmd string) (string, string, int, error) } // executeModule dispatches to the appropriate module handler. func (e *Executor) executeModule(ctx context.Context, host string, client sshExecutorClient, task *Task, play *Play) (*TaskResult, error) { originalModule := task.Module module := NormalizeModule(originalModule) // Apply task-level become overrides, including an explicit disable. if task.Become != nil { // Save old state to restore after the task finishes. oldBecome, oldUser, oldPass := client.BecomeState() if *task.Become { becomePass := oldPass if becomePass == "" { becomePass = e.resolveBecomePassword(host) } client.SetBecome(true, task.BecomeUser, becomePass) } else { client.SetBecome(false, "", "") } defer client.SetBecome(oldBecome, oldUser, oldPass) } if prefix := e.buildEnvironmentPrefix(host, task, play); prefix != "" { client = &environmentSSHClient{ sshExecutorClient: client, prefix: prefix, } } // Template the args args := e.templateArgs(task.Args, host, task) switch module { // Command execution case "ansible.builtin.shell": return e.moduleShell(ctx, client, args) case "ansible.builtin.command": return e.moduleCommand(ctx, client, args) case "ansible.builtin.raw": return e.moduleRaw(ctx, client, args) case "ansible.builtin.script": return e.moduleScript(ctx, client, args) // File operations case "ansible.builtin.copy": return e.moduleCopy(ctx, client, args, host, task) case "ansible.builtin.template": return e.moduleTemplate(ctx, client, args, host, task) case "ansible.builtin.file": return e.moduleFile(ctx, client, args) case "ansible.builtin.lineinfile": return e.moduleLineinfile(ctx, client, args) case "ansible.builtin.stat": return e.moduleStat(ctx, client, args) case "ansible.builtin.slurp": return e.moduleSlurp(ctx, client, args) case "ansible.builtin.fetch": return e.moduleFetch(ctx, client, args) case "ansible.builtin.get_url": return e.moduleGetURL(ctx, client, args) // Package management case "ansible.builtin.apt": return e.moduleApt(ctx, client, args) case "ansible.builtin.apt_key": return e.moduleAptKey(ctx, client, args) case "ansible.builtin.apt_repository": return e.moduleAptRepository(ctx, client, args) case "ansible.builtin.yum": return e.moduleYum(ctx, client, args) case "ansible.builtin.dnf": return e.moduleDnf(ctx, client, args) case "ansible.builtin.rpm": return e.moduleRPM(ctx, client, args, "rpm") case "ansible.builtin.package": return e.modulePackage(ctx, client, args) case "ansible.builtin.pip": return e.modulePip(ctx, client, args) // Service management case "ansible.builtin.service": return e.moduleService(ctx, client, args) case "ansible.builtin.systemd": return e.moduleSystemd(ctx, client, args) // User/Group case "ansible.builtin.user": return e.moduleUser(ctx, client, args) case "ansible.builtin.group": return e.moduleGroup(ctx, client, args) // HTTP case "ansible.builtin.uri": return e.moduleURI(ctx, client, args) // Misc case "ansible.builtin.debug": return e.moduleDebug(host, task, args) case "ansible.builtin.fail": return e.moduleFail(args) case "ansible.builtin.assert": return e.moduleAssert(args, host) case "ansible.builtin.ping": return e.modulePing(ctx, client, args) case "ansible.builtin.set_fact": return e.moduleSetFact(host, args) case "ansible.builtin.add_host": return e.moduleAddHost(args) case "ansible.builtin.group_by": return e.moduleGroupBy(host, args) case "ansible.builtin.pause": return e.modulePause(ctx, args) case "ansible.builtin.wait_for": return e.moduleWaitFor(ctx, client, args) case "ansible.builtin.git": return e.moduleGit(ctx, client, args) case "ansible.builtin.unarchive": return e.moduleUnarchive(ctx, client, args) case "ansible.builtin.archive": return e.moduleArchive(ctx, client, args) // Additional modules case "ansible.builtin.hostname": return e.moduleHostname(ctx, client, args) case "ansible.builtin.sysctl": return e.moduleSysctl(ctx, client, args) case "ansible.builtin.cron": return e.moduleCron(ctx, client, args) case "ansible.builtin.blockinfile": return e.moduleBlockinfile(ctx, client, args) case "ansible.builtin.include_vars": return e.moduleIncludeVars(args) case "ansible.builtin.meta": return e.moduleMeta(args) case "ansible.builtin.setup": return e.moduleSetup(ctx, host, client, args) case "ansible.builtin.reboot": return e.moduleReboot(ctx, client, args) // Community modules (basic support) case "community.general.ufw": return e.moduleUFW(ctx, client, args) case "ansible.posix.authorized_key": return e.moduleAuthorizedKey(ctx, client, args) case "community.docker.docker_compose": return e.moduleDockerCompose(ctx, client, args) case "community.docker.docker_compose_v2": return e.moduleDockerCompose(ctx, client, args) default: // For unknown modules, try to execute as shell if it looks like a command if contains(task.Module, " ") || task.Module == "" { return e.moduleShell(ctx, client, args) } if originalModule != "" && originalModule != module { return nil, coreerr.E("Executor.executeModule", "unsupported module: "+originalModule+" (resolved to "+module+")", nil) } return nil, coreerr.E("Executor.executeModule", "unsupported module: "+module, nil) } } func (e *Executor) resolveBecomePassword(host string) string { if e == nil { return "" } if v, ok := e.vars["ansible_become_password"].(string); ok && v != "" { return v } if e.inventory != nil { if hostVars := GetHostVars(e.inventory, host); len(hostVars) > 0 { if v, ok := hostVars["ansible_become_password"].(string); ok && v != "" { return v } } } return "" } func remoteFileText(ctx context.Context, client sshExecutorClient, path string) (string, bool) { data, err := client.Download(ctx, path) if err != nil { return "", false } return string(data), true } func fileDiffData(path, before, after string) map[string]any { return map[string]any{ "path": path, "before": before, "after": after, } } func backupRemoteFile(ctx context.Context, client sshExecutorClient, path string) (string, bool, error) { before, ok := remoteFileText(ctx, client, path) if !ok { return "", false, nil } backupPath := sprintf("%s.%s.bak", path, time.Now().UTC().Format("20060102T150405Z")) if err := client.Upload(ctx, bytes.NewReader([]byte(before)), backupPath, 0600); err != nil { return "", true, err } return backupPath, true, nil } // templateArgs templates all string values in args. func (e *Executor) templateArgs(args map[string]any, host string, task *Task) map[string]any { // Set inventory_hostname for templating oldInventoryHostname, hasInventoryHostname := e.vars["inventory_hostname"] e.vars["inventory_hostname"] = host defer func() { if hasInventoryHostname { e.vars["inventory_hostname"] = oldInventoryHostname } else { delete(e.vars, "inventory_hostname") } }() result := make(map[string]any) for k, v := range args { switch val := v.(type) { case string: result[k] = e.templateString(val, host, task) case map[string]any: // Recurse for nested maps result[k] = e.templateArgs(val, host, task) case []any: // Template strings in arrays templated := make([]any, len(val)) for i, item := range val { if s, ok := item.(string); ok { templated[i] = e.templateString(s, host, task) } else { templated[i] = item } } result[k] = templated default: result[k] = v } } return result } // --- Command Modules --- func (e *Executor) moduleShell(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { cmd := getStringArg(args, "_raw_params", "") if cmd == "" { cmd = getStringArg(args, "cmd", "") } if cmd == "" { return nil, coreerr.E("Executor.moduleShell", "no command specified", nil) } chdir := getStringArg(args, "chdir", "") skip, err := shouldSkipCommandModule(ctx, client, args, chdir) if err != nil { return nil, err } if skip { return &TaskResult{Changed: false}, nil } // Handle chdir if chdir != "" { cmd = sprintf("cd %q && %s", chdir, cmd) } if stdin := getStringArg(args, "stdin", ""); stdin != "" { cmd = prefixCommandStdin(cmd, stdin, getBoolArg(args, "stdin_add_newline", true)) } stdout, stderr, rc, err := client.RunScript(ctx, cmd) if err != nil { return &TaskResult{Failed: true, Msg: err.Error(), Stdout: stdout, Stderr: stderr, RC: rc}, nil } return &TaskResult{ Changed: true, Stdout: stdout, Stderr: stderr, RC: rc, Failed: rc != 0, }, nil } func (e *Executor) moduleCommand(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { cmd := buildCommandModuleCommand(args) if cmd == "" { return nil, coreerr.E("Executor.moduleCommand", "no command specified", nil) } chdir := getStringArg(args, "chdir", "") skip, err := shouldSkipCommandModule(ctx, client, args, chdir) if err != nil { return nil, err } if skip { return &TaskResult{Changed: false}, nil } // Handle chdir if chdir != "" { cmd = sprintf("cd %q && %s", chdir, cmd) } if stdin := getStringArg(args, "stdin", ""); stdin != "" { cmd = prefixCommandStdin(cmd, stdin, getBoolArg(args, "stdin_add_newline", true)) } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil { return &TaskResult{Failed: true, Msg: err.Error()}, nil } return &TaskResult{ Changed: true, Stdout: stdout, Stderr: stderr, RC: rc, Failed: rc != 0, }, nil } func buildCommandModuleCommand(args map[string]any) string { if argv := commandArgv(args); len(argv) > 0 { return join(" ", quoteArgs(argv)) } cmd := getStringArg(args, "_raw_params", "") if cmd == "" { cmd = getStringArg(args, "cmd", "") } return cmd } func commandArgv(args map[string]any) []string { raw, ok := args["argv"] if !ok { return nil } switch v := raw.(type) { case []string: out := make([]string, 0, len(v)) for _, item := range v { if item != "" { out = append(out, item) } } return out case []any: out := make([]string, 0, len(v)) for _, item := range v { if s, ok := item.(string); ok { if s != "" { out = append(out, s) } continue } s := sprintf("%v", item) if s != "" && s != "" { out = append(out, s) } } return out case string: if v == "" { return nil } return []string{v} default: s := sprintf("%v", v) if s == "" || s == "" { return nil } return []string{s} } } func shouldSkipCommandModule(ctx context.Context, client sshExecutorClient, args map[string]any, chdir string) (bool, error) { if path := getStringArg(args, "creates", ""); path != "" { path = resolveCommandModulePath(path, chdir) exists, err := client.FileExists(ctx, path) if err != nil { return false, coreerr.E("Executor.shouldSkipCommandModule", "creates check", err) } if exists { return true, nil } } if path := getStringArg(args, "removes", ""); path != "" { path = resolveCommandModulePath(path, chdir) exists, err := client.FileExists(ctx, path) if err != nil { return false, coreerr.E("Executor.shouldSkipCommandModule", "removes check", err) } if !exists { return true, nil } } return false, nil } func resolveCommandModulePath(filePath, chdir string) string { filePath = strings.TrimSpace(filePath) if filePath == "" || path.IsAbs(filePath) || chdir == "" { return filePath } return path.Join(chdir, filePath) } func (e *Executor) moduleRaw(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { cmd := getStringArg(args, "_raw_params", "") if cmd == "" { return nil, coreerr.E("Executor.moduleRaw", "no command specified", nil) } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil { return &TaskResult{Failed: true, Msg: err.Error()}, nil } return &TaskResult{ Changed: true, Stdout: stdout, Stderr: stderr, RC: rc, }, nil } func (e *Executor) moduleScript(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { script := getStringArg(args, "_raw_params", "") if script == "" { return nil, coreerr.E("Executor.moduleScript", "no script specified", nil) } chdir := getStringArg(args, "chdir", "") skip, err := shouldSkipCommandModule(ctx, client, args, chdir) if err != nil { return nil, err } if skip { return &TaskResult{Changed: false}, nil } // Read local script script = e.resolveLocalPath(script) data, err := coreio.Local.Read(script) if err != nil { return nil, coreerr.E("Executor.moduleScript", "read script", err) } if chdir != "" { data = sprintf("cd %q && %s", chdir, data) } stdout, stderr, rc, err := client.RunScript(ctx, data) if err != nil { return &TaskResult{Failed: true, Msg: err.Error()}, nil } return &TaskResult{ Changed: true, Stdout: stdout, Stderr: stderr, RC: rc, Failed: rc != 0, }, nil } // --- File Modules --- func (e *Executor) moduleCopy(ctx context.Context, client sshExecutorClient, args map[string]any, host string, task *Task) (*TaskResult, error) { dest := getStringArg(args, "dest", "") if dest == "" { return nil, coreerr.E("Executor.moduleCopy", "dest required", nil) } force := getBoolArg(args, "force", true) backup := getBoolArg(args, "backup", false) remoteSrc := getBoolArg(args, "remote_src", false) var content string var err error if src := getStringArg(args, "src", ""); src != "" { if remoteSrc { var data []byte data, err = client.Download(ctx, src) if err != nil { return nil, coreerr.E("Executor.moduleCopy", "download src", err) } content = string(data) } else { src = e.resolveLocalPath(src) content, err = coreio.Local.Read(src) if err != nil { return nil, coreerr.E("Executor.moduleCopy", "read src", err) } } } else if c := getStringArg(args, "content", ""); c != "" { content = c } else { return nil, coreerr.E("Executor.moduleCopy", "src or content required", nil) } mode := fs.FileMode(0644) if m := getStringArg(args, "mode", ""); m != "" { if parsed, err := strconv.ParseInt(m, 8, 32); err == nil { mode = fs.FileMode(parsed) } } before, hasBefore := remoteFileText(ctx, client, dest) if hasBefore && !force { return &TaskResult{Changed: false, Msg: sprintf("skipped existing destination: %s", dest)}, nil } if hasBefore && before == content { if getStringArg(args, "owner", "") == "" && getStringArg(args, "group", "") == "" { return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", dest)}, nil } } var backupPath string if backup && hasBefore { backupPath, _, err = backupRemoteFile(ctx, client, dest) if err != nil { return nil, coreerr.E("Executor.moduleCopy", "backup destination", err) } } err = client.Upload(ctx, newReader(content), dest, mode) if err != nil { return nil, err } // Handle owner/group (best-effort, errors ignored) if owner := getStringArg(args, "owner", ""); owner != "" { _, _, _, _ = client.Run(ctx, sprintf("chown %s %q", owner, dest)) } if group := getStringArg(args, "group", ""); group != "" { _, _, _, _ = client.Run(ctx, sprintf("chgrp %s %q", group, dest)) } result := &TaskResult{Changed: true, Msg: sprintf("copied to %s", dest)} if backupPath != "" { result.Data = map[string]any{"backup_file": backupPath} } if e.Diff { if hasBefore { if result.Data == nil { result.Data = make(map[string]any) } result.Data["diff"] = fileDiffData(dest, before, content) } } return result, nil } func (e *Executor) moduleTemplate(ctx context.Context, client sshExecutorClient, args map[string]any, host string, task *Task) (*TaskResult, error) { src := getStringArg(args, "src", "") dest := getStringArg(args, "dest", "") if src == "" || dest == "" { return nil, coreerr.E("Executor.moduleTemplate", "src and dest required", nil) } force := getBoolArg(args, "force", true) backup := getBoolArg(args, "backup", false) // Process template src = e.resolveLocalPath(src) content, err := e.TemplateFile(src, host, task) if err != nil { return nil, coreerr.E("Executor.moduleTemplate", "template", err) } mode := fs.FileMode(0644) if m := getStringArg(args, "mode", ""); m != "" { if parsed, err := strconv.ParseInt(m, 8, 32); err == nil { mode = fs.FileMode(parsed) } } before, hasBefore := remoteFileText(ctx, client, dest) if hasBefore && !force { return &TaskResult{Changed: false, Msg: sprintf("skipped existing destination: %s", dest)}, nil } if hasBefore && before == content { return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", dest)}, nil } var backupPath string if backup && hasBefore { backupPath, _, err = backupRemoteFile(ctx, client, dest) if err != nil { return nil, coreerr.E("Executor.moduleTemplate", "backup destination", err) } } err = client.Upload(ctx, newReader(content), dest, mode) if err != nil { return nil, err } result := &TaskResult{Changed: true, Msg: sprintf("templated to %s", dest)} if backupPath != "" { result.Data = map[string]any{"backup_file": backupPath} } if e.Diff { if hasBefore { if result.Data == nil { result.Data = make(map[string]any) } result.Data["diff"] = fileDiffData(dest, before, content) } } return result, nil } func (e *Executor) moduleFile(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { path := getStringArg(args, "path", "") if path == "" { path = getStringArg(args, "dest", "") } if path == "" { return nil, coreerr.E("Executor.moduleFile", "path required", nil) } state := getStringArg(args, "state", "file") switch state { case "directory": mode := getStringArg(args, "mode", "0755") cmd := sprintf("mkdir -p %q && chmod %s %q", path, mode, path) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } case "absent": cmd := sprintf("rm -rf %q", path) _, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil } case "touch": cmd := sprintf("touch %q", path) _, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil } case "link": src := getStringArg(args, "src", "") if src == "" { return nil, coreerr.E("Executor.moduleFile", "src required for link state", nil) } cmd := sprintf("ln -sf %q %q", src, path) _, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil } case "file": // Ensure file exists and set permissions if mode := getStringArg(args, "mode", ""); mode != "" { _, _, _, _ = client.Run(ctx, sprintf("chmod %s %q", mode, path)) } } // Handle owner/group (best-effort, errors ignored) if owner := getStringArg(args, "owner", ""); owner != "" { _, _, _, _ = client.Run(ctx, sprintf("chown %s %q", owner, path)) } if group := getStringArg(args, "group", ""); group != "" { _, _, _, _ = client.Run(ctx, sprintf("chgrp %s %q", group, path)) } if recurse := getBoolArg(args, "recurse", false); recurse { if owner := getStringArg(args, "owner", ""); owner != "" { _, _, _, _ = client.Run(ctx, sprintf("chown -R %s %q", owner, path)) } } return &TaskResult{Changed: true}, nil } func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { path := getStringArg(args, "path", "") if path == "" { path = getStringArg(args, "dest", "") } if path == "" { return nil, coreerr.E("Executor.moduleLineinfile", "path required", nil) } before, hasBefore := remoteFileText(ctx, client, path) line := getStringArg(args, "line", "") regexp := getStringArg(args, "regexp", "") state := getStringArg(args, "state", "present") backrefs := getBoolArg(args, "backrefs", false) create := getBoolArg(args, "create", false) insertBefore := getStringArg(args, "insertbefore", "") insertAfter := getStringArg(args, "insertafter", "") firstMatch := getBoolArg(args, "firstmatch", false) if state != "absent" && line != "" && regexp == "" && insertBefore == "" && insertAfter == "" { if hasBefore && fileContainsExactLine(before, line) { return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil } } if state == "absent" { if regexp != "" { if content, ok := remoteFileText(ctx, client, path); !ok || !regexpMatchString(regexp, content) { return &TaskResult{Changed: false}, nil } cmd := sprintf("sed -i '/%s/d' %q", regexp, path) _, stderr, rc, _ := client.Run(ctx, cmd) if rc != 0 { return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil } } } else { // Create the file first when requested so regexp-based updates have a // target to operate on. if create { _, _, _, _ = client.Run(ctx, sprintf("touch %q", path)) } // state == present if regexp != "" { // Replace line matching regexp. escapedLine := replaceAll(line, "/", "\\/") sedFlags := "-i" if backrefs { // When backrefs is enabled, Ansible only replaces matching lines // and does not append a new line when the pattern is absent. matchCmd := sprintf("grep -Eq %q %q", regexp, path) _, _, matchRC, _ := client.Run(ctx, matchCmd) if matchRC != 0 { return &TaskResult{Changed: false}, nil } sedFlags = "-E -i" } cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexp, escapedLine, path) _, _, rc, _ := client.Run(ctx, cmd) if rc != 0 { if backrefs { return &TaskResult{Changed: false}, nil } if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil { return nil, err } else if inserted { return &TaskResult{Changed: true}, nil } // Line not found, append. cmd = sprintf("echo %q >> %q", line, path) _, _, _, _ = client.Run(ctx, cmd) } } else if line != "" { if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil { return nil, err } else if inserted { return &TaskResult{Changed: true}, nil } // Ensure line is present cmd := sprintf("grep -qxF %q %q || echo %q >> %q", line, path, line, path) _, _, _, _ = client.Run(ctx, cmd) } } result := &TaskResult{Changed: true} if e.Diff { if after, ok := remoteFileText(ctx, client, path); ok && before != after { result.Data = map[string]any{"diff": fileDiffData(path, before, after)} } } return result, nil } func fileContainsExactLine(content, line string) bool { if content == "" || line == "" { return false } for _, candidate := range strings.Split(content, "\n") { if candidate == line { return true } } return false } func regexpMatchString(pattern, value string) bool { re, err := regexp.Compile(pattern) if err != nil { return false } return re.MatchString(value) } func insertLineRelativeToMatch(ctx context.Context, client commandRunner, path, line, insertBefore, insertAfter string, firstMatch bool) (bool, error) { if line == "" { return false, nil } if insertBefore == "BOF" { cmd := sprintf("tmp=$(mktemp) && { printf %%s %s; cat %q; } > \"$tmp\" && mv \"$tmp\" %q", shellSingleQuote(line+"\n"), path, path) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return false, coreerr.E("Executor.moduleLineinfile", "insertbefore line", err) } _ = stdout _ = stderr return true, nil } if insertAfter == "EOF" { cmd := sprintf("echo %q >> %q", line, path) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return false, coreerr.E("Executor.moduleLineinfile", "insertafter line", err) } _ = stdout _ = stderr return true, nil } if insertBefore != "" { matchCmd := sprintf("grep -Eq %q %q", insertBefore, path) _, _, matchRC, _ := client.Run(ctx, matchCmd) if matchRC == 0 { cmd := buildLineinfileInsertCommand(path, line, insertBefore, false, firstMatch) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return false, coreerr.E("Executor.moduleLineinfile", "insertbefore line", err) } _ = stdout _ = stderr return true, nil } } if insertAfter != "" { matchCmd := sprintf("grep -Eq %q %q", insertAfter, path) _, _, matchRC, _ := client.Run(ctx, matchCmd) if matchRC == 0 { cmd := buildLineinfileInsertCommand(path, line, insertAfter, true, firstMatch) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return false, coreerr.E("Executor.moduleLineinfile", "insertafter line", err) } _ = stdout _ = stderr return true, nil } } return false, nil } func buildLineinfileInsertCommand(path, line, anchor string, after, firstMatch bool) string { quotedLine := shellSingleQuote(line) quotedAnchor := shellSingleQuote(anchor) if firstMatch { if after { return sprintf("tmp=$(mktemp) && awk -v line=%s -v re=%s 'BEGIN{done=0} { print; if (!done && $0 ~ re) { print line; done=1 } }' %q > \"$tmp\" && mv \"$tmp\" %q", quotedLine, quotedAnchor, path, path) } return sprintf("tmp=$(mktemp) && awk -v line=%s -v re=%s 'BEGIN{done=0} { if (!done && $0 ~ re) { print line; done=1 } print }' %q > \"$tmp\" && mv \"$tmp\" %q", quotedLine, quotedAnchor, path, path) } if after { return sprintf("tmp=$(mktemp) && awk -v line=%s -v re=%s 'BEGIN{pos=0} { lines[NR]=$0; if ($0 ~ re) { pos=NR } } END { for (i=1; i<=NR; i++) { print lines[i]; if (i==pos) { print line } } }' %q > \"$tmp\" && mv \"$tmp\" %q", quotedLine, quotedAnchor, path, path) } return sprintf("tmp=$(mktemp) && awk -v line=%s -v re=%s 'BEGIN{pos=0} { lines[NR]=$0; if ($0 ~ re) { pos=NR } } END { for (i=1; i<=NR; i++) { if (i==pos) { print line } print lines[i] } }' %q > \"$tmp\" && mv \"$tmp\" %q", quotedLine, quotedAnchor, path, path) } func (e *Executor) moduleStat(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { path := getStringArg(args, "path", "") if path == "" { return nil, coreerr.E("Executor.moduleStat", "path required", nil) } stat, err := client.Stat(ctx, path) if err != nil { return nil, err } return &TaskResult{ Changed: false, Data: map[string]any{"stat": stat}, }, nil } func (e *Executor) moduleSlurp(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { path := getStringArg(args, "path", "") if path == "" { path = getStringArg(args, "src", "") } if path == "" { return nil, coreerr.E("Executor.moduleSlurp", "path required", nil) } content, err := client.Download(ctx, path) if err != nil { return nil, err } encoded := base64.StdEncoding.EncodeToString(content) return &TaskResult{ Changed: false, Data: map[string]any{"content": encoded, "encoding": "base64"}, }, nil } func (e *Executor) moduleFetch(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { src := getStringArg(args, "src", "") dest := getStringArg(args, "dest", "") if src == "" || dest == "" { return nil, coreerr.E("Executor.moduleFetch", "src and dest required", nil) } content, err := client.Download(ctx, src) if err != nil { return nil, err } // Create dest directory if err := coreio.Local.EnsureDir(pathDir(dest)); err != nil { return nil, err } if err := coreio.Local.Write(dest, string(content)); err != nil { return nil, err } return &TaskResult{Changed: true, Msg: sprintf("fetched %s to %s", src, dest)}, nil } func (e *Executor) moduleGetURL(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { url := getStringArg(args, "url", "") dest := getStringArg(args, "dest", "") if url == "" || dest == "" { return nil, coreerr.E("Executor.moduleGetURL", "url and dest required", nil) } // Use curl or wget cmd := sprintf("curl -fsSL -o %q %q || wget -q -O %q %q", dest, url, dest, url) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } // Set mode if specified (best-effort) if mode := getStringArg(args, "mode", ""); mode != "" { _, _, _, _ = client.Run(ctx, sprintf("chmod %s %q", mode, dest)) } return &TaskResult{Changed: true}, nil } // --- Package Modules --- func (e *Executor) moduleApt(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { names := normalizeStringArgs(args["name"]) state := getStringArg(args, "state", "present") updateCache := getBoolArg(args, "update_cache", false) var cmd string if updateCache { _, _, _, _ = client.Run(ctx, "apt-get update -qq") } switch state { case "present", "installed": if len(names) > 0 { cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s", join(" ", names)) } case "absent", "removed": if len(names) > 0 { cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq %s", join(" ", names)) } case "latest": if len(names) > 0 { cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade %s", join(" ", names)) } } if cmd == "" { return &TaskResult{Changed: false}, nil } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } func (e *Executor) moduleAptKey(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { url := getStringArg(args, "url", "") keyring := getStringArg(args, "keyring", "") state := getStringArg(args, "state", "present") if state == "absent" { if keyring != "" { _, _, _, _ = client.Run(ctx, sprintf("rm -f %q", keyring)) } return &TaskResult{Changed: true}, nil } if url == "" { return nil, coreerr.E("Executor.moduleAptKey", "url required", nil) } var cmd string if keyring != "" { cmd = sprintf("curl -fsSL %q | gpg --dearmor -o %q", url, keyring) } else { cmd = sprintf("curl -fsSL %q | apt-key add -", url) } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } func (e *Executor) moduleAptRepository(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { repo := getStringArg(args, "repo", "") filename := getStringArg(args, "filename", "") state := getStringArg(args, "state", "present") if repo == "" { return nil, coreerr.E("Executor.moduleAptRepository", "repo required", nil) } if filename == "" { // Generate filename from repo filename = replaceAll(repo, " ", "-") filename = replaceAll(filename, "/", "-") filename = replaceAll(filename, ":", "") } path := sprintf("/etc/apt/sources.list.d/%s.list", filename) if state == "absent" { _, _, _, _ = client.Run(ctx, sprintf("rm -f %q", path)) return &TaskResult{Changed: true}, nil } cmd := sprintf("echo %q > %q", repo, path) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } // Update apt cache (best-effort) if getBoolArg(args, "update_cache", true) { _, _, _, _ = client.Run(ctx, "apt-get update -qq") } return &TaskResult{Changed: true}, nil } func (e *Executor) modulePackage(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { // Detect package manager and delegate stdout, _, _, _ := client.Run(ctx, "which apt-get yum dnf 2>/dev/null | head -1") stdout = corexTrimSpace(stdout) switch { case contains(stdout, "dnf"): return e.moduleDnf(ctx, client, args) case contains(stdout, "yum"): return e.moduleYum(ctx, client, args) default: return e.moduleApt(ctx, client, args) } } func (e *Executor) moduleYum(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { return e.moduleRPM(ctx, client, args, "yum") } func (e *Executor) moduleDnf(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { return e.moduleRPM(ctx, client, args, "dnf") } func (e *Executor) moduleRPM(ctx context.Context, client sshExecutorClient, args map[string]any, manager string) (*TaskResult, error) { names := normalizeStringArgs(args["name"]) state := getStringArg(args, "state", "present") updateCache := getBoolArg(args, "update_cache", false) if updateCache && manager != "rpm" { _, _, _, _ = client.Run(ctx, sprintf("%s makecache -y", manager)) } var cmd string switch state { case "present", "installed": if len(names) > 0 { if manager == "rpm" { cmd = sprintf("rpm -ivh %s", join(" ", names)) } else { cmd = sprintf("%s install -y -q %s", manager, join(" ", names)) } } case "absent", "removed": if len(names) > 0 { if manager == "rpm" { cmd = sprintf("rpm -e %s", join(" ", names)) } else { cmd = sprintf("%s remove -y -q %s", manager, join(" ", names)) } } case "latest": if len(names) > 0 { if manager == "rpm" { cmd = sprintf("rpm -Uvh %s", join(" ", names)) } else if manager == "dnf" { cmd = sprintf("%s upgrade -y -q %s", manager, join(" ", names)) } else { cmd = sprintf("%s update -y -q %s", manager, join(" ", names)) } } } if cmd == "" { return &TaskResult{Changed: false}, nil } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } func (e *Executor) modulePip(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { names := normalizeStringArgs(args["name"]) state := getStringArg(args, "state", "present") executable := getStringArg(args, "executable", "pip3") virtualenv := getStringArg(args, "virtualenv", "") requirements := getStringArg(args, "requirements", "") extraArgs := getStringArg(args, "extra_args", "") if virtualenv != "" && executable == "pip3" { executable = path.Join(virtualenv, "bin", "pip") } var cmd string switch state { case "present", "installed": parts := []string{executable, "install"} if extraArgs != "" { parts = append(parts, extraArgs) } switch { case requirements != "": parts = append(parts, sprintf("-r %q", requirements)) case len(names) > 0: parts = append(parts, join(" ", names)) } cmd = join(" ", parts) case "absent", "removed": if len(names) > 0 { parts := []string{executable, "uninstall", "-y"} if extraArgs != "" { parts = append(parts, extraArgs) } parts = append(parts, join(" ", names)) cmd = join(" ", parts) } case "latest": if len(names) > 0 { parts := []string{executable, "install", "--upgrade"} if extraArgs != "" { parts = append(parts, extraArgs) } parts = append(parts, join(" ", names)) cmd = join(" ", parts) } } if cmd == "" { return &TaskResult{Changed: false}, nil } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } // --- Service Modules --- func (e *Executor) moduleService(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { name := getStringArg(args, "name", "") state := getStringArg(args, "state", "") enabled := args["enabled"] if name == "" { return nil, coreerr.E("Executor.moduleService", "name required", nil) } var cmds []string if state != "" { switch state { case "started": cmds = append(cmds, sprintf("systemctl start %s", name)) case "stopped": cmds = append(cmds, sprintf("systemctl stop %s", name)) case "restarted": cmds = append(cmds, sprintf("systemctl restart %s", name)) case "reloaded": cmds = append(cmds, sprintf("systemctl reload %s", name)) } } if enabled != nil { if getBoolArg(args, "enabled", false) { cmds = append(cmds, sprintf("systemctl enable %s", name)) } else { cmds = append(cmds, sprintf("systemctl disable %s", name)) } } for _, cmd := range cmds { stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } } return &TaskResult{Changed: len(cmds) > 0}, nil } func (e *Executor) moduleSystemd(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { // systemd is similar to service if getBoolArg(args, "daemon_reload", false) { _, _, _, _ = client.Run(ctx, "systemctl daemon-reload") } return e.moduleService(ctx, client, args) } // --- User/Group Modules --- func (e *Executor) moduleUser(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { name := getStringArg(args, "name", "") state := getStringArg(args, "state", "present") if name == "" { return nil, coreerr.E("Executor.moduleUser", "name required", nil) } if state == "absent" { cmd := sprintf("userdel -r %s 2>/dev/null || true", name) _, _, _, _ = client.Run(ctx, cmd) return &TaskResult{Changed: true}, nil } // Build useradd/usermod command var opts []string if uid := getStringArg(args, "uid", ""); uid != "" { opts = append(opts, "-u", uid) } if group := getStringArg(args, "group", ""); group != "" { opts = append(opts, "-g", group) } if groups := normalizeStringArgs(args["groups"]); len(groups) > 0 { opts = append(opts, "-G", join(",", groups)) } if home := getStringArg(args, "home", ""); home != "" { opts = append(opts, "-d", home) } if shell := getStringArg(args, "shell", ""); shell != "" { opts = append(opts, "-s", shell) } if getBoolArg(args, "system", false) { opts = append(opts, "-r") } if getBoolArg(args, "create_home", true) { opts = append(opts, "-m") } // Try usermod first, then useradd optsStr := join(" ", opts) var cmd string if optsStr == "" { cmd = sprintf("id %s >/dev/null 2>&1 || useradd %s", name, name) } else { cmd = sprintf("id %s >/dev/null 2>&1 && usermod %s %s || useradd %s %s", name, optsStr, name, optsStr, name) } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } func (e *Executor) moduleGroup(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { name := getStringArg(args, "name", "") state := getStringArg(args, "state", "present") if name == "" { return nil, coreerr.E("Executor.moduleGroup", "name required", nil) } if state == "absent" { cmd := sprintf("groupdel %s 2>/dev/null || true", name) _, _, _, _ = client.Run(ctx, cmd) return &TaskResult{Changed: true}, nil } var opts []string if gid := getStringArg(args, "gid", ""); gid != "" { opts = append(opts, "-g", gid) } if getBoolArg(args, "system", false) { opts = append(opts, "-r") } cmd := sprintf("getent group %s >/dev/null 2>&1 || groupadd %s %s", name, join(" ", opts), name) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } // --- HTTP Module --- func (e *Executor) moduleURI(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { url := getStringArg(args, "url", "") method := getStringArg(args, "method", "GET") bodyFormat := lower(getStringArg(args, "body_format", "")) returnContent := getBoolArg(args, "return_content", false) dest := getStringArg(args, "dest", "") timeout := getIntArg(args, "timeout", 0) validateCerts := getBoolArg(args, "validate_certs", true) if url == "" { return nil, coreerr.E("Executor.moduleURI", "url required", nil) } var curlOpts []string curlOpts = append(curlOpts, "-s", "-S") curlOpts = append(curlOpts, "-X", method) // Headers if headers, ok := args["headers"].(map[string]any); ok { for k, v := range headers { curlOpts = append(curlOpts, "-H", sprintf("%q", sprintf("%s: %v", k, v))) } } if !validateCerts { curlOpts = append(curlOpts, "-k") } // Body if body := args["body"]; body != nil { bodyText, err := renderURIBody(body, bodyFormat) if err != nil { return nil, coreerr.E("Executor.moduleURI", "render body", err) } if bodyText != "" { curlOpts = append(curlOpts, "-d", sprintf("%q", bodyText)) if !hasHeaderIgnoreCase(headersMap(args), "Content-Type") { switch bodyFormat { case "json": curlOpts = append(curlOpts, "-H", "\"Content-Type: application/json\"") case "form-urlencoded", "form_urlencoded", "form": curlOpts = append(curlOpts, "-H", "\"Content-Type: application/x-www-form-urlencoded\"") } } } } if timeout > 0 { curlOpts = append(curlOpts, "--max-time", strconv.Itoa(timeout)) } // Status code curlOpts = append(curlOpts, "-w", "\\n%{http_code}") cmd := sprintf("curl %s %q", join(" ", curlOpts), url) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil { return &TaskResult{Failed: true, Msg: err.Error()}, nil } // Parse status code from last line lines := split(stdout, "\n") statusCode := 0 content := "" if len(lines) > 0 { statusText := corexTrimSpace(lines[len(lines)-1]) statusCode, _ = strconv.Atoi(statusText) if len(lines) > 1 { content = join("\n", lines[:len(lines)-1]) } } // Check expected status codes. expectedStatuses := normalizeStatusCodes(args["status_code"], 200) failed := rc != 0 || !containsInt(expectedStatuses, statusCode) data := map[string]any{"status": statusCode} if returnContent { data["content"] = content } if failed { return &TaskResult{ Changed: false, Failed: true, Stdout: stdout, Stderr: stderr, RC: statusCode, Data: data, }, nil } if dest != "" { before, hasBefore := remoteFileText(ctx, client, dest) if !hasBefore || before != content { if err := client.Upload(ctx, newReader(content), dest, 0644); err != nil { return nil, coreerr.E("Executor.moduleURI", "upload dest", err) } data["dest"] = dest return &TaskResult{ Changed: true, Stdout: stdout, Stderr: stderr, RC: statusCode, Data: data, }, nil } data["dest"] = dest } return &TaskResult{ Changed: false, Stdout: stdout, Stderr: stderr, RC: statusCode, Data: data, }, nil } func renderURIBody(body any, bodyFormat string) (string, error) { switch bodyFormat { case "", "raw": return sprintf("%v", body), nil case "json": switch v := body.(type) { case string: return v, nil case []byte: return string(v), nil default: data, err := json.Marshal(v) if err != nil { return "", err } return string(data), nil } case "form-urlencoded", "form_urlencoded", "form": return renderURIBodyFormEncoded(body), nil default: return sprintf("%v", body), nil } } func renderURIBodyFormEncoded(body any) string { values := url.Values{} switch v := body.(type) { case map[string]any: keys := make([]string, 0, len(v)) for key := range v { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { appendFormValue(values, key, v[key]) } case map[any]any: keys := make([]string, 0, len(v)) for key := range v { if s, ok := key.(string); ok { keys = append(keys, s) } } sort.Strings(keys) for _, key := range keys { appendFormValue(values, key, v[key]) } case map[string]string: keys := make([]string, 0, len(v)) for key := range v { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { values.Add(key, v[key]) } case []any: for _, item := range v { if pair, ok := item.(map[string]any); ok { key := getStringArg(pair, "key", "") if key == "" { key = getStringArg(pair, "name", "") } if key != "" { appendFormValue(values, key, pair["value"]) } } } case string: return v default: return sprintf("%v", body) } return values.Encode() } func appendFormValue(values url.Values, key string, value any) { switch v := value.(type) { case nil: values.Add(key, "") case string: values.Add(key, v) case []string: for _, item := range v { values.Add(key, item) } case []any: for _, item := range v { values.Add(key, sprintf("%v", item)) } default: values.Add(key, sprintf("%v", v)) } } func headersMap(args map[string]any) map[string]any { headers, _ := args["headers"].(map[string]any) return headers } func hasHeaderIgnoreCase(headers map[string]any, name string) bool { for key := range headers { if lower(key) == lower(name) { return true } } return false } // --- Misc Modules --- func (e *Executor) moduleDebug(host string, task *Task, args map[string]any) (*TaskResult, error) { msg := getStringArg(args, "msg", "") if v, ok := args["var"]; ok { name := sprintf("%v", v) if value, ok := e.lookupConditionValue(name, host, task, nil); ok { msg = sprintf("%s = %v", name, value) } else { msg = sprintf("%s = ", name) } } return &TaskResult{ Changed: false, Msg: msg, }, nil } func (e *Executor) moduleFail(args map[string]any) (*TaskResult, error) { msg := getStringArg(args, "msg", "Failed as requested") return &TaskResult{ Failed: true, Msg: msg, }, nil } func (e *Executor) modulePing(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { data := getStringArg(args, "data", "pong") stdout, stderr, rc, err := client.Run(ctx, "true") if err != nil { return &TaskResult{Failed: true, Msg: err.Error(), Stdout: stdout, Stderr: stderr, RC: rc}, nil } if rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, Stderr: stderr, RC: rc}, nil } return &TaskResult{ Msg: data, Data: map[string]any{"ping": data}, }, nil } func (e *Executor) moduleAssert(args map[string]any, host string) (*TaskResult, error) { that, ok := args["that"] if !ok { return nil, coreerr.E("Executor.moduleAssert", "'that' required", nil) } conditions := normalizeConditions(that) for _, cond := range conditions { if !e.evalCondition(cond, host) { msg := getStringArg(args, "fail_msg", sprintf("Assertion failed: %s", cond)) return &TaskResult{Failed: true, Msg: msg}, nil } } return &TaskResult{Changed: false, Msg: "All assertions passed"}, nil } func (e *Executor) moduleSetFact(host string, args map[string]any) (*TaskResult, error) { values := make(map[string]any, len(args)) for k, v := range args { if k == "cacheable" { continue } values[k] = v } e.setHostVars(host, values) e.setHostFacts(host, values) return &TaskResult{ Changed: true, Data: map[string]any{"ansible_facts": values}, }, nil } func (e *Executor) moduleAddHost(args map[string]any) (*TaskResult, error) { name := getStringArg(args, "name", "") if name == "" { name = getStringArg(args, "hostname", "") } if name == "" { return nil, coreerr.E("Executor.moduleAddHost", "name required", nil) } groups := normalizeStringList(args["groups"]) if len(groups) == 0 { groups = normalizeStringList(args["group"]) } e.mu.Lock() defer e.mu.Unlock() if e.inventory == nil { e.inventory = &Inventory{} } if e.inventory.All == nil { e.inventory.All = &InventoryGroup{} } host := findInventoryHost(e.inventory.All, name) changed := false if host == nil { host = &Host{} changed = true } if host.Vars == nil { host.Vars = make(map[string]any) } if v := getStringArg(args, "ansible_host", ""); v != "" { if host.AnsibleHost != v { changed = true } host.AnsibleHost = v } switch v := args["ansible_port"].(type) { case int: host.AnsiblePort = v case int8: host.AnsiblePort = int(v) case int16: host.AnsiblePort = int(v) case int32: host.AnsiblePort = int(v) case int64: host.AnsiblePort = int(v) case uint: host.AnsiblePort = int(v) case uint8: host.AnsiblePort = int(v) case uint16: host.AnsiblePort = int(v) case uint32: host.AnsiblePort = int(v) case uint64: host.AnsiblePort = int(v) case string: if port, err := strconv.Atoi(v); err == nil { if host.AnsiblePort != port { changed = true } host.AnsiblePort = port } } if v := getStringArg(args, "ansible_user", ""); v != "" { if host.AnsibleUser != v { changed = true } host.AnsibleUser = v } if v := getStringArg(args, "ansible_password", ""); v != "" { if host.AnsiblePassword != v { changed = true } host.AnsiblePassword = v } if v := getStringArg(args, "ansible_ssh_private_key_file", ""); v != "" { if host.AnsibleSSHPrivateKeyFile != v { changed = true } host.AnsibleSSHPrivateKeyFile = v } if v := getStringArg(args, "ansible_connection", ""); v != "" { if host.AnsibleConnection != v { changed = true } host.AnsibleConnection = v } if v := getStringArg(args, "ansible_become_password", ""); v != "" { if host.AnsibleBecomePassword != v { changed = true } host.AnsibleBecomePassword = v } reserved := map[string]bool{ "name": true, "hostname": true, "groups": true, "group": true, "ansible_host": true, "ansible_port": true, "ansible_user": true, "ansible_password": true, "ansible_ssh_private_key_file": true, "ansible_connection": true, "ansible_become_password": true, } for key, val := range args { if reserved[key] { continue } if existing, ok := host.Vars[key]; !ok || !reflect.DeepEqual(existing, val) { changed = true } host.Vars[key] = val } if e.inventory.All.Hosts == nil { e.inventory.All.Hosts = make(map[string]*Host) } if existing, ok := e.inventory.All.Hosts[name]; !ok || existing != host { changed = true } e.inventory.All.Hosts[name] = host for _, groupName := range groups { if groupName == "" { continue } group := ensureInventoryGroup(e.inventory.All, groupName) if group.Hosts == nil { group.Hosts = make(map[string]*Host) } if existing, ok := group.Hosts[name]; !ok || existing != host { changed = true } group.Hosts[name] = host } msg := sprintf("host %s added", name) if len(groups) > 0 { msg += " to groups: " + join(", ", groups) } data := map[string]any{"host": name} if len(groups) > 0 { data["groups"] = groups } return &TaskResult{Changed: changed, Msg: msg, Data: data}, nil } func (e *Executor) moduleGroupBy(host string, args map[string]any) (*TaskResult, error) { key := getStringArg(args, "key", "") if key == "" { key = getStringArg(args, "_raw_params", "") } if key == "" { return nil, coreerr.E("Executor.moduleGroupBy", "key required", nil) } e.mu.Lock() defer e.mu.Unlock() if e.inventory == nil { e.inventory = &Inventory{} } if e.inventory.All == nil { e.inventory.All = &InventoryGroup{} } group := ensureInventoryGroup(e.inventory.All, key) if group.Hosts == nil { group.Hosts = make(map[string]*Host) } hostEntry := findInventoryHost(e.inventory.All, host) if hostEntry == nil { hostEntry = &Host{} if e.inventory.All.Hosts == nil { e.inventory.All.Hosts = make(map[string]*Host) } e.inventory.All.Hosts[host] = hostEntry } _, alreadyMember := group.Hosts[host] group.Hosts[host] = hostEntry msg := sprintf("host %s grouped by %s", host, key) return &TaskResult{ Changed: !alreadyMember, Msg: msg, Data: map[string]any{"host": host, "group": key}, }, nil } func (e *Executor) modulePause(ctx context.Context, args map[string]any) (*TaskResult, error) { prompt := getStringArg(args, "prompt", "") echo := getBoolArg(args, "echo", true) duration := time.Duration(0) if s, ok := args["seconds"].(int); ok { duration += time.Duration(s) * time.Second } if s, ok := args["seconds"].(string); ok { if seconds, err := strconv.Atoi(s); err == nil { duration += time.Duration(seconds) * time.Second } } if m, ok := args["minutes"].(int); ok { duration += time.Duration(m) * time.Minute } if s, ok := args["minutes"].(string); ok { if minutes, err := strconv.Atoi(s); err == nil { duration += time.Duration(minutes) * time.Minute } } if prompt != "" && os.Stdin != nil { if stat, err := os.Stdin.Stat(); err == nil && (stat.Mode()&os.ModeCharDevice) != 0 { if echo { _, _ = fmt.Fprintln(os.Stdout, prompt) } else { _, _ = fmt.Fprint(os.Stdout, prompt) } reader := bufio.NewReader(os.Stdin) select { case <-ctx.Done(): return nil, ctx.Err() default: _, _ = reader.ReadString('\n') } } } if duration > 0 { timer := time.NewTimer(duration) defer timer.Stop() select { case <-ctx.Done(): return nil, ctx.Err() case <-timer.C: } } result := &TaskResult{Changed: false} if prompt != "" { result.Msg = prompt } return result, nil } func normalizeStringList(value any) []string { switch v := value.(type) { case nil: return nil case string: if v == "" { return nil } parts := corexSplit(v, ",") out := make([]string, 0, len(parts)) for _, part := range parts { if trimmed := corexTrimSpace(part); trimmed != "" { out = append(out, trimmed) } } if len(out) == 0 && corexTrimSpace(v) != "" { return []string{corexTrimSpace(v)} } return out case []string: out := make([]string, 0, len(v)) for _, item := range v { if trimmed := corexTrimSpace(item); trimmed != "" { out = append(out, trimmed) } } return out case []any: out := make([]string, 0, len(v)) for _, item := range v { if s, ok := item.(string); ok { if trimmed := corexTrimSpace(s); trimmed != "" { out = append(out, trimmed) } } } return out default: s := corexTrimSpace(corexSprint(v)) if s == "" { return nil } return []string{s} } } // normalizeStringArgs collects one or more string values from a scalar or list // input without splitting comma-separated content. func normalizeStringArgs(value any) []string { switch v := value.(type) { case nil: return nil case string: if trimmed := corexTrimSpace(v); trimmed != "" { return []string{trimmed} } case []string: out := make([]string, 0, len(v)) for _, item := range v { if trimmed := corexTrimSpace(item); trimmed != "" { out = append(out, trimmed) } } return out case []any: out := make([]string, 0, len(v)) for _, item := range v { if s, ok := item.(string); ok { if trimmed := corexTrimSpace(s); trimmed != "" { out = append(out, trimmed) } continue } s := corexTrimSpace(corexSprint(item)) if s != "" && s != "" { out = append(out, s) } } return out default: s := corexTrimSpace(corexSprint(v)) if s != "" && s != "" { return []string{s} } } return nil } func ensureInventoryGroup(parent *InventoryGroup, name string) *InventoryGroup { if parent == nil { return nil } if parent.Children == nil { parent.Children = make(map[string]*InventoryGroup) } if group, ok := parent.Children[name]; ok && group != nil { return group } group := &InventoryGroup{} parent.Children[name] = group return group } func findInventoryHost(group *InventoryGroup, name string) *Host { if group == nil { return nil } if host, ok := group.Hosts[name]; ok { return host } for _, child := range group.Children { if host := findInventoryHost(child, name); host != nil { return host } } return nil } func (e *Executor) moduleWaitFor(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { port := getIntArg(args, "port", 0) path := getStringArg(args, "path", "") host := getStringArg(args, "host", "127.0.0.1") state := getStringArg(args, "state", "started") searchRegex := getStringArg(args, "search_regex", "") timeoutMsg := getStringArg(args, "msg", "wait_for timed out") delay := getIntArg(args, "delay", 0) timeout := getIntArg(args, "timeout", 300) var compiledRegex *regexp.Regexp if searchRegex != "" { var err error compiledRegex, err = regexp.Compile(searchRegex) if err != nil { return nil, coreerr.E("Executor.moduleWaitFor", "compile search_regex", err) } } if delay > 0 { timer := time.NewTimer(time.Duration(delay) * time.Second) select { case <-ctx.Done(): timer.Stop() return nil, ctx.Err() case <-timer.C: } } if path != "" { deadline := time.NewTimer(time.Duration(timeout) * time.Second) ticker := time.NewTicker(250 * time.Millisecond) defer deadline.Stop() defer ticker.Stop() for { exists, err := client.FileExists(ctx, path) if err != nil { return &TaskResult{Failed: true, Msg: err.Error()}, nil } satisfied := false switch state { case "absent": satisfied = !exists if exists && compiledRegex != nil { data, err := client.Download(ctx, path) if err == nil { satisfied = !compiledRegex.Match(data) } } default: satisfied = exists if satisfied && compiledRegex != nil { data, err := client.Download(ctx, path) if err != nil { satisfied = false } else { satisfied = compiledRegex.Match(data) } } } if satisfied { return &TaskResult{Changed: false}, nil } select { case <-ctx.Done(): return nil, ctx.Err() case <-deadline.C: return &TaskResult{Failed: true, Msg: timeoutMsg, RC: 1}, nil case <-ticker.C: } } } if port > 0 { switch state { case "started", "present": cmd := sprintf("timeout %d bash -c 'until nc -z %s %d; do sleep 1; done'", timeout, host, port) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: false}, nil case "stopped", "absent": cmd := sprintf("timeout %d bash -c 'until ! nc -z %s %d; do sleep 1; done'", timeout, host, port) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: false}, nil case "drained": cmd := sprintf("timeout %d bash -c 'until ! ss -Htan state established \"( sport = :%d or dport = :%d )\" | grep -q .; do sleep 1; done'", timeout, port, port) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: false}, nil } } return &TaskResult{Changed: false}, nil } func (e *Executor) moduleGit(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { repo := getStringArg(args, "repo", "") dest := getStringArg(args, "dest", "") version := getStringArg(args, "version", "HEAD") if repo == "" || dest == "" { return nil, coreerr.E("Executor.moduleGit", "repo and dest required", nil) } // Check if dest exists exists, _ := client.FileExists(ctx, dest+"/.git") var cmd string if exists { // Fetch and checkout (force to ensure clean state) cmd = sprintf("cd %q && git fetch --all && git checkout --force %q", dest, version) } else { cmd = sprintf("git clone %q %q && cd %q && git checkout %q", repo, dest, dest, version) } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } func (e *Executor) moduleUnarchive(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { src := getStringArg(args, "src", "") dest := getStringArg(args, "dest", "") remote := getBoolArg(args, "remote_src", false) if src == "" || dest == "" { return nil, coreerr.E("Executor.moduleUnarchive", "src and dest required", nil) } // Create dest directory (best-effort) _, _, _, _ = client.Run(ctx, sprintf("mkdir -p %q", dest)) var cmd string if !remote { // Upload local file first src = e.resolveLocalPath(src) data, err := coreio.Local.Read(src) if err != nil { return nil, coreerr.E("Executor.moduleUnarchive", "read src", err) } tmpPath := "/tmp/ansible_unarchive_" + pathBase(src) err = client.Upload(ctx, newReader(data), tmpPath, 0644) if err != nil { return nil, err } src = tmpPath defer func() { _, _, _, _ = client.Run(ctx, sprintf("rm -f %q", tmpPath)) }() } // Detect archive type and extract if hasSuffix(src, ".tar.gz") || hasSuffix(src, ".tgz") { cmd = sprintf("tar -xzf %q -C %q", src, dest) } else if hasSuffix(src, ".tar.xz") { cmd = sprintf("tar -xJf %q -C %q", src, dest) } else if hasSuffix(src, ".tar.bz2") { cmd = sprintf("tar -xjf %q -C %q", src, dest) } else if hasSuffix(src, ".tar") { cmd = sprintf("tar -xf %q -C %q", src, dest) } else if hasSuffix(src, ".zip") { cmd = sprintf("unzip -o %q -d %q", src, dest) } else { cmd = sprintf("tar -xf %q -C %q", src, dest) // Guess tar } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } func (e *Executor) moduleArchive(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { dest := getStringArg(args, "dest", "") format := lower(getStringArg(args, "format", "")) paths := archivePaths(args) if dest == "" || len(paths) == 0 { return nil, coreerr.E("Executor.moduleArchive", "path and dest required", nil) } // Create the parent directory first so archive creation does not fail. _, _, _, _ = client.Run(ctx, sprintf("mkdir -p %q", pathDir(dest))) var cmd string var deleteOnSuccess bool switch { case format == "zip" || hasSuffix(dest, ".zip"): cmd = sprintf("zip -r %q %s", dest, join(" ", quoteArgs(paths))) case format == "gz" || format == "tgz" || hasSuffix(dest, ".tar.gz") || hasSuffix(dest, ".tgz"): cmd = sprintf("tar -czf %q %s", dest, join(" ", quoteArgs(paths))) case format == "bz2" || hasSuffix(dest, ".tar.bz2"): cmd = sprintf("tar -cjf %q %s", dest, join(" ", quoteArgs(paths))) case format == "xz" || hasSuffix(dest, ".tar.xz"): cmd = sprintf("tar -cJf %q %s", dest, join(" ", quoteArgs(paths))) default: cmd = sprintf("tar -cf %q %s", dest, join(" ", quoteArgs(paths))) } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } deleteOnSuccess = getBoolArg(args, "remove", false) if deleteOnSuccess { _, _, _, _ = client.Run(ctx, sprintf("rm -rf %s", join(" ", quoteArgs(paths)))) } return &TaskResult{Changed: true}, nil } func archivePaths(args map[string]any) []string { raw, ok := args["path"] if !ok { raw, ok = args["paths"] } if !ok { return nil } switch v := raw.(type) { case string: if v == "" { return nil } return []string{v} case []string: out := make([]string, 0, len(v)) for _, item := range v { if item != "" { out = append(out, item) } } return out case []any: out := make([]string, 0, len(v)) for _, item := range v { if s, ok := item.(string); ok && s != "" { out = append(out, s) } } return out default: s := sprintf("%v", v) if s == "" || s == "" { return nil } return []string{s} } } func quoteArgs(values []string) []string { quoted := make([]string, 0, len(values)) for _, value := range values { quoted = append(quoted, sprintf("%q", value)) } return quoted } func prefixCommandStdin(cmd, stdin string, addNewline bool) string { if stdin == "" { return cmd } if addNewline { stdin += "\n" } return sprintf("printf %%s %s | %s", shellSingleQuote(stdin), cmd) } func shellSingleQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } // --- Helpers --- func getStringArg(args map[string]any, key, def string) string { if v, ok := args[key]; ok { if s, ok := v.(string); ok { return s } return sprintf("%v", v) } return def } func getBoolArg(args map[string]any, key string, def bool) bool { if v, ok := args[key]; ok { switch b := v.(type) { case bool: return b case string: lowered := lower(b) return lowered == "true" || lowered == "yes" || lowered == "1" } } return def } func getIntArg(args map[string]any, key string, def int) int { if v, ok := args[key]; ok { switch n := v.(type) { case int: return n case int8: return int(n) case int16: return int(n) case int32: return int(n) case int64: return int(n) case uint: return int(n) case uint8: return int(n) case uint16: return int(n) case uint32: return int(n) case uint64: return int(n) case string: if parsed, err := strconv.Atoi(n); err == nil { return parsed } } } return def } func normalizeStatusCodes(value any, def int) []int { switch v := value.(type) { case nil: return []int{def} case int: return []int{v} case int8: return []int{int(v)} case int16: return []int{int(v)} case int32: return []int{int(v)} case int64: return []int{int(v)} case uint: return []int{int(v)} case uint8: return []int{int(v)} case uint16: return []int{int(v)} case uint32: return []int{int(v)} case uint64: return []int{int(v)} case string: if parsed, err := strconv.Atoi(v); err == nil { return []int{parsed} } case []int: return v case []any: out := make([]int, 0, len(v)) for _, item := range v { out = append(out, normalizeStatusCodes(item, def)...) } if len(out) > 0 { return out } case []string: out := make([]int, 0, len(v)) for _, item := range v { if parsed, err := strconv.Atoi(item); err == nil { out = append(out, parsed) } } if len(out) > 0 { return out } } return []int{def} } func containsInt(values []int, target int) bool { for _, value := range values { if value == target { return true } } return false } // --- Additional Modules --- func (e *Executor) moduleHostname(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { name := getStringArg(args, "name", "") if name == "" { return nil, coreerr.E("Executor.moduleHostname", "name required", nil) } currentStdout, _, currentRC, currentErr := client.Run(ctx, "hostname") if currentErr == nil && currentRC == 0 && corexTrimSpace(currentStdout) == name { return &TaskResult{Changed: false, Msg: "hostname already set"}, nil } // Set hostname cmd := sprintf("hostnamectl set-hostname %q || hostname %q", name, name) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } // Update /etc/hosts if needed (best-effort) _, _, _, _ = client.Run(ctx, sprintf("sed -i 's/127.0.1.1.*/127.0.1.1\t%s/' /etc/hosts", name)) return &TaskResult{Changed: true}, nil } func (e *Executor) moduleSysctl(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { name := getStringArg(args, "name", "") value := getStringArg(args, "value", "") state := getStringArg(args, "state", "present") reload := getBoolArg(args, "reload", false) sysctlFile := getStringArg(args, "sysctl_file", "/etc/sysctl.conf") escapedName := regexp.QuoteMeta(name) if name == "" { return nil, coreerr.E("Executor.moduleSysctl", "name required", nil) } if state == "absent" { // Remove from the configured sysctl file. cmd := sprintf("sed -i '/%s/d' %q", escapedName, sysctlFile) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } if reload { stdout, stderr, rc, err = client.Run(ctx, "sysctl -p") if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } } return &TaskResult{Changed: true}, nil } // Set value cmd := sprintf("sysctl -w %s=%s", name, value) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } // Persist if requested (best-effort) if getBoolArg(args, "sysctl_set", true) { cmd = sprintf("grep -q '^%s' %q && sed -i 's/^%s.*/%s=%s/' %q || echo '%s=%s' >> %q", escapedName, sysctlFile, escapedName, name, value, sysctlFile, name, value, sysctlFile) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } } if reload { stdout, stderr, rc, err := client.Run(ctx, "sysctl -p") if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } } return &TaskResult{Changed: true}, nil } func (e *Executor) moduleCron(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { name := getStringArg(args, "name", "") job := getStringArg(args, "job", "") state := getStringArg(args, "state", "present") user := getStringArg(args, "user", "root") disabled := getBoolArg(args, "disabled", false) minute := getStringArg(args, "minute", "*") hour := getStringArg(args, "hour", "*") day := getStringArg(args, "day", "*") month := getStringArg(args, "month", "*") weekday := getStringArg(args, "weekday", "*") if state == "absent" { if name != "" { // Remove by name (comment marker) cmd := sprintf("crontab -u %s -l 2>/dev/null | grep -v '# %s' | grep -v '%s' | crontab -u %s -", user, name, job, user) _, _, _, _ = client.Run(ctx, cmd) } return &TaskResult{Changed: true}, nil } // Build cron entry schedule := sprintf("%s %s %s %s %s", minute, hour, day, month, weekday) entry := sprintf("%s %s # %s", schedule, job, name) if disabled { entry = "# " + entry } // Add to crontab cmd := sprintf("(crontab -u %s -l 2>/dev/null | grep -v '# %s' ; echo %q) | crontab -u %s -", user, name, entry, user) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } func (e *Executor) moduleBlockinfile(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { path := getStringArg(args, "path", "") if path == "" { path = getStringArg(args, "dest", "") } if path == "" { return nil, coreerr.E("Executor.moduleBlockinfile", "path required", nil) } before, _ := remoteFileText(ctx, client, path) block := getStringArg(args, "block", "") marker := getStringArg(args, "marker", "# {mark} ANSIBLE MANAGED BLOCK") state := getStringArg(args, "state", "present") create := getBoolArg(args, "create", false) backup := getBoolArg(args, "backup", false) prependNewline := getBoolArg(args, "prepend_newline", false) appendNewline := getBoolArg(args, "append_newline", false) beginMarker := replaceN(marker, "{mark}", "BEGIN", 1) endMarker := replaceN(marker, "{mark}", "END", 1) var backupPath string if backup { var hasBefore bool backupPath, hasBefore, _ = backupRemoteFile(ctx, client, path) if !hasBefore { backupPath = "" } } if state == "absent" { // Remove block cmd := sprintf("sed -i '/%s/,/%s/d' %q", replaceAll(beginMarker, "/", "\\/"), replaceAll(endMarker, "/", "\\/"), path) _, _, _, _ = client.Run(ctx, cmd) return &TaskResult{Changed: true}, nil } // Create file if needed (best-effort) if create { _, _, _, _ = client.Run(ctx, sprintf("touch %q", path)) } // Remove existing block and add new one escapedBlock := replaceAll(block, "'", "'\\''") blockContent := beginMarker + "\n" + escapedBlock + "\n" + endMarker if prependNewline { blockContent = "\n" + blockContent } if appendNewline { blockContent += "\n" } cmd := sprintf(` sed -i '/%s/,/%s/d' %q 2>/dev/null || true cat >> %q << 'BLOCK_EOF' %s BLOCK_EOF `, replaceAll(beginMarker, "/", "\\/"), replaceAll(endMarker, "/", "\\/"), path, path, blockContent) stdout, stderr, rc, err := client.RunScript(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } result := &TaskResult{Changed: true} if backupPath != "" { result.Data = map[string]any{"backup_file": backupPath} } if e.Diff { if after, ok := remoteFileText(ctx, client, path); ok && before != after { if result.Data == nil { result.Data = make(map[string]any) } result.Data["diff"] = fileDiffData(path, before, after) } } return result, nil } func (e *Executor) moduleIncludeVars(args map[string]any) (*TaskResult, error) { file := getStringArg(args, "file", "") if file == "" { file = getStringArg(args, "_raw_params", "") } dir := getStringArg(args, "dir", "") name := getStringArg(args, "name", "") filesMatching := getStringArg(args, "files_matching", "") ignoreFiles := normalizeStringList(args["ignore_files"]) extensions := normalizeIncludeVarsExtensions(normalizeStringList(args["extensions"])) hashBehaviour := lower(getStringArg(args, "hash_behaviour", "replace")) depth := getIntArg(args, "depth", 0) if file == "" && dir == "" { return &TaskResult{Changed: false}, nil } loaded := make(map[string]any) var sources []string loadFile := func(path string) error { data, err := coreio.Local.Read(path) if err != nil { return coreerr.E("Executor.moduleIncludeVars", "read vars file", err) } var vars map[string]any if err := yaml.Unmarshal([]byte(data), &vars); err != nil { return coreerr.E("Executor.moduleIncludeVars", "parse vars file", err) } mergeVars(loaded, vars, hashBehaviour == "merge") return nil } if file != "" { file = e.resolveLocalPath(file) sources = append(sources, file) if err := loadFile(file); err != nil { return nil, err } } if dir != "" { dir = e.resolveLocalPath(dir) files, err := collectIncludeVarsFiles(dir, depth, filesMatching, extensions, ignoreFiles) if err != nil { return nil, err } for _, path := range files { sources = append(sources, path) if err := loadFile(path); err != nil { return nil, err } } } if name != "" { e.vars[name] = loaded } else { mergeVars(e.vars, loaded, hashBehaviour == "merge") } msg := "include_vars" if len(sources) > 0 { msg += ": " + join(", ", sources) } result := &TaskResult{Changed: true, Msg: msg} if len(sources) > 0 { result.Data = map[string]any{ "ansible_included_var_files": append([]string(nil), sources...), } } return result, nil } func normalizeIncludeVarsExtensions(values []string) []string { if len(values) == 0 { return []string{".json", ".yml", ".yaml"} } extensions := make([]string, 0, len(values)) seen := make(map[string]bool, len(values)) for _, value := range values { ext := lower(corexTrimSpace(value)) if ext == "" { continue } if !corexHasPrefix(ext, ".") { ext = "." + ext } if seen[ext] { continue } seen[ext] = true extensions = append(extensions, ext) } return extensions } func collectIncludeVarsFiles(dir string, depth int, filesMatching string, extensions []string, ignoreFiles []string) ([]string, error) { info, err := os.Stat(dir) if err != nil { return nil, coreerr.E("Executor.moduleIncludeVars", "read vars dir", err) } if !info.IsDir() { return nil, coreerr.E("Executor.moduleIncludeVars", "read vars dir: not a directory", nil) } type dirEntry struct { path string depth int } var matcher *regexp.Regexp if filesMatching != "" { matcher, err = regexp.Compile(filesMatching) if err != nil { return nil, coreerr.E("Executor.moduleIncludeVars", "compile files_matching", err) } } var files []string allowed := make(map[string]bool, len(extensions)) for _, ext := range extensions { allowed[ext] = true } ignored := make(map[string]bool, len(ignoreFiles)) for _, name := range ignoreFiles { if name = corexTrimSpace(name); name != "" { ignored[name] = true } } stack := []dirEntry{{path: dir, depth: 0}} for len(stack) > 0 { current := stack[len(stack)-1] stack = stack[:len(stack)-1] entries, err := os.ReadDir(current.path) if err != nil { return nil, coreerr.E("Executor.moduleIncludeVars", "read vars dir", err) } for i := len(entries) - 1; i >= 0; i-- { entry := entries[i] fullPath := joinPath(current.path, entry.Name()) if entry.IsDir() { if depth == 0 || current.depth < depth { stack = append(stack, dirEntry{path: fullPath, depth: current.depth + 1}) } continue } if ignored[entry.Name()] { continue } ext := lower(filepath.Ext(entry.Name())) if !allowed[ext] { continue } if matcher != nil && !matcher.MatchString(entry.Name()) { continue } files = append(files, fullPath) } } sort.Strings(files) return files, nil } func mergeVars(dst, src map[string]any, mergeMaps bool) { if dst == nil || src == nil { return } for key, val := range src { if !mergeMaps { dst[key] = val continue } if existing, ok := dst[key].(map[string]any); ok { if next, ok := val.(map[string]any); ok { mergeVars(existing, next, true) continue } } dst[key] = val } } func (e *Executor) moduleMeta(args map[string]any) (*TaskResult, error) { // meta module controls play execution // Most actions are no-ops for us, but we preserve the requested action so // the executor can apply side effects such as handler flushing. action := getStringArg(args, "_raw_params", "") if action == "" { action = getStringArg(args, "free_form", "") } if action == "" { action = getStringArg(args, "action", "") } result := &TaskResult{Changed: action == "clear_facts"} if action != "" { result.Data = map[string]any{"action": action} } return result, nil } func (e *Executor) moduleSetup(ctx context.Context, host string, client sshFactsRunner, args map[string]any) (*TaskResult, error) { gatherTimeout := getIntArg(args, "gather_timeout", 0) if gatherTimeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(gatherTimeout)*time.Second) defer cancel() } facts, err := e.collectFacts(ctx, client) if err != nil { return &TaskResult{Failed: true, Msg: err.Error()}, nil } factMap := factsToMap(facts) factMap = applyGatherSubsetFilter(factMap, normalizeStringList(args["gather_subset"])) filteredFactMap := filterFactsMap(factMap, normalizeStringList(args["filter"])) filteredFacts := factsFromMap(filteredFactMap) e.mu.Lock() e.facts[host] = filteredFacts e.mu.Unlock() return &TaskResult{ Changed: false, Msg: "facts gathered", Data: map[string]any{"ansible_facts": filteredFactMap}, }, nil } func applyGatherSubsetFilter(facts map[string]any, subsets []string) map[string]any { if len(facts) == 0 || len(subsets) == 0 { return facts } normalized := make([]string, 0, len(subsets)) for _, subset := range subsets { if trimmed := lower(corexTrimSpace(subset)); trimmed != "" { normalized = append(normalized, trimmed) } } if len(normalized) == 0 { return facts } includeAll := false excludeAll := false excludeMin := false positives := make([]string, 0, len(normalized)) exclusions := make([]string, 0, len(normalized)) for _, subset := range normalized { if corexHasPrefix(subset, "!") { name := corexTrimPrefix(subset, "!") if name != "" { exclusions = append(exclusions, name) } switch name { case "all": excludeAll = true case "min": excludeMin = true } continue } positives = append(positives, subset) switch subset { case "all": includeAll = true case "min": // handled below } } if includeAll && !excludeAll { return facts } selected := make(map[string]bool) if len(positives) == 0 { if !excludeAll { for key := range facts { selected[key] = true } } else if !excludeMin { addSubsetKeys(selected, "min") } } else { if !excludeMin { addSubsetKeys(selected, "min") } } for _, subset := range positives { addSubsetKeys(selected, subset) } for _, subset := range exclusions { removeSubsetKeys(selected, subset) } if len(selected) == 0 { return map[string]any{} } filtered := make(map[string]any) for key, value := range facts { if selected[key] { filtered[key] = value } } return filtered } func addSubsetKeys(selected map[string]bool, subset string) { for _, key := range gatherSubsetKeys(subset) { selected[key] = true } } func removeSubsetKeys(selected map[string]bool, subset string) { if subset == "all" { return } for _, key := range gatherSubsetKeys(subset) { delete(selected, key) } delete(selected, subset) } func gatherSubsetKeys(subset string) []string { switch subset { case "all": return []string{ "ansible_hostname", "ansible_fqdn", "ansible_os_family", "ansible_distribution", "ansible_distribution_version", "ansible_architecture", "ansible_kernel", "ansible_memtotal_mb", "ansible_processor_vcpus", "ansible_default_ipv4_address", } case "min": return []string{ "ansible_hostname", "ansible_fqdn", "ansible_os_family", "ansible_distribution", "ansible_distribution_version", "ansible_architecture", "ansible_kernel", } case "hardware": return []string{ "ansible_architecture", "ansible_kernel", "ansible_memtotal_mb", "ansible_processor_vcpus", } case "network": return []string{ "ansible_default_ipv4_address", } case "distribution": return []string{ "ansible_os_family", "ansible_distribution", "ansible_distribution_version", } case "virtual": return nil default: return nil } } func (e *Executor) collectFacts(ctx context.Context, client sshFactsRunner) (*Facts, error) { facts := &Facts{} read := func(cmd string) (string, error) { stdout, _, _, err := client.Run(ctx, cmd) if err != nil { if ctx.Err() != nil { return "", ctx.Err() } return "", nil } return stdout, nil } stdout, err := read("hostname -f 2>/dev/null || hostname") if err != nil { return nil, err } if stdout != "" { facts.FQDN = corexTrimSpace(stdout) } stdout, err = read("hostname -s 2>/dev/null || hostname") if err != nil { return nil, err } if stdout != "" { facts.Hostname = corexTrimSpace(stdout) } stdout, err = read("cat /etc/os-release 2>/dev/null | grep -E '^(ID|VERSION_ID|NAME)=' | head -3") if err != nil { return nil, err } if stdout != "" { for _, line := range split(stdout, "\n") { switch { case corexHasPrefix(line, "ID="): id := trimCutset(corexTrimPrefix(line, "ID="), "\"'") if facts.Distribution == "" { facts.Distribution = id } if facts.OS == "" { facts.OS = osFamilyFromReleaseID(id) } case corexHasPrefix(line, "NAME="): name := trimCutset(corexTrimPrefix(line, "NAME="), "\"'") if facts.OS == "" { facts.OS = osFamilyFromReleaseID(name) } case corexHasPrefix(line, "VERSION_ID="): facts.Version = trimCutset(corexTrimPrefix(line, "VERSION_ID="), "\"'") } } } stdout, err = read("uname -m") if err != nil { return nil, err } if stdout != "" { facts.Architecture = corexTrimSpace(stdout) } stdout, err = read("uname -r") if err != nil { return nil, err } if stdout != "" { facts.Kernel = corexTrimSpace(stdout) } stdout, err = read("nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null") if err != nil { return nil, err } if stdout != "" { if n, parseErr := strconv.Atoi(corexTrimSpace(stdout)); parseErr == nil { facts.CPUs = n } } stdout, err = read("free -m 2>/dev/null | awk '/^Mem:/ {print $2}'") if err != nil { return nil, err } if stdout != "" { if n, parseErr := strconv.ParseInt(corexTrimSpace(stdout), 10, 64); parseErr == nil { facts.Memory = n } } stdout, err = read("hostname -I 2>/dev/null | awk '{print $1}'") if err != nil { return nil, err } if stdout != "" { facts.IPv4 = corexTrimSpace(stdout) } return facts, nil } func factsToMap(facts *Facts) map[string]any { if facts == nil { return nil } return map[string]any{ "ansible_hostname": facts.Hostname, "ansible_fqdn": facts.FQDN, "ansible_os_family": facts.OS, "ansible_distribution": facts.Distribution, "ansible_distribution_version": facts.Version, "ansible_architecture": facts.Architecture, "ansible_kernel": facts.Kernel, "ansible_memtotal_mb": facts.Memory, "ansible_processor_vcpus": facts.CPUs, "ansible_default_ipv4_address": facts.IPv4, } } func filterFactsMap(facts map[string]any, patterns []string) map[string]any { if len(facts) == 0 || len(patterns) == 0 { return facts } filtered := make(map[string]any) for key, value := range facts { for _, pattern := range patterns { matched, err := path.Match(pattern, key) if err != nil { matched = pattern == key } if matched { filtered[key] = value break } } } return filtered } func factsFromMap(values map[string]any) *Facts { if len(values) == 0 { return &Facts{} } facts := &Facts{} if v, ok := values["ansible_hostname"].(string); ok { facts.Hostname = v } if v, ok := values["ansible_fqdn"].(string); ok { facts.FQDN = v } if v, ok := values["ansible_os_family"].(string); ok { facts.OS = v } if v, ok := values["ansible_distribution"].(string); ok { facts.Distribution = v } if v, ok := values["ansible_distribution_version"].(string); ok { facts.Version = v } if v, ok := values["ansible_architecture"].(string); ok { facts.Architecture = v } if v, ok := values["ansible_kernel"].(string); ok { facts.Kernel = v } if v, ok := values["ansible_memtotal_mb"].(int64); ok { facts.Memory = v } if v, ok := values["ansible_memtotal_mb"].(int); ok { facts.Memory = int64(v) } if v, ok := values["ansible_processor_vcpus"].(int); ok { facts.CPUs = v } if v, ok := values["ansible_processor_vcpus"].(int64); ok { facts.CPUs = int(v) } if v, ok := values["ansible_default_ipv4_address"].(string); ok { facts.IPv4 = v } return facts } func osFamilyFromReleaseID(id string) string { switch lower(corexTrimSpace(id)) { case "debian", "ubuntu": return "Debian" case "rhel", "redhat", "centos", "fedora", "rocky", "almalinux", "oracle": return "RedHat" case "arch", "manjaro": return "Archlinux" case "alpine": return "Alpine" default: return "" } } func (e *Executor) moduleReboot(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { preRebootDelay := getIntArg(args, "pre_reboot_delay", 0) postRebootDelay := getIntArg(args, "post_reboot_delay", 0) rebootTimeout := getIntArg(args, "reboot_timeout", 600) testCommand := getStringArg(args, "test_command", "whoami") msg := getStringArg(args, "msg", "Reboot initiated by Ansible") runReboot := func(cmd string) (*TaskResult, error) { stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { msg := stderr if msg == "" && err != nil { msg = err.Error() } return &TaskResult{Failed: true, Msg: msg, Stdout: stdout, Stderr: stderr, RC: rc}, nil } return nil, nil } if preRebootDelay > 0 { cmd := sprintf("sleep %d && shutdown -r now '%s' &", preRebootDelay, msg) if result, err := runReboot(cmd); err != nil || result != nil { return result, err } } else { if result, err := runReboot(sprintf("shutdown -r now '%s' &", msg)); err != nil || result != nil { return result, err } } if postRebootDelay > 0 { stdout, stderr, rc, err := client.Run(ctx, sprintf("sleep %d", postRebootDelay)) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } } if testCommand == "" { return &TaskResult{Changed: true, Msg: "Reboot initiated"}, nil } deadline := time.NewTimer(time.Duration(rebootTimeout) * time.Second) ticker := time.NewTicker(1 * time.Second) defer deadline.Stop() defer ticker.Stop() var lastStdout, lastStderr string var lastRC int for { stdout, stderr, rc, err := client.Run(ctx, testCommand) lastStdout = stdout lastStderr = stderr lastRC = rc if err == nil && rc == 0 { break } select { case <-ctx.Done(): return nil, ctx.Err() case <-deadline.C: return &TaskResult{ Failed: true, Msg: "reboot timed out waiting for host to become ready", Stdout: lastStdout, Stderr: lastStderr, RC: lastRC, }, nil case <-ticker.C: } } return &TaskResult{Changed: true, Msg: "Reboot initiated"}, nil } func (e *Executor) moduleUFW(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { rule := getStringArg(args, "rule", "") port := getStringArg(args, "port", "") proto := getStringArg(args, "proto", "tcp") state := getStringArg(args, "state", "") logging := getStringArg(args, "logging", "") deleteRule := getBoolArg(args, "delete", false) var cmd string // Handle logging configuration. if logging != "" { cmd = sprintf("ufw logging %s", logging) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } // Handle state (enable/disable) if state != "" { switch state { case "enabled": cmd = "ufw --force enable" case "disabled": cmd = "ufw disable" case "reloaded": cmd = "ufw reload" case "reset": cmd = "ufw --force reset" } if cmd != "" { stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } return &TaskResult{Changed: true}, nil } } // Handle rule if rule != "" && port != "" { switch rule { case "allow": cmd = sprintf("ufw allow %s/%s", port, proto) case "deny": cmd = sprintf("ufw deny %s/%s", port, proto) case "reject": cmd = sprintf("ufw reject %s/%s", port, proto) case "limit": cmd = sprintf("ufw limit %s/%s", port, proto) } if deleteRule && cmd != "" { cmd = "ufw delete " + corexTrimPrefix(cmd, "ufw ") } stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } } return &TaskResult{Changed: true}, nil } func (e *Executor) moduleAuthorizedKey(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { user := getStringArg(args, "user", "") key := getStringArg(args, "key", "") state := getStringArg(args, "state", "present") exclusive := getBoolArg(args, "exclusive", false) manageDir := getBoolArg(args, "manage_dir", true) pathArg := getStringArg(args, "path", "") if user == "" || key == "" { return nil, coreerr.E("Executor.moduleAuthorizedKey", "user and key required", nil) } // Get user's home directory stdout, _, _, err := client.Run(ctx, sprintf("getent passwd %s | cut -d: -f6", user)) if err != nil { return nil, coreerr.E("Executor.moduleAuthorizedKey", "get home dir", err) } home := corexTrimSpace(stdout) if home == "" { home = "/root" if user != "root" { home = "/home/" + user } } authKeysPath := pathArg if authKeysPath == "" { authKeysPath = joinPath(home, ".ssh", "authorized_keys") } else if corexHasPrefix(authKeysPath, "~/") { authKeysPath = joinPath(home, corexTrimPrefix(authKeysPath, "~/")) } else if authKeysPath == "~" { authKeysPath = home } if authKeysPath == "" { authKeysPath = joinPath(home, ".ssh", "authorized_keys") } if state == "absent" { if content, ok := remoteFileText(ctx, client, authKeysPath); !ok || !fileContainsExactLine(content, key) { return &TaskResult{Changed: false}, nil } // Remove the exact key line when present. cmd := sprintf("if [ -f %q ]; then sed -i '\\|^%s$|d' %q; fi", authKeysPath, sedExactLinePattern(key), authKeysPath) _, _, _, _ = client.Run(ctx, cmd) return &TaskResult{Changed: true}, nil } if content, ok := remoteFileText(ctx, client, authKeysPath); ok && fileContainsExactLine(content, key) { return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", authKeysPath)}, nil } if manageDir { // Ensure the parent directory exists (best-effort). _, _, _, _ = client.Run(ctx, sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q", pathDir(authKeysPath), pathDir(authKeysPath), user, user, pathDir(authKeysPath))) } if exclusive { cmd := sprintf("printf '%%s\\n' %q > %q", key, authKeysPath) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } _, _, _, _ = client.Run(ctx, sprintf("chmod 600 %q && chown %s:%s %q", authKeysPath, user, user, authKeysPath)) return &TaskResult{Changed: true}, nil } // Add the key if it is not already present. cmd := sprintf("grep -qF %q %q 2>/dev/null || echo %q >> %q", key, authKeysPath, key, authKeysPath) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } // Fix permissions (best-effort) _, _, _, _ = client.Run(ctx, sprintf("chmod 600 %q && chown %s:%s %q", authKeysPath, user, user, authKeysPath)) return &TaskResult{Changed: true}, nil } func sedExactLinePattern(value string) string { pattern := regexp.QuoteMeta(value) return replaceAll(pattern, "|", "\\|") } func (e *Executor) moduleDockerCompose(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) { projectSrc := getStringArg(args, "project_src", "") state := getStringArg(args, "state", "present") projectName := getStringArg(args, "project_name", "") files := normalizeStringArgs(args["files"]) if projectSrc == "" { return nil, coreerr.E("Executor.moduleDockerCompose", "project_src required", nil) } var cmdParts []string cmdParts = append(cmdParts, "cd", shellQuote(projectSrc), "&&", "docker", "compose") if projectName != "" { cmdParts = append(cmdParts, "-p", shellQuote(projectName)) } for _, file := range files { cmdParts = append(cmdParts, "-f", shellQuote(file)) } switch state { case "present": cmdParts = append(cmdParts, "up", "-d") case "absent": cmdParts = append(cmdParts, "down") case "stopped": cmdParts = append(cmdParts, "stop") case "restarted": cmdParts = append(cmdParts, "restart") default: cmdParts = append(cmdParts, "up", "-d") } cmd := join(" ", cmdParts) stdout, stderr, rc, err := client.Run(ctx, cmd) if err != nil || rc != 0 { return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil } // Heuristic for changed changed := true if contains(stdout, "Up to date") || contains(stderr, "Up to date") { changed = false } return &TaskResult{Changed: changed, Stdout: stdout}, nil }