2026-03-09 11:37:27 +00:00
|
|
|
package ansible
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-01 23:09:00 +00:00
|
|
|
"bufio"
|
2026-04-02 00:24:23 +00:00
|
|
|
"bytes"
|
2026-03-09 11:37:27 +00:00
|
|
|
"context"
|
2026-04-03 15:01:00 +00:00
|
|
|
"crypto/sha1"
|
|
|
|
|
"crypto/sha256"
|
2026-04-03 15:04:30 +00:00
|
|
|
"crypto/sha512"
|
2026-03-09 11:37:27 +00:00
|
|
|
"encoding/base64"
|
2026-04-03 15:01:00 +00:00
|
|
|
"encoding/hex"
|
2026-04-01 21:32:36 +00:00
|
|
|
"encoding/json"
|
2026-04-01 23:09:00 +00:00
|
|
|
"fmt"
|
2026-03-26 16:39:59 +00:00
|
|
|
"io/fs"
|
2026-04-02 02:17:38 +00:00
|
|
|
"net/url"
|
2026-04-01 19:05:24 +00:00
|
|
|
"os"
|
2026-04-01 20:27:53 +00:00
|
|
|
"path"
|
2026-04-01 19:05:24 +00:00
|
|
|
"path/filepath"
|
2026-04-03 10:43:56 +00:00
|
|
|
"reflect"
|
2026-04-01 21:53:41 +00:00
|
|
|
"regexp"
|
2026-04-01 19:05:24 +00:00
|
|
|
"sort"
|
2026-03-09 11:37:27 +00:00
|
|
|
"strconv"
|
2026-04-02 00:47:30 +00:00
|
|
|
"strings"
|
2026-04-01 19:45:37 +00:00
|
|
|
"time"
|
2026-03-16 19:50:03 +00:00
|
|
|
|
2026-03-22 01:50:56 +00:00
|
|
|
coreio "dappco.re/go/core/io"
|
|
|
|
|
coreerr "dappco.re/go/core/log"
|
2026-04-01 19:05:24 +00:00
|
|
|
"gopkg.in/yaml.v3"
|
2026-03-09 11:37:27 +00:00
|
|
|
)
|
|
|
|
|
|
2026-04-01 19:12:44 +00:00
|
|
|
type sshFactsRunner interface {
|
|
|
|
|
Run(ctx context.Context, cmd string) (string, string, int, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 01:39:10 +00:00
|
|
|
type commandRunner interface {
|
|
|
|
|
Run(ctx context.Context, cmd string) (string, string, int, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// executeModule dispatches to the appropriate module handler.
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) executeModule(ctx context.Context, host string, client sshExecutorClient, task *Task, play *Play) (*TaskResult, error) {
|
2026-04-02 14:22:10 +00:00
|
|
|
originalModule := task.Module
|
|
|
|
|
module := NormalizeModule(originalModule)
|
2026-04-03 12:54:45 +00:00
|
|
|
executionHost := e.resolveDelegateHost(host, task)
|
|
|
|
|
factsHost := host
|
|
|
|
|
if task.DelegateFacts && task.Delegate != "" {
|
|
|
|
|
factsHost = executionHost
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-01 23:55:05 +00:00
|
|
|
// Apply task-level become overrides, including an explicit disable.
|
|
|
|
|
if task.Become != nil {
|
|
|
|
|
// Save old state to restore after the task finishes.
|
2026-04-01 20:00:45 +00:00
|
|
|
oldBecome, oldUser, oldPass := client.BecomeState()
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-01 23:55:05 +00:00
|
|
|
if *task.Become {
|
2026-04-02 14:46:35 +00:00
|
|
|
becomePass := oldPass
|
|
|
|
|
if becomePass == "" {
|
|
|
|
|
becomePass = e.resolveBecomePassword(host)
|
|
|
|
|
}
|
|
|
|
|
client.SetBecome(true, task.BecomeUser, becomePass)
|
2026-04-01 23:55:05 +00:00
|
|
|
} else {
|
|
|
|
|
client.SetBecome(false, "", "")
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
defer client.SetBecome(oldBecome, oldUser, oldPass)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:51:57 +00:00
|
|
|
if prefix := e.buildEnvironmentPrefix(host, task, play); prefix != "" {
|
|
|
|
|
client = &environmentSSHClient{
|
|
|
|
|
sshExecutorClient: client,
|
|
|
|
|
prefix: prefix,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 13:08:52 +00:00
|
|
|
// Merge play-level module defaults before templating so defaults and task
|
|
|
|
|
// arguments can both resolve host-scoped variables.
|
|
|
|
|
args := mergeModuleDefaults(task.Args, e.resolveModuleDefaults(play, module))
|
|
|
|
|
args = e.templateArgs(args, host, task)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
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)
|
2026-04-03 13:31:06 +00:00
|
|
|
case "ansible.builtin.replace":
|
|
|
|
|
return e.moduleReplace(ctx, client, args)
|
2026-03-09 11:37:27 +00:00
|
|
|
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)
|
2026-04-01 19:48:36 +00:00
|
|
|
case "ansible.builtin.yum":
|
|
|
|
|
return e.moduleYum(ctx, client, args)
|
|
|
|
|
case "ansible.builtin.dnf":
|
|
|
|
|
return e.moduleDnf(ctx, client, args)
|
2026-04-02 00:43:32 +00:00
|
|
|
case "ansible.builtin.rpm":
|
|
|
|
|
return e.moduleRPM(ctx, client, args, "rpm")
|
2026-03-09 11:37:27 +00:00
|
|
|
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":
|
2026-04-02 13:53:06 +00:00
|
|
|
return e.moduleDebug(host, task, args)
|
2026-03-09 11:37:27 +00:00
|
|
|
case "ansible.builtin.fail":
|
|
|
|
|
return e.moduleFail(args)
|
|
|
|
|
case "ansible.builtin.assert":
|
|
|
|
|
return e.moduleAssert(args, host)
|
2026-04-02 02:29:36 +00:00
|
|
|
case "ansible.builtin.ping":
|
|
|
|
|
return e.modulePing(ctx, client, args)
|
2026-03-09 11:37:27 +00:00
|
|
|
case "ansible.builtin.set_fact":
|
2026-04-03 12:54:45 +00:00
|
|
|
return e.moduleSetFact(factsHost, args)
|
2026-04-01 19:09:11 +00:00
|
|
|
case "ansible.builtin.add_host":
|
|
|
|
|
return e.moduleAddHost(args)
|
2026-04-01 19:21:57 +00:00
|
|
|
case "ansible.builtin.group_by":
|
|
|
|
|
return e.moduleGroupBy(host, args)
|
2026-03-09 11:37:27 +00:00
|
|
|
case "ansible.builtin.pause":
|
|
|
|
|
return e.modulePause(ctx, args)
|
|
|
|
|
case "ansible.builtin.wait_for":
|
|
|
|
|
return e.moduleWaitFor(ctx, client, args)
|
2026-04-03 14:40:29 +00:00
|
|
|
case "ansible.builtin.wait_for_connection":
|
|
|
|
|
return e.moduleWaitForConnection(ctx, client, args)
|
2026-03-09 11:37:27 +00:00
|
|
|
case "ansible.builtin.git":
|
|
|
|
|
return e.moduleGit(ctx, client, args)
|
|
|
|
|
case "ansible.builtin.unarchive":
|
|
|
|
|
return e.moduleUnarchive(ctx, client, args)
|
2026-04-01 19:37:33 +00:00
|
|
|
case "ansible.builtin.archive":
|
|
|
|
|
return e.moduleArchive(ctx, client, args)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
// 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":
|
2026-04-03 12:54:45 +00:00
|
|
|
return e.moduleSetup(ctx, factsHost, client, args)
|
2026-03-09 11:37:27 +00:00
|
|
|
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)
|
2026-04-01 20:19:38 +00:00
|
|
|
case "community.docker.docker_compose_v2":
|
|
|
|
|
return e.moduleDockerCompose(ctx, client, args)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
// For unknown modules, try to execute as shell if it looks like a command
|
2026-03-26 14:47:37 +00:00
|
|
|
if contains(task.Module, " ") || task.Module == "" {
|
2026-03-09 11:37:27 +00:00
|
|
|
return e.moduleShell(ctx, client, args)
|
|
|
|
|
}
|
2026-04-02 14:22:10 +00:00
|
|
|
if originalModule != "" && originalModule != module {
|
|
|
|
|
return nil, coreerr.E("Executor.executeModule", "unsupported module: "+originalModule+" (resolved to "+module+")", nil)
|
|
|
|
|
}
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.executeModule", "unsupported module: "+module, nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 13:08:52 +00:00
|
|
|
func (e *Executor) resolveModuleDefaults(play *Play, module string) map[string]any {
|
|
|
|
|
if play == nil || len(play.ModuleDefaults) == 0 || module == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
canonical := NormalizeModule(module)
|
|
|
|
|
|
|
|
|
|
merged := make(map[string]any)
|
|
|
|
|
seen := false
|
|
|
|
|
keys := make([]string, 0, len(play.ModuleDefaults))
|
|
|
|
|
for key := range play.ModuleDefaults {
|
|
|
|
|
keys = append(keys, key)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(keys)
|
|
|
|
|
|
|
|
|
|
for _, key := range keys {
|
|
|
|
|
if NormalizeModule(key) != canonical {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
defaults := play.ModuleDefaults[key]
|
|
|
|
|
if len(defaults) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for k, v := range defaults {
|
|
|
|
|
merged[k] = v
|
|
|
|
|
}
|
|
|
|
|
seen = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !seen {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return merged
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func mergeModuleDefaults(args, defaults map[string]any) map[string]any {
|
|
|
|
|
if len(args) == 0 && len(defaults) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
merged := make(map[string]any, len(args)+len(defaults))
|
|
|
|
|
for k, v := range defaults {
|
|
|
|
|
merged[k] = v
|
|
|
|
|
}
|
|
|
|
|
for k, v := range args {
|
|
|
|
|
merged[k] = v
|
|
|
|
|
}
|
|
|
|
|
return merged
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:46:35 +00:00
|
|
|
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 ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:22:39 +00:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:24:23 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:31:22 +00:00
|
|
|
func backupCronTab(ctx context.Context, client sshExecutorClient, user, name string) (string, error) {
|
|
|
|
|
stdout, _, rc, err := client.Run(ctx, sprintf("crontab -u %s -l 2>/dev/null", user))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", coreerr.E("Executor.moduleCron", "backup crontab", err)
|
|
|
|
|
}
|
|
|
|
|
if rc != 0 || strings.TrimSpace(stdout) == "" {
|
|
|
|
|
return "", nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
backupName := user
|
|
|
|
|
if backupName == "" {
|
|
|
|
|
backupName = "root"
|
|
|
|
|
}
|
|
|
|
|
if name != "" {
|
|
|
|
|
backupName += "-" + name
|
|
|
|
|
}
|
|
|
|
|
backupName = sanitizeBackupToken(backupName)
|
|
|
|
|
|
|
|
|
|
backupPath := path.Join("/tmp", sprintf("ansible-cron-%s.%s.bak", backupName, time.Now().UTC().Format("20060102T150405Z")))
|
|
|
|
|
if err := client.Upload(ctx, bytes.NewReader([]byte(stdout)), backupPath, 0600); err != nil {
|
|
|
|
|
return "", coreerr.E("Executor.moduleCron", "backup crontab", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return backupPath, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sanitizeBackupToken(value string) string {
|
|
|
|
|
if value == "" {
|
|
|
|
|
return "default"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
b.Grow(len(value))
|
|
|
|
|
lastDash := false
|
|
|
|
|
for _, r := range value {
|
|
|
|
|
switch {
|
|
|
|
|
case r >= 'a' && r <= 'z',
|
|
|
|
|
r >= 'A' && r <= 'Z',
|
|
|
|
|
r >= '0' && r <= '9',
|
|
|
|
|
r == '.', r == '_', r == '-':
|
|
|
|
|
b.WriteRune(r)
|
|
|
|
|
lastDash = false
|
|
|
|
|
default:
|
|
|
|
|
if !lastDash {
|
|
|
|
|
b.WriteByte('-')
|
|
|
|
|
lastDash = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
token := strings.Trim(b.String(), "-")
|
|
|
|
|
if token == "" {
|
|
|
|
|
return "default"
|
|
|
|
|
}
|
|
|
|
|
return token
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// 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
|
2026-04-03 07:35:12 +00:00
|
|
|
oldInventoryHostname, hasInventoryHostname := e.vars["inventory_hostname"]
|
2026-03-09 11:37:27 +00:00
|
|
|
e.vars["inventory_hostname"] = host
|
2026-04-03 07:35:12 +00:00
|
|
|
defer func() {
|
|
|
|
|
if hasInventoryHostname {
|
|
|
|
|
e.vars["inventory_hostname"] = oldInventoryHostname
|
|
|
|
|
} else {
|
|
|
|
|
delete(e.vars, "inventory_hostname")
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
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 ---
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleShell(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
cmd := getStringArg(args, "_raw_params", "")
|
|
|
|
|
if cmd == "" {
|
|
|
|
|
cmd = getStringArg(args, "cmd", "")
|
|
|
|
|
}
|
|
|
|
|
if cmd == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleShell", "no command specified", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:11:29 +00:00
|
|
|
chdir := getStringArg(args, "chdir", "")
|
|
|
|
|
|
|
|
|
|
skip, err := shouldSkipCommandModule(ctx, client, args, chdir)
|
2026-04-01 20:48:09 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if skip {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// Handle chdir
|
2026-04-02 14:11:29 +00:00
|
|
|
if chdir != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("cd %q && %s", chdir, cmd)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:47:30 +00:00
|
|
|
if stdin := getStringArg(args, "stdin", ""); stdin != "" {
|
|
|
|
|
cmd = prefixCommandStdin(cmd, stdin, getBoolArg(args, "stdin_add_newline", true))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:58:03 +00:00
|
|
|
stdout, stderr, rc, err := runShellScriptCommand(ctx, client, cmd, getStringArg(args, "executable", ""))
|
2026-03-09 11:37:27 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleCommand(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-04-01 22:36:28 +00:00
|
|
|
cmd := buildCommandModuleCommand(args)
|
2026-03-09 11:37:27 +00:00
|
|
|
if cmd == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleCommand", "no command specified", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:11:29 +00:00
|
|
|
chdir := getStringArg(args, "chdir", "")
|
|
|
|
|
|
|
|
|
|
skip, err := shouldSkipCommandModule(ctx, client, args, chdir)
|
2026-04-01 20:48:09 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if skip {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// Handle chdir
|
2026-04-02 14:11:29 +00:00
|
|
|
if chdir != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("cd %q && %s", chdir, cmd)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:47:30 +00:00
|
|
|
if stdin := getStringArg(args, "stdin", ""); stdin != "" {
|
|
|
|
|
cmd = prefixCommandStdin(cmd, stdin, getBoolArg(args, "stdin_add_newline", true))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:36:28 +00:00
|
|
|
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 != "<nil>" {
|
|
|
|
|
out = append(out, s)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
case string:
|
|
|
|
|
if v == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return []string{v}
|
|
|
|
|
default:
|
|
|
|
|
s := sprintf("%v", v)
|
|
|
|
|
if s == "" || s == "<nil>" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return []string{s}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:11:29 +00:00
|
|
|
func shouldSkipCommandModule(ctx context.Context, client sshExecutorClient, args map[string]any, chdir string) (bool, error) {
|
2026-04-01 20:48:09 +00:00
|
|
|
if path := getStringArg(args, "creates", ""); path != "" {
|
2026-04-02 14:11:29 +00:00
|
|
|
path = resolveCommandModulePath(path, chdir)
|
2026-04-01 20:48:09 +00:00
|
|
|
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 != "" {
|
2026-04-02 14:11:29 +00:00
|
|
|
path = resolveCommandModulePath(path, chdir)
|
2026-04-01 20:48:09 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:11:29 +00:00
|
|
|
func resolveCommandModulePath(filePath, chdir string) string {
|
|
|
|
|
filePath = strings.TrimSpace(filePath)
|
|
|
|
|
if filePath == "" || path.IsAbs(filePath) || chdir == "" {
|
|
|
|
|
return filePath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return path.Join(chdir, filePath)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleRaw(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
cmd := getStringArg(args, "_raw_params", "")
|
|
|
|
|
if cmd == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleRaw", "no command specified", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleScript(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
script := getStringArg(args, "_raw_params", "")
|
|
|
|
|
if script == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleScript", "no script specified", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:11:29 +00:00
|
|
|
chdir := getStringArg(args, "chdir", "")
|
|
|
|
|
|
|
|
|
|
skip, err := shouldSkipCommandModule(ctx, client, args, chdir)
|
2026-04-01 22:59:12 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if skip {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// Read local script
|
2026-04-01 22:03:43 +00:00
|
|
|
script = e.resolveLocalPath(script)
|
2026-03-16 19:50:03 +00:00
|
|
|
data, err := coreio.Local.Read(script)
|
2026-03-09 11:37:27 +00:00
|
|
|
if err != nil {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleScript", "read script", err)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:11:29 +00:00
|
|
|
if chdir != "" {
|
2026-04-01 22:59:12 +00:00
|
|
|
data = sprintf("cd %q && %s", chdir, data)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:58:03 +00:00
|
|
|
stdout, stderr, rc, err := runShellScriptCommand(ctx, client, data, getStringArg(args, "executable", ""))
|
2026-03-09 11:37:27 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:58:03 +00:00
|
|
|
// runShellScriptCommand executes a shell script using either the default
|
|
|
|
|
// heredoc path or a caller-specified executable.
|
|
|
|
|
//
|
|
|
|
|
// Example:
|
|
|
|
|
//
|
|
|
|
|
// stdout, stderr, rc, err := runShellScriptCommand(ctx, client, "echo hi", "/bin/dash")
|
|
|
|
|
func runShellScriptCommand(ctx context.Context, client sshExecutorClient, script, executable string) (stdout, stderr string, exitCode int, err error) {
|
|
|
|
|
if executable == "" {
|
|
|
|
|
return client.RunScript(ctx, script)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd := sprintf("%s -c %s", shellSingleQuote(executable), shellSingleQuote(script))
|
|
|
|
|
return client.Run(ctx, cmd)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// --- File Modules ---
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleCopy(ctx context.Context, client sshExecutorClient, args map[string]any, host string, task *Task) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
dest := getStringArg(args, "dest", "")
|
|
|
|
|
if dest == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleCopy", "dest required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-04-01 23:02:03 +00:00
|
|
|
force := getBoolArg(args, "force", true)
|
2026-04-02 00:24:23 +00:00
|
|
|
backup := getBoolArg(args, "backup", false)
|
2026-04-01 23:11:42 +00:00
|
|
|
remoteSrc := getBoolArg(args, "remote_src", false)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-03-16 19:50:03 +00:00
|
|
|
var content string
|
2026-03-09 11:37:27 +00:00
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
if src := getStringArg(args, "src", ""); src != "" {
|
2026-04-01 23:11:42 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
} else if c := getStringArg(args, "content", ""); c != "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
content = c
|
2026-03-09 11:37:27 +00:00
|
|
|
} else {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleCopy", "src or content required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:39:59 +00:00
|
|
|
mode := fs.FileMode(0644)
|
2026-03-09 11:37:27 +00:00
|
|
|
if m := getStringArg(args, "mode", ""); m != "" {
|
|
|
|
|
if parsed, err := strconv.ParseInt(m, 8, 32); err == nil {
|
2026-03-26 16:39:59 +00:00
|
|
|
mode = fs.FileMode(parsed)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:22:39 +00:00
|
|
|
before, hasBefore := remoteFileText(ctx, client, dest)
|
2026-04-01 23:02:03 +00:00
|
|
|
if hasBefore && !force {
|
|
|
|
|
return &TaskResult{Changed: false, Msg: sprintf("skipped existing destination: %s", dest)}, nil
|
|
|
|
|
}
|
2026-04-01 20:22:39 +00:00
|
|
|
if hasBefore && before == content {
|
|
|
|
|
if getStringArg(args, "owner", "") == "" && getStringArg(args, "group", "") == "" {
|
|
|
|
|
return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", dest)}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:24:23 +00:00
|
|
|
var backupPath string
|
|
|
|
|
if backup && hasBefore {
|
|
|
|
|
backupPath, _, err = backupRemoteFile(ctx, client, dest)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleCopy", "backup destination", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 14:47:37 +00:00
|
|
|
err = client.Upload(ctx, newReader(content), dest, mode)
|
2026-03-09 11:37:27 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle owner/group (best-effort, errors ignored)
|
|
|
|
|
if owner := getStringArg(args, "owner", ""); owner != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chown %s %q", owner, dest))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
if group := getStringArg(args, "group", ""); group != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chgrp %s %q", group, dest))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:22:39 +00:00
|
|
|
result := &TaskResult{Changed: true, Msg: sprintf("copied to %s", dest)}
|
2026-04-02 00:24:23 +00:00
|
|
|
if backupPath != "" {
|
|
|
|
|
result.Data = map[string]any{"backup_file": backupPath}
|
|
|
|
|
}
|
2026-04-01 20:22:39 +00:00
|
|
|
if e.Diff {
|
|
|
|
|
if hasBefore {
|
2026-04-02 00:24:23 +00:00
|
|
|
if result.Data == nil {
|
|
|
|
|
result.Data = make(map[string]any)
|
|
|
|
|
}
|
|
|
|
|
result.Data["diff"] = fileDiffData(dest, before, content)
|
2026-04-01 20:22:39 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleTemplate(ctx context.Context, client sshExecutorClient, args map[string]any, host string, task *Task) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
src := getStringArg(args, "src", "")
|
|
|
|
|
dest := getStringArg(args, "dest", "")
|
|
|
|
|
if src == "" || dest == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleTemplate", "src and dest required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-04-01 23:02:03 +00:00
|
|
|
force := getBoolArg(args, "force", true)
|
2026-04-02 00:24:23 +00:00
|
|
|
backup := getBoolArg(args, "backup", false)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
// Process template
|
2026-04-01 22:03:43 +00:00
|
|
|
src = e.resolveLocalPath(src)
|
2026-03-09 11:37:27 +00:00
|
|
|
content, err := e.TemplateFile(src, host, task)
|
|
|
|
|
if err != nil {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleTemplate", "template", err)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:39:59 +00:00
|
|
|
mode := fs.FileMode(0644)
|
2026-03-09 11:37:27 +00:00
|
|
|
if m := getStringArg(args, "mode", ""); m != "" {
|
|
|
|
|
if parsed, err := strconv.ParseInt(m, 8, 32); err == nil {
|
2026-03-26 16:39:59 +00:00
|
|
|
mode = fs.FileMode(parsed)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:22:39 +00:00
|
|
|
before, hasBefore := remoteFileText(ctx, client, dest)
|
2026-04-01 23:02:03 +00:00
|
|
|
if hasBefore && !force {
|
|
|
|
|
return &TaskResult{Changed: false, Msg: sprintf("skipped existing destination: %s", dest)}, nil
|
|
|
|
|
}
|
2026-04-01 20:22:39 +00:00
|
|
|
if hasBefore && before == content {
|
|
|
|
|
return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", dest)}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:24:23 +00:00
|
|
|
var backupPath string
|
|
|
|
|
if backup && hasBefore {
|
|
|
|
|
backupPath, _, err = backupRemoteFile(ctx, client, dest)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleTemplate", "backup destination", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 14:47:37 +00:00
|
|
|
err = client.Upload(ctx, newReader(content), dest, mode)
|
2026-03-09 11:37:27 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:22:39 +00:00
|
|
|
result := &TaskResult{Changed: true, Msg: sprintf("templated to %s", dest)}
|
2026-04-02 00:24:23 +00:00
|
|
|
if backupPath != "" {
|
|
|
|
|
result.Data = map[string]any{"backup_file": backupPath}
|
|
|
|
|
}
|
2026-04-01 20:22:39 +00:00
|
|
|
if e.Diff {
|
|
|
|
|
if hasBefore {
|
2026-04-02 00:24:23 +00:00
|
|
|
if result.Data == nil {
|
|
|
|
|
result.Data = make(map[string]any)
|
|
|
|
|
}
|
|
|
|
|
result.Data["diff"] = fileDiffData(dest, before, content)
|
2026-04-01 20:22:39 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleFile(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
path := getStringArg(args, "path", "")
|
|
|
|
|
if path == "" {
|
|
|
|
|
path = getStringArg(args, "dest", "")
|
|
|
|
|
}
|
|
|
|
|
if path == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleFile", "path required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state := getStringArg(args, "state", "file")
|
|
|
|
|
|
|
|
|
|
switch state {
|
|
|
|
|
case "directory":
|
|
|
|
|
mode := getStringArg(args, "mode", "0755")
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("mkdir -p %q && chmod %s %q", path, mode, path)
|
2026-03-09 11:37:27 +00:00
|
|
|
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":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("rm -rf %q", path)
|
2026-03-09 11:37:27 +00:00
|
|
|
_, stderr, rc, err := client.Run(ctx, cmd)
|
|
|
|
|
if err != nil || rc != 0 {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "touch":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("touch %q", path)
|
2026-03-09 11:37:27 +00:00
|
|
|
_, 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 == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleFile", "src required for link state", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("ln -sf %q %q", src, path)
|
2026-03-09 11:37:27 +00:00
|
|
|
_, stderr, rc, err := client.Run(ctx, cmd)
|
|
|
|
|
if err != nil || rc != 0 {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:15:47 +00:00
|
|
|
case "hard":
|
|
|
|
|
src := getStringArg(args, "src", "")
|
|
|
|
|
if src == "" {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleFile", "src required for hard state", nil)
|
|
|
|
|
}
|
|
|
|
|
cmd := sprintf("ln -f %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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
case "file":
|
|
|
|
|
// Ensure file exists and set permissions
|
|
|
|
|
if mode := getStringArg(args, "mode", ""); mode != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chmod %s %q", mode, path))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle owner/group (best-effort, errors ignored)
|
|
|
|
|
if owner := getStringArg(args, "owner", ""); owner != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chown %s %q", owner, path))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
if group := getStringArg(args, "group", ""); group != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chgrp %s %q", group, path))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
if recurse := getBoolArg(args, "recurse", false); recurse {
|
|
|
|
|
if owner := getStringArg(args, "owner", ""); owner != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chown -R %s %q", owner, path))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-04-03 14:27:30 +00:00
|
|
|
if group := getStringArg(args, "group", ""); group != "" {
|
|
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chgrp -R %s %q", group, path))
|
|
|
|
|
}
|
|
|
|
|
if mode := getStringArg(args, "mode", ""); mode != "" {
|
|
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chmod -R %s %q", mode, path))
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleLineinfile(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
path := getStringArg(args, "path", "")
|
|
|
|
|
if path == "" {
|
|
|
|
|
path = getStringArg(args, "dest", "")
|
|
|
|
|
}
|
|
|
|
|
if path == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleLineinfile", "path required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:35:42 +00:00
|
|
|
before, hasBefore := remoteFileText(ctx, client, path)
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
line := getStringArg(args, "line", "")
|
|
|
|
|
regexp := getStringArg(args, "regexp", "")
|
2026-04-03 14:48:28 +00:00
|
|
|
searchString := getStringArg(args, "search_string", "")
|
2026-03-09 11:37:27 +00:00
|
|
|
state := getStringArg(args, "state", "present")
|
2026-04-03 14:24:26 +00:00
|
|
|
backup := getBoolArg(args, "backup", false)
|
2026-04-01 19:31:26 +00:00
|
|
|
backrefs := getBoolArg(args, "backrefs", false)
|
2026-04-01 22:56:14 +00:00
|
|
|
create := getBoolArg(args, "create", false)
|
2026-04-02 01:39:10 +00:00
|
|
|
insertBefore := getStringArg(args, "insertbefore", "")
|
|
|
|
|
insertAfter := getStringArg(args, "insertafter", "")
|
2026-04-02 01:42:56 +00:00
|
|
|
firstMatch := getBoolArg(args, "firstmatch", false)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-03 14:24:26 +00:00
|
|
|
var backupPath string
|
|
|
|
|
ensureBackup := func() error {
|
|
|
|
|
if !backup || backupPath != "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var hasCopy bool
|
|
|
|
|
var err error
|
|
|
|
|
backupPath, hasCopy, err = backupRemoteFile(ctx, client, path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return coreerr.E("Executor.moduleLineinfile", "backup remote file", err)
|
|
|
|
|
}
|
|
|
|
|
if !hasCopy {
|
|
|
|
|
backupPath = ""
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 10:51:55 +00:00
|
|
|
if state != "absent" && line != "" && regexp == "" && insertBefore == "" && insertAfter == "" {
|
2026-04-03 11:35:42 +00:00
|
|
|
if hasBefore && fileContainsExactLine(before, line) {
|
2026-04-03 10:51:55 +00:00
|
|
|
return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
if state == "absent" {
|
2026-04-03 14:48:28 +00:00
|
|
|
if searchString != "" {
|
|
|
|
|
if !hasBefore || !strings.Contains(before, searchString) {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
|
|
|
|
if err := ensureBackup(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updated, changed := removeLinesContaining(before, searchString)
|
|
|
|
|
if !changed {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
|
|
|
|
if err := client.Upload(ctx, newReader(updated), path, 0644); err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleLineinfile", "upload lineinfile search_string removal", err)
|
|
|
|
|
}
|
|
|
|
|
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 {
|
|
|
|
|
result.Data = ensureTaskResultData(result.Data)
|
|
|
|
|
result.Data["diff"] = fileDiffData(path, before, after)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
if regexp != "" {
|
2026-04-03 10:51:55 +00:00
|
|
|
if content, ok := remoteFileText(ctx, client, path); !ok || !regexpMatchString(regexp, content) {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
2026-04-03 14:24:26 +00:00
|
|
|
if err := ensureBackup(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("sed -i '/%s/d' %q", regexp, path)
|
2026-03-09 11:37:27 +00:00
|
|
|
_, stderr, rc, _ := client.Run(ctx, cmd)
|
|
|
|
|
if rc != 0 {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-04-01 22:56:14 +00:00
|
|
|
// 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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// state == present
|
2026-04-03 14:48:28 +00:00
|
|
|
if searchString != "" {
|
|
|
|
|
if hasBefore && fileContainsExactLine(before, line) {
|
|
|
|
|
return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hasBefore {
|
|
|
|
|
updated, changed := replaceFirstLineContaining(before, searchString, line)
|
|
|
|
|
if changed {
|
|
|
|
|
if err := ensureBackup(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := client.Upload(ctx, newReader(updated), path, 0644); err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleLineinfile", "upload lineinfile search_string replacement", err)
|
|
|
|
|
}
|
|
|
|
|
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 {
|
|
|
|
|
result.Data = ensureTaskResultData(result.Data)
|
|
|
|
|
result.Data["diff"] = fileDiffData(path, before, after)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := ensureBackup(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
} else if inserted {
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updated := line
|
|
|
|
|
if hasBefore {
|
|
|
|
|
updated = before
|
|
|
|
|
if updated != "" && !strings.HasSuffix(updated, "\n") {
|
|
|
|
|
updated += "\n"
|
|
|
|
|
}
|
|
|
|
|
updated += line
|
|
|
|
|
}
|
|
|
|
|
if !hasBefore && line != "" {
|
|
|
|
|
updated = line + "\n"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := client.Upload(ctx, newReader(updated), path, 0644); err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleLineinfile", "upload lineinfile search_string append", err)
|
|
|
|
|
}
|
|
|
|
|
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 {
|
|
|
|
|
result.Data = ensureTaskResultData(result.Data)
|
|
|
|
|
result.Data["diff"] = fileDiffData(path, before, after)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
if regexp != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
escapedLine := replaceAll(line, "/", "\\/")
|
2026-04-01 19:31:26 +00:00
|
|
|
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"
|
|
|
|
|
}
|
2026-04-03 14:24:26 +00:00
|
|
|
|
|
|
|
|
if err := ensureBackup(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:31:26 +00:00
|
|
|
cmd := sprintf("sed %s 's/%s/%s/' %q", sedFlags, regexp, escapedLine, path)
|
2026-03-09 11:37:27 +00:00
|
|
|
_, _, rc, _ := client.Run(ctx, cmd)
|
|
|
|
|
if rc != 0 {
|
2026-04-01 19:31:26 +00:00
|
|
|
if backrefs {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
2026-04-03 14:24:26 +00:00
|
|
|
|
|
|
|
|
if err := ensureBackup(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-02 01:42:56 +00:00
|
|
|
if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil {
|
2026-04-02 01:39:10 +00:00
|
|
|
return nil, err
|
|
|
|
|
} else if inserted {
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
2026-04-03 14:24:26 +00:00
|
|
|
|
2026-04-01 19:31:26 +00:00
|
|
|
// Line not found, append.
|
2026-04-03 14:24:26 +00:00
|
|
|
if err := ensureBackup(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("echo %q >> %q", line, path)
|
2026-03-09 11:37:27 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
|
|
|
}
|
|
|
|
|
} else if line != "" {
|
2026-04-03 14:24:26 +00:00
|
|
|
if err := ensureBackup(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-02 01:42:56 +00:00
|
|
|
if inserted, err := insertLineRelativeToMatch(ctx, client, path, line, insertBefore, insertAfter, firstMatch); err != nil {
|
2026-04-02 01:39:10 +00:00
|
|
|
return nil, err
|
|
|
|
|
} else if inserted {
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// Ensure line is present
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("grep -qxF %q %q || echo %q >> %q", line, path, line, path)
|
2026-03-09 11:37:27 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:35:42 +00:00
|
|
|
result := &TaskResult{Changed: true}
|
2026-04-03 14:24:26 +00:00
|
|
|
if backupPath != "" {
|
|
|
|
|
result.Data = map[string]any{"backup_file": backupPath}
|
|
|
|
|
}
|
2026-04-03 11:35:42 +00:00
|
|
|
if e.Diff {
|
|
|
|
|
if after, ok := remoteFileText(ctx, client, path); ok && before != after {
|
2026-04-03 14:24:26 +00:00
|
|
|
if result.Data == nil {
|
|
|
|
|
result.Data = make(map[string]any)
|
|
|
|
|
}
|
|
|
|
|
result.Data["diff"] = fileDiffData(path, before, after)
|
2026-04-03 11:35:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 13:31:06 +00:00
|
|
|
func (e *Executor) moduleReplace(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.moduleReplace", "path required", nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pattern := getStringArg(args, "regexp", "")
|
|
|
|
|
if pattern == "" {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleReplace", "regexp required", nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
replacement := getStringArg(args, "replace", "")
|
|
|
|
|
backup := getBoolArg(args, "backup", false)
|
|
|
|
|
|
|
|
|
|
before, ok := remoteFileText(ctx, client, path)
|
|
|
|
|
if !ok {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: sprintf("file not found: %s", path)}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
re, err := regexp.Compile(pattern)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleReplace", "compile regexp", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
after := re.ReplaceAllString(before, replacement)
|
|
|
|
|
if after == before {
|
|
|
|
|
return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", path)}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result := &TaskResult{Changed: true}
|
|
|
|
|
if backup {
|
|
|
|
|
backupPath, hasBefore, err := backupRemoteFile(ctx, client, path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleReplace", "backup remote file", err)
|
|
|
|
|
}
|
|
|
|
|
if hasBefore {
|
|
|
|
|
result.Data = map[string]any{"backup_file": backupPath}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := client.Upload(ctx, bytes.NewReader([]byte(after)), path, 0644); err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleReplace", "upload replacement", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if e.Diff {
|
|
|
|
|
result.Data = ensureTaskResultData(result.Data)
|
|
|
|
|
result.Data["diff"] = fileDiffData(path, before, after)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ensureTaskResultData(data map[string]any) map[string]any {
|
|
|
|
|
if data != nil {
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
return make(map[string]any)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 10:51:55 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 01:42:56 +00:00
|
|
|
func insertLineRelativeToMatch(ctx context.Context, client commandRunner, path, line, insertBefore, insertAfter string, firstMatch bool) (bool, error) {
|
2026-04-02 01:39:10 +00:00
|
|
|
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 {
|
2026-04-02 01:42:56 +00:00
|
|
|
cmd := buildLineinfileInsertCommand(path, line, insertBefore, false, firstMatch)
|
2026-04-02 01:39:10 +00:00
|
|
|
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 {
|
2026-04-02 01:42:56 +00:00
|
|
|
cmd := buildLineinfileInsertCommand(path, line, insertAfter, true, firstMatch)
|
2026-04-02 01:39:10 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:48:28 +00:00
|
|
|
func replaceFirstLineContaining(content, needle, line string) (string, bool) {
|
|
|
|
|
if content == "" || needle == "" {
|
|
|
|
|
return content, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lines := strings.Split(content, "\n")
|
|
|
|
|
changed := false
|
|
|
|
|
for i, current := range lines {
|
|
|
|
|
if changed {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(current, needle) {
|
|
|
|
|
lines[i] = line
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !changed {
|
|
|
|
|
return content, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return join("\n", lines), true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func removeLinesContaining(content, needle string) (string, bool) {
|
|
|
|
|
if content == "" || needle == "" {
|
|
|
|
|
return content, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lines := strings.Split(content, "\n")
|
|
|
|
|
kept := make([]string, 0, len(lines))
|
|
|
|
|
removed := false
|
|
|
|
|
for _, current := range lines {
|
|
|
|
|
if strings.Contains(current, needle) {
|
|
|
|
|
removed = true
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
kept = append(kept, current)
|
|
|
|
|
}
|
|
|
|
|
if !removed {
|
|
|
|
|
return content, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return join("\n", kept), true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 01:42:56 +00:00
|
|
|
func buildLineinfileInsertCommand(path, line, anchor string, after, firstMatch bool) string {
|
2026-04-02 01:39:10 +00:00
|
|
|
quotedLine := shellSingleQuote(line)
|
|
|
|
|
quotedAnchor := shellSingleQuote(anchor)
|
2026-04-02 01:42:56 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 01:39:10 +00:00
|
|
|
if after {
|
2026-04-02 01:42:56 +00:00
|
|
|
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",
|
2026-04-02 01:39:10 +00:00
|
|
|
quotedLine, quotedAnchor, path, path)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 01:42:56 +00:00
|
|
|
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",
|
2026-04-02 01:39:10 +00:00
|
|
|
quotedLine, quotedAnchor, path, path)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleStat(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
path := getStringArg(args, "path", "")
|
|
|
|
|
if path == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleStat", "path required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stat, err := client.Stat(ctx, path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &TaskResult{
|
|
|
|
|
Changed: false,
|
|
|
|
|
Data: map[string]any{"stat": stat},
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleSlurp(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
path := getStringArg(args, "path", "")
|
|
|
|
|
if path == "" {
|
|
|
|
|
path = getStringArg(args, "src", "")
|
|
|
|
|
}
|
|
|
|
|
if path == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleSlurp", "path required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleFetch(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
src := getStringArg(args, "src", "")
|
|
|
|
|
dest := getStringArg(args, "dest", "")
|
|
|
|
|
if src == "" || dest == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleFetch", "src and dest required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
content, err := client.Download(ctx, src)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create dest directory
|
2026-03-26 14:47:37 +00:00
|
|
|
if err := coreio.Local.EnsureDir(pathDir(dest)); err != nil {
|
2026-03-09 11:37:27 +00:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 19:50:03 +00:00
|
|
|
if err := coreio.Local.Write(dest, string(content)); err != nil {
|
2026-03-09 11:37:27 +00:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 14:47:37 +00:00
|
|
|
return &TaskResult{Changed: true, Msg: sprintf("fetched %s to %s", src, dest)}, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleGetURL(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
url := getStringArg(args, "url", "")
|
|
|
|
|
dest := getStringArg(args, "dest", "")
|
|
|
|
|
if url == "" || dest == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleGetURL", "url and dest required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 15:01:00 +00:00
|
|
|
checksumSpec := corexTrimSpace(getStringArg(args, "checksum", ""))
|
|
|
|
|
|
|
|
|
|
// Stream to stdout so we can validate checksums before writing the file.
|
|
|
|
|
cmd := sprintf("curl -fsSL %q || wget -q -O - %q", url, url)
|
2026-03-09 11:37:27 +00:00
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
|
|
|
if err != nil || rc != 0 {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 15:01:00 +00:00
|
|
|
content := []byte(stdout)
|
|
|
|
|
if checksumSpec != "" {
|
|
|
|
|
if err := verifyGetURLChecksum(content, checksumSpec); err != nil {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: err.Error(), Stdout: stdout, Stderr: stderr, RC: rc}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mode := fs.FileMode(0644)
|
|
|
|
|
// Set mode if specified (best-effort).
|
|
|
|
|
if modeArg := getStringArg(args, "mode", ""); modeArg != "" {
|
|
|
|
|
if parsed, err := strconv.ParseInt(modeArg, 8, 32); err == nil {
|
|
|
|
|
mode = fs.FileMode(parsed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := client.Upload(ctx, bytes.NewReader(content), dest, mode); err != nil {
|
|
|
|
|
return nil, err
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 15:01:00 +00:00
|
|
|
func verifyGetURLChecksum(content []byte, checksumSpec string) error {
|
|
|
|
|
parts := strings.SplitN(checksumSpec, ":", 2)
|
|
|
|
|
algorithm := "sha256"
|
|
|
|
|
expected := checksumSpec
|
|
|
|
|
if len(parts) == 2 {
|
|
|
|
|
algorithm = lower(corexTrimSpace(parts[0]))
|
|
|
|
|
expected = corexTrimSpace(parts[1])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expected = strings.ToLower(corexTrimSpace(expected))
|
|
|
|
|
if expected == "" {
|
|
|
|
|
return coreerr.E("Executor.moduleGetURL", "checksum required", nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var actual string
|
|
|
|
|
switch algorithm {
|
|
|
|
|
case "", "sha256":
|
|
|
|
|
sum := sha256.Sum256(content)
|
|
|
|
|
actual = hex.EncodeToString(sum[:])
|
|
|
|
|
case "sha1":
|
|
|
|
|
sum := sha1.Sum(content)
|
|
|
|
|
actual = hex.EncodeToString(sum[:])
|
2026-04-03 15:04:30 +00:00
|
|
|
case "sha224":
|
|
|
|
|
sum := sha256.Sum224(content)
|
|
|
|
|
actual = hex.EncodeToString(sum[:])
|
|
|
|
|
case "sha384":
|
|
|
|
|
sum := sha512.Sum384(content)
|
|
|
|
|
actual = hex.EncodeToString(sum[:])
|
|
|
|
|
case "sha512":
|
|
|
|
|
sum := sha512.Sum512(content)
|
|
|
|
|
actual = hex.EncodeToString(sum[:])
|
2026-04-03 15:01:00 +00:00
|
|
|
default:
|
|
|
|
|
return coreerr.E("Executor.moduleGetURL", "unsupported checksum algorithm: "+algorithm, nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if actual != expected {
|
|
|
|
|
return coreerr.E("Executor.moduleGetURL", sprintf("checksum mismatch: expected %s but got %s", expected, actual), nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// --- Package Modules ---
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleApt(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-04-03 11:19:59 +00:00
|
|
|
names := normalizeStringArgs(args["name"])
|
2026-03-09 11:37:27 +00:00
|
|
|
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":
|
2026-04-03 11:19:59 +00:00
|
|
|
if len(names) > 0 {
|
|
|
|
|
cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s", join(" ", names))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
case "absent", "removed":
|
2026-04-03 11:19:59 +00:00
|
|
|
if len(names) > 0 {
|
|
|
|
|
cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq %s", join(" ", names))
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
case "latest":
|
2026-04-03 11:19:59 +00:00
|
|
|
if len(names) > 0 {
|
|
|
|
|
cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade %s", join(" ", names))
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleAptKey(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
url := getStringArg(args, "url", "")
|
|
|
|
|
keyring := getStringArg(args, "keyring", "")
|
|
|
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
|
|
|
|
|
|
if state == "absent" {
|
|
|
|
|
if keyring != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("rm -f %q", keyring))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if url == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleAptKey", "url required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var cmd string
|
|
|
|
|
if keyring != "" {
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("curl -fsSL %q | gpg --dearmor -o %q", url, keyring)
|
2026-03-09 11:37:27 +00:00
|
|
|
} else {
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("curl -fsSL %q | apt-key add -", url)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleAptRepository(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
repo := getStringArg(args, "repo", "")
|
|
|
|
|
filename := getStringArg(args, "filename", "")
|
|
|
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
|
|
|
|
|
|
if repo == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleAptRepository", "repo required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if filename == "" {
|
|
|
|
|
// Generate filename from repo
|
2026-03-26 14:47:37 +00:00
|
|
|
filename = replaceAll(repo, " ", "-")
|
|
|
|
|
filename = replaceAll(filename, "/", "-")
|
|
|
|
|
filename = replaceAll(filename, ":", "")
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 14:47:37 +00:00
|
|
|
path := sprintf("/etc/apt/sources.list.d/%s.list", filename)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
if state == "absent" {
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("rm -f %q", path))
|
2026-03-09 11:37:27 +00:00
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("echo %q > %q", repo, path)
|
2026-03-09 11:37:27 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) modulePackage(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
// Detect package manager and delegate
|
|
|
|
|
stdout, _, _, _ := client.Run(ctx, "which apt-get yum dnf 2>/dev/null | head -1")
|
2026-03-26 14:47:37 +00:00
|
|
|
stdout = corexTrimSpace(stdout)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-01 19:48:36 +00:00
|
|
|
switch {
|
|
|
|
|
case contains(stdout, "dnf"):
|
|
|
|
|
return e.moduleDnf(ctx, client, args)
|
|
|
|
|
case contains(stdout, "yum"):
|
|
|
|
|
return e.moduleYum(ctx, client, args)
|
|
|
|
|
default:
|
2026-03-09 11:37:27 +00:00
|
|
|
return e.moduleApt(ctx, client, args)
|
|
|
|
|
}
|
2026-04-01 19:48:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleYum(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-04-01 19:48:36 +00:00
|
|
|
return e.moduleRPM(ctx, client, args, "yum")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleDnf(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-04-01 19:48:36 +00:00
|
|
|
return e.moduleRPM(ctx, client, args, "dnf")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:55:15 +00:00
|
|
|
func (e *Executor) moduleRPM(ctx context.Context, client sshExecutorClient, args map[string]any, packageManager string) (*TaskResult, error) {
|
2026-04-03 11:19:59 +00:00
|
|
|
names := normalizeStringArgs(args["name"])
|
2026-04-01 19:48:36 +00:00
|
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
|
updateCache := getBoolArg(args, "update_cache", false)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-03 14:55:15 +00:00
|
|
|
if updateCache && packageManager != "rpm" {
|
|
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("%s makecache -y", packageManager))
|
2026-04-01 19:48:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var cmd string
|
|
|
|
|
switch state {
|
|
|
|
|
case "present", "installed":
|
2026-04-03 11:19:59 +00:00
|
|
|
if len(names) > 0 {
|
2026-04-03 14:55:15 +00:00
|
|
|
if packageManager == "rpm" {
|
2026-04-03 11:19:59 +00:00
|
|
|
cmd = sprintf("rpm -ivh %s", join(" ", names))
|
2026-04-02 00:43:32 +00:00
|
|
|
} else {
|
2026-04-03 14:55:15 +00:00
|
|
|
cmd = sprintf("%s install -y -q %s", packageManager, join(" ", names))
|
2026-04-02 00:43:32 +00:00
|
|
|
}
|
2026-04-01 19:48:36 +00:00
|
|
|
}
|
|
|
|
|
case "absent", "removed":
|
2026-04-03 11:19:59 +00:00
|
|
|
if len(names) > 0 {
|
2026-04-03 14:55:15 +00:00
|
|
|
if packageManager == "rpm" {
|
2026-04-03 11:19:59 +00:00
|
|
|
cmd = sprintf("rpm -e %s", join(" ", names))
|
2026-04-02 00:43:32 +00:00
|
|
|
} else {
|
2026-04-03 14:55:15 +00:00
|
|
|
cmd = sprintf("%s remove -y -q %s", packageManager, join(" ", names))
|
2026-04-02 00:43:32 +00:00
|
|
|
}
|
2026-04-01 19:48:36 +00:00
|
|
|
}
|
|
|
|
|
case "latest":
|
2026-04-03 11:19:59 +00:00
|
|
|
if len(names) > 0 {
|
2026-04-03 14:55:15 +00:00
|
|
|
if packageManager == "rpm" {
|
2026-04-03 11:19:59 +00:00
|
|
|
cmd = sprintf("rpm -Uvh %s", join(" ", names))
|
2026-04-03 14:55:15 +00:00
|
|
|
} else if packageManager == "dnf" {
|
|
|
|
|
cmd = sprintf("%s upgrade -y -q %s", packageManager, join(" ", names))
|
2026-04-01 19:48:36 +00:00
|
|
|
} else {
|
2026-04-03 14:55:15 +00:00
|
|
|
cmd = sprintf("%s update -y -q %s", packageManager, join(" ", names))
|
2026-04-01 19:48:36 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) modulePip(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-04-03 11:19:59 +00:00
|
|
|
names := normalizeStringArgs(args["name"])
|
2026-03-09 11:37:27 +00:00
|
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
|
executable := getStringArg(args, "executable", "pip3")
|
2026-04-02 13:43:16 +00:00
|
|
|
virtualenv := getStringArg(args, "virtualenv", "")
|
|
|
|
|
requirements := getStringArg(args, "requirements", "")
|
|
|
|
|
extraArgs := getStringArg(args, "extra_args", "")
|
|
|
|
|
|
|
|
|
|
if virtualenv != "" && executable == "pip3" {
|
|
|
|
|
executable = path.Join(virtualenv, "bin", "pip")
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
var cmd string
|
|
|
|
|
switch state {
|
|
|
|
|
case "present", "installed":
|
2026-04-02 13:43:16 +00:00
|
|
|
parts := []string{executable, "install"}
|
|
|
|
|
if extraArgs != "" {
|
|
|
|
|
parts = append(parts, extraArgs)
|
|
|
|
|
}
|
|
|
|
|
switch {
|
|
|
|
|
case requirements != "":
|
|
|
|
|
parts = append(parts, sprintf("-r %q", requirements))
|
2026-04-03 11:19:59 +00:00
|
|
|
case len(names) > 0:
|
|
|
|
|
parts = append(parts, join(" ", names))
|
2026-04-02 13:43:16 +00:00
|
|
|
}
|
|
|
|
|
cmd = join(" ", parts)
|
2026-03-09 11:37:27 +00:00
|
|
|
case "absent", "removed":
|
2026-04-03 11:19:59 +00:00
|
|
|
if len(names) > 0 {
|
2026-04-02 13:43:16 +00:00
|
|
|
parts := []string{executable, "uninstall", "-y"}
|
|
|
|
|
if extraArgs != "" {
|
|
|
|
|
parts = append(parts, extraArgs)
|
|
|
|
|
}
|
2026-04-03 11:19:59 +00:00
|
|
|
parts = append(parts, join(" ", names))
|
2026-04-02 13:43:16 +00:00
|
|
|
cmd = join(" ", parts)
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
case "latest":
|
2026-04-03 11:19:59 +00:00
|
|
|
if len(names) > 0 {
|
2026-04-02 13:43:16 +00:00
|
|
|
parts := []string{executable, "install", "--upgrade"}
|
|
|
|
|
if extraArgs != "" {
|
|
|
|
|
parts = append(parts, extraArgs)
|
|
|
|
|
}
|
2026-04-03 11:19:59 +00:00
|
|
|
parts = append(parts, join(" ", names))
|
2026-04-02 13:43:16 +00:00
|
|
|
cmd = join(" ", parts)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cmd == "" {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 ---
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleService(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
name := getStringArg(args, "name", "")
|
|
|
|
|
state := getStringArg(args, "state", "")
|
|
|
|
|
enabled := args["enabled"]
|
|
|
|
|
|
|
|
|
|
if name == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleService", "name required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var cmds []string
|
|
|
|
|
|
|
|
|
|
if state != "" {
|
|
|
|
|
switch state {
|
|
|
|
|
case "started":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmds = append(cmds, sprintf("systemctl start %s", name))
|
2026-03-09 11:37:27 +00:00
|
|
|
case "stopped":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmds = append(cmds, sprintf("systemctl stop %s", name))
|
2026-03-09 11:37:27 +00:00
|
|
|
case "restarted":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmds = append(cmds, sprintf("systemctl restart %s", name))
|
2026-03-09 11:37:27 +00:00
|
|
|
case "reloaded":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmds = append(cmds, sprintf("systemctl reload %s", name))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if enabled != nil {
|
|
|
|
|
if getBoolArg(args, "enabled", false) {
|
2026-03-26 14:47:37 +00:00
|
|
|
cmds = append(cmds, sprintf("systemctl enable %s", name))
|
2026-03-09 11:37:27 +00:00
|
|
|
} else {
|
2026-03-26 14:47:37 +00:00
|
|
|
cmds = append(cmds, sprintf("systemctl disable %s", name))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleSystemd(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
// 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 ---
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleUser(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
name := getStringArg(args, "name", "")
|
|
|
|
|
state := getStringArg(args, "state", "present")
|
2026-04-03 13:12:51 +00:00
|
|
|
appendGroups := getBoolArg(args, "append", false)
|
2026-04-03 13:23:29 +00:00
|
|
|
local := getBoolArg(args, "local", false)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
if name == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleUser", "name required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if state == "absent" {
|
2026-04-03 13:23:29 +00:00
|
|
|
delCmd := "userdel"
|
|
|
|
|
if local {
|
|
|
|
|
delCmd = "luserdel"
|
|
|
|
|
}
|
|
|
|
|
cmd := sprintf("%s -r %s 2>/dev/null || true", delCmd, name)
|
2026-03-09 11:37:27 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build useradd/usermod command
|
2026-04-03 13:12:51 +00:00
|
|
|
var addOpts []string
|
|
|
|
|
var modOpts []string
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
if uid := getStringArg(args, "uid", ""); uid != "" {
|
2026-04-03 13:12:51 +00:00
|
|
|
addOpts = append(addOpts, "-u", uid)
|
|
|
|
|
modOpts = append(modOpts, "-u", uid)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
if group := getStringArg(args, "group", ""); group != "" {
|
2026-04-03 13:12:51 +00:00
|
|
|
addOpts = append(addOpts, "-g", group)
|
|
|
|
|
modOpts = append(modOpts, "-g", group)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-04-03 11:19:59 +00:00
|
|
|
if groups := normalizeStringArgs(args["groups"]); len(groups) > 0 {
|
2026-04-03 13:12:51 +00:00
|
|
|
addOpts = append(addOpts, "-G", join(",", groups))
|
|
|
|
|
if appendGroups {
|
|
|
|
|
modOpts = append(modOpts, "-a")
|
|
|
|
|
}
|
|
|
|
|
modOpts = append(modOpts, "-G", join(",", groups))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
if home := getStringArg(args, "home", ""); home != "" {
|
2026-04-03 13:12:51 +00:00
|
|
|
addOpts = append(addOpts, "-d", home)
|
|
|
|
|
modOpts = append(modOpts, "-d", home)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
if shell := getStringArg(args, "shell", ""); shell != "" {
|
2026-04-03 13:12:51 +00:00
|
|
|
addOpts = append(addOpts, "-s", shell)
|
|
|
|
|
modOpts = append(modOpts, "-s", shell)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
if getBoolArg(args, "system", false) {
|
2026-04-03 13:12:51 +00:00
|
|
|
addOpts = append(addOpts, "-r")
|
|
|
|
|
modOpts = append(modOpts, "-r")
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
if getBoolArg(args, "create_home", true) {
|
2026-04-03 13:12:51 +00:00
|
|
|
addOpts = append(addOpts, "-m")
|
|
|
|
|
modOpts = append(modOpts, "-m")
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try usermod first, then useradd
|
2026-04-03 13:12:51 +00:00
|
|
|
addOptsStr := join(" ", addOpts)
|
|
|
|
|
modOptsStr := join(" ", modOpts)
|
2026-04-03 13:23:29 +00:00
|
|
|
addCmd := "useradd"
|
|
|
|
|
modCmd := "usermod"
|
|
|
|
|
if local {
|
|
|
|
|
addCmd = "luseradd"
|
|
|
|
|
modCmd = "lusermod"
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
var cmd string
|
2026-04-03 13:12:51 +00:00
|
|
|
if addOptsStr == "" {
|
2026-04-03 13:23:29 +00:00
|
|
|
cmd = sprintf("id %s >/dev/null 2>&1 || %s %s", name, addCmd, name)
|
2026-03-09 11:37:27 +00:00
|
|
|
} else {
|
2026-04-03 13:23:29 +00:00
|
|
|
cmd = sprintf("id %s >/dev/null 2>&1 && %s %s %s || %s %s %s",
|
|
|
|
|
name, modCmd, modOptsStr, name, addCmd, addOptsStr, name)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleGroup(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
name := getStringArg(args, "name", "")
|
|
|
|
|
state := getStringArg(args, "state", "present")
|
2026-04-03 13:15:48 +00:00
|
|
|
local := getBoolArg(args, "local", false)
|
|
|
|
|
nonUnique := getBoolArg(args, "non_unique", false)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
if name == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleGroup", "name required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if state == "absent" {
|
2026-04-03 13:15:48 +00:00
|
|
|
delCmd := "groupdel"
|
|
|
|
|
if local {
|
|
|
|
|
delCmd = "lgroupdel"
|
|
|
|
|
}
|
|
|
|
|
cmd := sprintf("%s %s 2>/dev/null || true", delCmd, name)
|
2026-03-09 11:37:27 +00:00
|
|
|
_, _, _, _ = 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")
|
|
|
|
|
}
|
2026-04-03 13:15:48 +00:00
|
|
|
if nonUnique {
|
|
|
|
|
opts = append(opts, "-o")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addCmd := "groupadd"
|
|
|
|
|
if local {
|
|
|
|
|
addCmd = "lgroupadd"
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-03 13:15:48 +00:00
|
|
|
cmd := sprintf("getent group %s >/dev/null 2>&1 || %s %s %s",
|
|
|
|
|
name, addCmd, join(" ", opts), name)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
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 ---
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleURI(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
url := getStringArg(args, "url", "")
|
|
|
|
|
method := getStringArg(args, "method", "GET")
|
2026-04-01 21:32:36 +00:00
|
|
|
bodyFormat := lower(getStringArg(args, "body_format", ""))
|
2026-04-01 21:28:53 +00:00
|
|
|
returnContent := getBoolArg(args, "return_content", false)
|
2026-04-02 03:08:14 +00:00
|
|
|
dest := getStringArg(args, "dest", "")
|
2026-04-02 02:32:49 +00:00
|
|
|
timeout := getIntArg(args, "timeout", 0)
|
|
|
|
|
validateCerts := getBoolArg(args, "validate_certs", true)
|
2026-04-03 13:56:25 +00:00
|
|
|
urlUsername := getStringArg(args, "url_username", "")
|
|
|
|
|
urlPassword := getStringArg(args, "url_password", "")
|
|
|
|
|
forceBasicAuth := getBoolArg(args, "force_basic_auth", false)
|
2026-04-03 14:58:15 +00:00
|
|
|
followRedirects := lower(getStringArg(args, "follow_redirects", "safe"))
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
if url == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleURI", "url required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var curlOpts []string
|
|
|
|
|
curlOpts = append(curlOpts, "-s", "-S")
|
|
|
|
|
curlOpts = append(curlOpts, "-X", method)
|
|
|
|
|
|
2026-04-03 13:56:25 +00:00
|
|
|
// Basic auth is modelled explicitly so callers do not need to embed
|
|
|
|
|
// credentials in the URL.
|
|
|
|
|
if urlUsername != "" || urlPassword != "" {
|
|
|
|
|
curlOpts = append(curlOpts, "-u", shellQuote(urlUsername+":"+urlPassword))
|
|
|
|
|
if forceBasicAuth {
|
|
|
|
|
curlOpts = append(curlOpts, "--basic")
|
|
|
|
|
}
|
|
|
|
|
} else if forceBasicAuth {
|
|
|
|
|
curlOpts = append(curlOpts, "--basic")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// Headers
|
|
|
|
|
if headers, ok := args["headers"].(map[string]any); ok {
|
|
|
|
|
for k, v := range headers {
|
2026-04-01 21:32:36 +00:00
|
|
|
curlOpts = append(curlOpts, "-H", sprintf("%q", sprintf("%s: %v", k, v)))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 02:32:49 +00:00
|
|
|
if !validateCerts {
|
|
|
|
|
curlOpts = append(curlOpts, "-k")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:58:15 +00:00
|
|
|
curlOpts = appendURIFollowRedirects(curlOpts, method, followRedirects)
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// Body
|
2026-04-01 21:32:36 +00:00
|
|
|
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))
|
2026-04-02 02:17:38 +00:00
|
|
|
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\"")
|
|
|
|
|
}
|
2026-04-01 21:32:36 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 02:32:49 +00:00
|
|
|
if timeout > 0 {
|
|
|
|
|
curlOpts = append(curlOpts, "--max-time", strconv.Itoa(timeout))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// Status code
|
|
|
|
|
curlOpts = append(curlOpts, "-w", "\\n%{http_code}")
|
|
|
|
|
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("curl %s %q", join(" ", curlOpts), url)
|
2026-03-09 11:37:27 +00:00
|
|
|
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
|
2026-04-01 21:28:53 +00:00
|
|
|
lines := split(stdout, "\n")
|
2026-03-09 11:37:27 +00:00
|
|
|
statusCode := 0
|
2026-04-01 21:28:53 +00:00
|
|
|
content := ""
|
2026-03-09 11:37:27 +00:00
|
|
|
if len(lines) > 0 {
|
2026-04-01 21:28:53 +00:00
|
|
|
statusText := corexTrimSpace(lines[len(lines)-1])
|
|
|
|
|
statusCode, _ = strconv.Atoi(statusText)
|
|
|
|
|
if len(lines) > 1 {
|
|
|
|
|
content = join("\n", lines[:len(lines)-1])
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:57:15 +00:00
|
|
|
// Check expected status codes.
|
|
|
|
|
expectedStatuses := normalizeStatusCodes(args["status_code"], 200)
|
|
|
|
|
failed := rc != 0 || !containsInt(expectedStatuses, statusCode)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-01 21:28:53 +00:00
|
|
|
data := map[string]any{"status": statusCode}
|
|
|
|
|
if returnContent {
|
|
|
|
|
data["content"] = content
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 03:08:14 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
return &TaskResult{
|
|
|
|
|
Changed: false,
|
|
|
|
|
Stdout: stdout,
|
|
|
|
|
Stderr: stderr,
|
|
|
|
|
RC: statusCode,
|
2026-04-01 21:28:53 +00:00
|
|
|
Data: data,
|
2026-03-09 11:37:27 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:58:15 +00:00
|
|
|
func appendURIFollowRedirects(opts []string, method, followRedirects string) []string {
|
|
|
|
|
if len(opts) == 0 {
|
|
|
|
|
return opts
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch lower(corexTrimSpace(followRedirects)) {
|
|
|
|
|
case "", "safe":
|
|
|
|
|
if method == "GET" || method == "HEAD" {
|
|
|
|
|
return append(opts, "-L")
|
|
|
|
|
}
|
|
|
|
|
case "all", "yes", "true":
|
|
|
|
|
return append(opts, "-L")
|
|
|
|
|
case "none", "no", "false":
|
|
|
|
|
return append(opts, "--max-redirs", "0")
|
|
|
|
|
case "urllib2":
|
|
|
|
|
if method == "GET" || method == "HEAD" {
|
|
|
|
|
return append(opts, "-L")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return opts
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 21:32:36 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-04-02 02:17:38 +00:00
|
|
|
case "form-urlencoded", "form_urlencoded", "form":
|
|
|
|
|
return renderURIBodyFormEncoded(body), nil
|
2026-04-01 21:32:36 +00:00
|
|
|
default:
|
|
|
|
|
return sprintf("%v", body), nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 02:17:38 +00:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 21:32:36 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// --- Misc Modules ---
|
|
|
|
|
|
2026-04-02 13:53:06 +00:00
|
|
|
func (e *Executor) moduleDebug(host string, task *Task, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
msg := getStringArg(args, "msg", "")
|
|
|
|
|
if v, ok := args["var"]; ok {
|
2026-04-02 13:53:06 +00:00
|
|
|
name := sprintf("%v", v)
|
|
|
|
|
if value, ok := e.lookupConditionValue(name, host, task, nil); ok {
|
|
|
|
|
msg = sprintf("%s = %v", name, value)
|
|
|
|
|
} else {
|
|
|
|
|
msg = sprintf("%s = <undefined>", name)
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 02:29:36 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:24:19 +00:00
|
|
|
return &TaskResult{
|
|
|
|
|
Msg: data,
|
|
|
|
|
Data: map[string]any{"ping": data},
|
|
|
|
|
}, nil
|
2026-04-02 02:29:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
func (e *Executor) moduleAssert(args map[string]any, host string) (*TaskResult, error) {
|
|
|
|
|
that, ok := args["that"]
|
|
|
|
|
if !ok {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleAssert", "'that' required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
conditions := normalizeConditions(that)
|
|
|
|
|
for _, cond := range conditions {
|
|
|
|
|
if !e.evalCondition(cond, host) {
|
2026-03-26 14:47:37 +00:00
|
|
|
msg := getStringArg(args, "fail_msg", sprintf("Assertion failed: %s", cond))
|
2026-03-09 11:37:27 +00:00
|
|
|
return &TaskResult{Failed: true, Msg: msg}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &TaskResult{Changed: false, Msg: "All assertions passed"}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 13:53:06 +00:00
|
|
|
func (e *Executor) moduleSetFact(host string, args map[string]any) (*TaskResult, error) {
|
|
|
|
|
values := make(map[string]any, len(args))
|
2026-03-09 11:37:27 +00:00
|
|
|
for k, v := range args {
|
2026-04-02 13:53:06 +00:00
|
|
|
if k == "cacheable" {
|
|
|
|
|
continue
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-04-02 13:53:06 +00:00
|
|
|
values[k] = v
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-04-02 13:53:06 +00:00
|
|
|
e.setHostVars(host, values)
|
2026-04-03 12:29:11 +00:00
|
|
|
e.setHostFacts(host, values)
|
2026-04-03 12:24:19 +00:00
|
|
|
return &TaskResult{
|
|
|
|
|
Changed: true,
|
|
|
|
|
Data: map[string]any{"ansible_facts": values},
|
|
|
|
|
}, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:09:11 +00:00
|
|
|
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)
|
2026-04-03 10:43:56 +00:00
|
|
|
changed := false
|
2026-04-01 19:09:11 +00:00
|
|
|
if host == nil {
|
|
|
|
|
host = &Host{}
|
2026-04-03 10:43:56 +00:00
|
|
|
changed = true
|
2026-04-01 19:09:11 +00:00
|
|
|
}
|
|
|
|
|
if host.Vars == nil {
|
|
|
|
|
host.Vars = make(map[string]any)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if v := getStringArg(args, "ansible_host", ""); v != "" {
|
2026-04-03 10:43:56 +00:00
|
|
|
if host.AnsibleHost != v {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
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 {
|
2026-04-03 10:43:56 +00:00
|
|
|
if host.AnsiblePort != port {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
host.AnsiblePort = port
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if v := getStringArg(args, "ansible_user", ""); v != "" {
|
2026-04-03 10:43:56 +00:00
|
|
|
if host.AnsibleUser != v {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
host.AnsibleUser = v
|
|
|
|
|
}
|
|
|
|
|
if v := getStringArg(args, "ansible_password", ""); v != "" {
|
2026-04-03 10:43:56 +00:00
|
|
|
if host.AnsiblePassword != v {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
host.AnsiblePassword = v
|
|
|
|
|
}
|
|
|
|
|
if v := getStringArg(args, "ansible_ssh_private_key_file", ""); v != "" {
|
2026-04-03 10:43:56 +00:00
|
|
|
if host.AnsibleSSHPrivateKeyFile != v {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
host.AnsibleSSHPrivateKeyFile = v
|
|
|
|
|
}
|
|
|
|
|
if v := getStringArg(args, "ansible_connection", ""); v != "" {
|
2026-04-03 10:43:56 +00:00
|
|
|
if host.AnsibleConnection != v {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
host.AnsibleConnection = v
|
|
|
|
|
}
|
|
|
|
|
if v := getStringArg(args, "ansible_become_password", ""); v != "" {
|
2026-04-03 10:43:56 +00:00
|
|
|
if host.AnsibleBecomePassword != v {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-04-03 10:43:56 +00:00
|
|
|
if existing, ok := host.Vars[key]; !ok || !reflect.DeepEqual(existing, val) {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
host.Vars[key] = val
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if e.inventory.All.Hosts == nil {
|
|
|
|
|
e.inventory.All.Hosts = make(map[string]*Host)
|
|
|
|
|
}
|
2026-04-03 10:43:56 +00:00
|
|
|
if existing, ok := e.inventory.All.Hosts[name]; !ok || existing != host {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-03 10:43:56 +00:00
|
|
|
if existing, ok := group.Hosts[name]; !ok || existing != host {
|
|
|
|
|
changed = true
|
|
|
|
|
}
|
2026-04-01 19:09:11 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 10:43:56 +00:00
|
|
|
return &TaskResult{Changed: changed, Msg: msg, Data: data}, nil
|
2026-04-01 19:09:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:21:57 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
func (e *Executor) modulePause(ctx context.Context, args map[string]any) (*TaskResult, error) {
|
2026-04-01 23:09:00 +00:00
|
|
|
prompt := getStringArg(args, "prompt", "")
|
|
|
|
|
echo := getBoolArg(args, "echo", true)
|
|
|
|
|
|
2026-04-01 19:45:37 +00:00
|
|
|
duration := time.Duration(0)
|
2026-03-09 11:37:27 +00:00
|
|
|
if s, ok := args["seconds"].(int); ok {
|
2026-04-01 19:45:37 +00:00
|
|
|
duration += time.Duration(s) * time.Second
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
if s, ok := args["seconds"].(string); ok {
|
2026-04-01 19:45:37 +00:00
|
|
|
if seconds, err := strconv.Atoi(s); err == nil {
|
|
|
|
|
duration += time.Duration(seconds) * time.Second
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-04-01 19:45:37 +00:00
|
|
|
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
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 23:09:00 +00:00
|
|
|
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')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:45:37 +00:00
|
|
|
if duration > 0 {
|
|
|
|
|
timer := time.NewTimer(duration)
|
|
|
|
|
defer timer.Stop()
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
2026-04-01 19:45:37 +00:00
|
|
|
return nil, ctx.Err()
|
|
|
|
|
case <-timer.C:
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-04-01 19:45:37 +00:00
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-01 23:09:00 +00:00
|
|
|
result := &TaskResult{Changed: false}
|
|
|
|
|
if prompt != "" {
|
|
|
|
|
result.Msg = prompt
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:09:11 +00:00
|
|
|
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}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:19:59 +00:00
|
|
|
// 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 != "<nil>" {
|
|
|
|
|
out = append(out, s)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
default:
|
|
|
|
|
s := corexTrimSpace(corexSprint(v))
|
|
|
|
|
if s != "" && s != "<nil>" {
|
|
|
|
|
return []string{s}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:09:11 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleWaitFor(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-04-02 02:21:29 +00:00
|
|
|
port := getIntArg(args, "port", 0)
|
2026-04-01 20:12:52 +00:00
|
|
|
path := getStringArg(args, "path", "")
|
2026-03-09 11:37:27 +00:00
|
|
|
host := getStringArg(args, "host", "127.0.0.1")
|
|
|
|
|
state := getStringArg(args, "state", "started")
|
2026-04-01 21:53:41 +00:00
|
|
|
searchRegex := getStringArg(args, "search_regex", "")
|
2026-04-01 23:09:00 +00:00
|
|
|
timeoutMsg := getStringArg(args, "msg", "wait_for timed out")
|
2026-04-01 23:51:27 +00:00
|
|
|
delay := getIntArg(args, "delay", 0)
|
2026-04-02 02:21:29 +00:00
|
|
|
timeout := getIntArg(args, "timeout", 300)
|
2026-04-01 21:53:41 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-01 23:51:27 +00:00
|
|
|
if delay > 0 {
|
|
|
|
|
timer := time.NewTimer(time.Duration(delay) * time.Second)
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
timer.Stop()
|
|
|
|
|
return nil, ctx.Err()
|
|
|
|
|
case <-timer.C:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:12:52 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 21:53:41 +00:00
|
|
|
satisfied := false
|
|
|
|
|
switch state {
|
|
|
|
|
case "absent":
|
2026-04-01 20:12:52 +00:00
|
|
|
satisfied = !exists
|
2026-04-01 21:53:41 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-01 20:12:52 +00:00
|
|
|
}
|
|
|
|
|
if satisfied {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return nil, ctx.Err()
|
|
|
|
|
case <-deadline.C:
|
2026-04-01 23:09:00 +00:00
|
|
|
return &TaskResult{Failed: true, Msg: timeoutMsg, RC: 1}, nil
|
2026-04-01 20:12:52 +00:00
|
|
|
case <-ticker.C:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:40:01 +00:00
|
|
|
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
|
2026-04-02 01:25:37 +00:00
|
|
|
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
|
2026-04-01 21:49:22 +00:00
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:40:29 +00:00
|
|
|
func (e *Executor) moduleWaitForConnection(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
|
|
|
|
timeout := getIntArg(args, "timeout", 300)
|
|
|
|
|
delay := getIntArg(args, "delay", 0)
|
|
|
|
|
sleep := getIntArg(args, "sleep", 1)
|
2026-04-03 14:43:54 +00:00
|
|
|
connectTimeout := getIntArg(args, "connect_timeout", 5)
|
2026-04-03 14:40:29 +00:00
|
|
|
timeoutMsg := getStringArg(args, "msg", "wait_for_connection timed out")
|
|
|
|
|
|
|
|
|
|
if delay > 0 {
|
|
|
|
|
timer := time.NewTimer(time.Duration(delay) * time.Second)
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
timer.Stop()
|
|
|
|
|
return nil, ctx.Err()
|
|
|
|
|
case <-timer.C:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
runCheck := func() (*TaskResult, bool) {
|
2026-04-03 14:43:54 +00:00
|
|
|
runCtx := ctx
|
|
|
|
|
if connectTimeout > 0 {
|
|
|
|
|
var cancel context.CancelFunc
|
|
|
|
|
runCtx, cancel = context.WithTimeout(ctx, time.Duration(connectTimeout)*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stdout, stderr, rc, err := client.Run(runCtx, "true")
|
2026-04-03 14:40:29 +00:00
|
|
|
if err == nil && rc == 0 {
|
|
|
|
|
return &TaskResult{Changed: false}, true
|
|
|
|
|
}
|
|
|
|
|
if timeout <= 0 {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: err.Error(), Stdout: stdout, Stderr: stderr, RC: rc}, true
|
|
|
|
|
}
|
|
|
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, Stderr: stderr, RC: rc}, true
|
|
|
|
|
}
|
|
|
|
|
return &TaskResult{Stdout: stdout, Stderr: stderr, RC: rc}, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if timeout <= 0 {
|
|
|
|
|
result, done := runCheck()
|
|
|
|
|
if done {
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
return &TaskResult{Failed: true, Msg: timeoutMsg}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deadline := time.NewTimer(time.Duration(timeout) * time.Second)
|
|
|
|
|
defer deadline.Stop()
|
|
|
|
|
|
|
|
|
|
sleepDuration := time.Duration(sleep) * time.Second
|
|
|
|
|
if sleepDuration < 0 {
|
|
|
|
|
sleepDuration = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
result, done := runCheck()
|
|
|
|
|
if done {
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return nil, ctx.Err()
|
|
|
|
|
case <-deadline.C:
|
|
|
|
|
return &TaskResult{Failed: true, Msg: timeoutMsg, Stdout: result.Stdout, Stderr: result.Stderr, RC: result.RC}, nil
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if sleepDuration > 0 {
|
|
|
|
|
timer := time.NewTimer(sleepDuration)
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
timer.Stop()
|
|
|
|
|
return nil, ctx.Err()
|
|
|
|
|
case <-deadline.C:
|
|
|
|
|
timer.Stop()
|
|
|
|
|
return &TaskResult{Failed: true, Msg: timeoutMsg, Stdout: result.Stdout, Stderr: result.Stderr, RC: result.RC}, nil
|
|
|
|
|
case <-timer.C:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleGit(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
repo := getStringArg(args, "repo", "")
|
|
|
|
|
dest := getStringArg(args, "dest", "")
|
|
|
|
|
version := getStringArg(args, "version", "HEAD")
|
|
|
|
|
|
|
|
|
|
if repo == "" || dest == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleGit", "repo and dest required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if dest exists
|
|
|
|
|
exists, _ := client.FileExists(ctx, dest+"/.git")
|
|
|
|
|
|
|
|
|
|
var cmd string
|
|
|
|
|
if exists {
|
|
|
|
|
// Fetch and checkout (force to ensure clean state)
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("cd %q && git fetch --all && git checkout --force %q", dest, version)
|
2026-03-09 11:37:27 +00:00
|
|
|
} else {
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("git clone %q %q && cd %q && git checkout %q",
|
2026-03-09 11:37:27 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleUnarchive(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
src := getStringArg(args, "src", "")
|
|
|
|
|
dest := getStringArg(args, "dest", "")
|
|
|
|
|
remote := getBoolArg(args, "remote_src", false)
|
|
|
|
|
|
|
|
|
|
if src == "" || dest == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleUnarchive", "src and dest required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create dest directory (best-effort)
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("mkdir -p %q", dest))
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
var cmd string
|
|
|
|
|
if !remote {
|
|
|
|
|
// Upload local file first
|
2026-04-01 22:03:43 +00:00
|
|
|
src = e.resolveLocalPath(src)
|
2026-03-16 19:50:03 +00:00
|
|
|
data, err := coreio.Local.Read(src)
|
2026-03-09 11:37:27 +00:00
|
|
|
if err != nil {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleUnarchive", "read src", err)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-03-26 14:47:37 +00:00
|
|
|
tmpPath := "/tmp/ansible_unarchive_" + pathBase(src)
|
|
|
|
|
err = client.Upload(ctx, newReader(data), tmpPath, 0644)
|
2026-03-09 11:37:27 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
src = tmpPath
|
2026-03-26 14:47:37 +00:00
|
|
|
defer func() { _, _, _, _ = client.Run(ctx, sprintf("rm -f %q", tmpPath)) }()
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Detect archive type and extract
|
2026-03-26 14:47:37 +00:00
|
|
|
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)
|
2026-03-09 11:37:27 +00:00
|
|
|
} else {
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("tar -xf %q -C %q", src, dest) // Guess tar
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleArchive(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-04-01 19:37:33 +00:00
|
|
|
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 == "<nil>" {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:47:30 +00:00
|
|
|
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, "'", `'"'"'`) + "'"
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// --- 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
|
|
|
|
|
}
|
2026-03-26 14:47:37 +00:00
|
|
|
return sprintf("%v", v)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
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:
|
2026-03-26 14:47:37 +00:00
|
|
|
lowered := lower(b)
|
|
|
|
|
return lowered == "true" || lowered == "yes" || lowered == "1"
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return def
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:21:40 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:57:15 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// --- Additional Modules ---
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleHostname(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
name := getStringArg(args, "name", "")
|
2026-04-03 13:04:55 +00:00
|
|
|
if name == "" {
|
|
|
|
|
name = getStringArg(args, "hostname", "")
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
if name == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleHostname", "name required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 10:47:35 +00:00
|
|
|
currentStdout, _, currentRC, currentErr := client.Run(ctx, "hostname")
|
|
|
|
|
if currentErr == nil && currentRC == 0 && corexTrimSpace(currentStdout) == name {
|
|
|
|
|
return &TaskResult{Changed: false, Msg: "hostname already set"}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// Set hostname
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("hostnamectl set-hostname %q || hostname %q", name, name)
|
2026-03-09 11:37:27 +00:00
|
|
|
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)
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("sed -i 's/127.0.1.1.*/127.0.1.1\t%s/' /etc/hosts", name))
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleSysctl(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
name := getStringArg(args, "name", "")
|
|
|
|
|
value := getStringArg(args, "value", "")
|
|
|
|
|
state := getStringArg(args, "state", "present")
|
2026-04-01 21:44:08 +00:00
|
|
|
reload := getBoolArg(args, "reload", false)
|
2026-04-03 13:33:51 +00:00
|
|
|
ignoreErrors := getBoolArg(args, "ignoreerrors", false)
|
2026-04-03 10:22:09 +00:00
|
|
|
sysctlFile := getStringArg(args, "sysctl_file", "/etc/sysctl.conf")
|
|
|
|
|
escapedName := regexp.QuoteMeta(name)
|
2026-04-03 13:33:51 +00:00
|
|
|
sysctlFlags := ""
|
|
|
|
|
if ignoreErrors {
|
|
|
|
|
sysctlFlags = " -e"
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
if name == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleSysctl", "name required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if state == "absent" {
|
2026-04-03 10:22:09 +00:00
|
|
|
// Remove from the configured sysctl file.
|
|
|
|
|
cmd := sprintf("sed -i '/%s/d' %q", escapedName, sysctlFile)
|
2026-04-01 21:44:08 +00:00
|
|
|
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 {
|
2026-04-03 13:33:51 +00:00
|
|
|
stdout, stderr, rc, err = client.Run(ctx, "sysctl"+sysctlFlags+" -p")
|
2026-04-01 21:44:08 +00:00
|
|
|
if err != nil || rc != 0 {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set value
|
2026-04-03 13:33:51 +00:00
|
|
|
cmd := sprintf("sysctl%s -w %s=%s", sysctlFlags, name, value)
|
2026-03-09 11:37:27 +00:00
|
|
|
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) {
|
2026-04-03 10:22:09 +00:00
|
|
|
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)
|
2026-04-01 21:44:08 +00:00
|
|
|
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 {
|
2026-04-03 13:33:51 +00:00
|
|
|
stdout, stderr, rc, err := client.Run(ctx, "sysctl"+sysctlFlags+" -p")
|
2026-04-01 21:44:08 +00:00
|
|
|
if err != nil || rc != 0 {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleCron(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
name := getStringArg(args, "name", "")
|
|
|
|
|
job := getStringArg(args, "job", "")
|
|
|
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
|
user := getStringArg(args, "user", "root")
|
2026-04-02 02:54:18 +00:00
|
|
|
disabled := getBoolArg(args, "disabled", false)
|
2026-04-03 13:51:58 +00:00
|
|
|
specialTime := getStringArg(args, "special_time", "")
|
2026-04-03 14:31:22 +00:00
|
|
|
backup := getBoolArg(args, "backup", false)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
minute := getStringArg(args, "minute", "*")
|
|
|
|
|
hour := getStringArg(args, "hour", "*")
|
|
|
|
|
day := getStringArg(args, "day", "*")
|
|
|
|
|
month := getStringArg(args, "month", "*")
|
|
|
|
|
weekday := getStringArg(args, "weekday", "*")
|
|
|
|
|
|
2026-04-03 14:31:22 +00:00
|
|
|
var backupPath string
|
|
|
|
|
if backup {
|
|
|
|
|
var err error
|
|
|
|
|
backupPath, err = backupCronTab(ctx, client, user, name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
if state == "absent" {
|
|
|
|
|
if name != "" {
|
|
|
|
|
// Remove by name (comment marker)
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("crontab -u %s -l 2>/dev/null | grep -v '# %s' | grep -v '%s' | crontab -u %s -",
|
2026-03-09 11:37:27 +00:00
|
|
|
user, name, job, user)
|
|
|
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
|
|
|
}
|
2026-04-03 14:31:22 +00:00
|
|
|
result := &TaskResult{Changed: true}
|
|
|
|
|
if backupPath != "" {
|
|
|
|
|
result.Data = map[string]any{"backup_file": backupPath}
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build cron entry
|
2026-03-26 14:47:37 +00:00
|
|
|
schedule := sprintf("%s %s %s %s %s", minute, hour, day, month, weekday)
|
2026-04-03 13:51:58 +00:00
|
|
|
if specialTime != "" {
|
|
|
|
|
schedule = "@" + specialTime
|
|
|
|
|
}
|
2026-03-26 14:47:37 +00:00
|
|
|
entry := sprintf("%s %s # %s", schedule, job, name)
|
2026-04-02 02:54:18 +00:00
|
|
|
if disabled {
|
|
|
|
|
entry = "# " + entry
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
// Add to crontab
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("(crontab -u %s -l 2>/dev/null | grep -v '# %s' ; echo %q) | crontab -u %s -",
|
2026-03-09 11:37:27 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:31:22 +00:00
|
|
|
result := &TaskResult{Changed: true}
|
|
|
|
|
if backupPath != "" {
|
|
|
|
|
result.Data = map[string]any{"backup_file": backupPath}
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleBlockinfile(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
path := getStringArg(args, "path", "")
|
|
|
|
|
if path == "" {
|
|
|
|
|
path = getStringArg(args, "dest", "")
|
|
|
|
|
}
|
|
|
|
|
if path == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleBlockinfile", "path required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 11:35:42 +00:00
|
|
|
before, _ := remoteFileText(ctx, client, path)
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
block := getStringArg(args, "block", "")
|
|
|
|
|
marker := getStringArg(args, "marker", "# {mark} ANSIBLE MANAGED BLOCK")
|
|
|
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
|
create := getBoolArg(args, "create", false)
|
2026-04-02 02:50:19 +00:00
|
|
|
backup := getBoolArg(args, "backup", false)
|
2026-04-02 02:47:08 +00:00
|
|
|
prependNewline := getBoolArg(args, "prepend_newline", false)
|
|
|
|
|
appendNewline := getBoolArg(args, "append_newline", false)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-03-26 14:47:37 +00:00
|
|
|
beginMarker := replaceN(marker, "{mark}", "BEGIN", 1)
|
|
|
|
|
endMarker := replaceN(marker, "{mark}", "END", 1)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-02 02:50:19 +00:00
|
|
|
var backupPath string
|
|
|
|
|
if backup {
|
|
|
|
|
var hasBefore bool
|
|
|
|
|
backupPath, hasBefore, _ = backupRemoteFile(ctx, client, path)
|
|
|
|
|
if !hasBefore {
|
|
|
|
|
backupPath = ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
if state == "absent" {
|
|
|
|
|
// Remove block
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("sed -i '/%s/,/%s/d' %q",
|
|
|
|
|
replaceAll(beginMarker, "/", "\\/"),
|
|
|
|
|
replaceAll(endMarker, "/", "\\/"),
|
2026-03-09 11:37:27 +00:00
|
|
|
path)
|
|
|
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create file if needed (best-effort)
|
|
|
|
|
if create {
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("touch %q", path))
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove existing block and add new one
|
2026-03-26 14:47:37 +00:00
|
|
|
escapedBlock := replaceAll(block, "'", "'\\''")
|
2026-04-02 02:47:08 +00:00
|
|
|
blockContent := beginMarker + "\n" + escapedBlock + "\n" + endMarker
|
|
|
|
|
if prependNewline {
|
|
|
|
|
blockContent = "\n" + blockContent
|
|
|
|
|
}
|
|
|
|
|
if appendNewline {
|
|
|
|
|
blockContent += "\n"
|
|
|
|
|
}
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf(`
|
2026-03-09 11:37:27 +00:00
|
|
|
sed -i '/%s/,/%s/d' %q 2>/dev/null || true
|
|
|
|
|
cat >> %q << 'BLOCK_EOF'
|
|
|
|
|
%s
|
|
|
|
|
BLOCK_EOF
|
2026-03-26 14:47:37 +00:00
|
|
|
`, replaceAll(beginMarker, "/", "\\/"),
|
|
|
|
|
replaceAll(endMarker, "/", "\\/"),
|
2026-04-02 02:47:08 +00:00
|
|
|
path, path, blockContent)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
stdout, stderr, rc, err := client.RunScript(ctx, cmd)
|
|
|
|
|
if err != nil || rc != 0 {
|
|
|
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 02:50:19 +00:00
|
|
|
result := &TaskResult{Changed: true}
|
|
|
|
|
if backupPath != "" {
|
|
|
|
|
result.Data = map[string]any{"backup_file": backupPath}
|
|
|
|
|
}
|
2026-04-03 11:35:42 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-02 02:50:19 +00:00
|
|
|
|
|
|
|
|
return result, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Executor) moduleIncludeVars(args map[string]any) (*TaskResult, error) {
|
|
|
|
|
file := getStringArg(args, "file", "")
|
|
|
|
|
if file == "" {
|
|
|
|
|
file = getStringArg(args, "_raw_params", "")
|
|
|
|
|
}
|
2026-04-01 19:05:24 +00:00
|
|
|
dir := getStringArg(args, "dir", "")
|
|
|
|
|
name := getStringArg(args, "name", "")
|
2026-04-01 23:41:20 +00:00
|
|
|
filesMatching := getStringArg(args, "files_matching", "")
|
2026-04-01 23:48:25 +00:00
|
|
|
ignoreFiles := normalizeStringList(args["ignore_files"])
|
2026-04-01 23:45:48 +00:00
|
|
|
extensions := normalizeIncludeVarsExtensions(normalizeStringList(args["extensions"]))
|
2026-04-01 19:05:24 +00:00
|
|
|
hashBehaviour := lower(getStringArg(args, "hash_behaviour", "replace"))
|
2026-04-01 23:27:19 +00:00
|
|
|
depth := getIntArg(args, "depth", 0)
|
2026-04-01 19:05:24 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
if file != "" {
|
2026-04-01 22:03:43 +00:00
|
|
|
file = e.resolveLocalPath(file)
|
2026-04-01 19:05:24 +00:00
|
|
|
sources = append(sources, file)
|
|
|
|
|
if err := loadFile(file); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:05:24 +00:00
|
|
|
if dir != "" {
|
2026-04-01 22:03:43 +00:00
|
|
|
dir = e.resolveLocalPath(dir)
|
2026-04-01 23:48:25 +00:00
|
|
|
files, err := collectIncludeVarsFiles(dir, depth, filesMatching, extensions, ignoreFiles)
|
2026-04-01 19:05:24 +00:00
|
|
|
if err != nil {
|
2026-04-01 23:27:19 +00:00
|
|
|
return nil, err
|
2026-04-01 19:05:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:44:24 +00:00
|
|
|
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
|
2026-04-01 19:05:24 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 23:45:48 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 23:48:25 +00:00
|
|
|
func collectIncludeVarsFiles(dir string, depth int, filesMatching string, extensions []string, ignoreFiles []string) ([]string, error) {
|
2026-04-01 23:27:19 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 23:41:20 +00:00
|
|
|
var matcher *regexp.Regexp
|
|
|
|
|
if filesMatching != "" {
|
|
|
|
|
matcher, err = regexp.Compile(filesMatching)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleIncludeVars", "compile files_matching", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 23:27:19 +00:00
|
|
|
var files []string
|
2026-04-01 23:45:48 +00:00
|
|
|
allowed := make(map[string]bool, len(extensions))
|
|
|
|
|
for _, ext := range extensions {
|
|
|
|
|
allowed[ext] = true
|
|
|
|
|
}
|
2026-04-01 23:48:25 +00:00
|
|
|
ignored := make(map[string]bool, len(ignoreFiles))
|
|
|
|
|
for _, name := range ignoreFiles {
|
|
|
|
|
if name = corexTrimSpace(name); name != "" {
|
|
|
|
|
ignored[name] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-01 23:27:19 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 23:48:25 +00:00
|
|
|
if ignored[entry.Name()] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-04-01 23:27:19 +00:00
|
|
|
ext := lower(filepath.Ext(entry.Name()))
|
2026-04-01 23:45:48 +00:00
|
|
|
if !allowed[ext] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if matcher != nil && !matcher.MatchString(entry.Name()) {
|
|
|
|
|
continue
|
2026-04-01 23:27:19 +00:00
|
|
|
}
|
2026-04-01 23:45:48 +00:00
|
|
|
files = append(files, fullPath)
|
2026-04-01 23:27:19 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sort.Strings(files)
|
|
|
|
|
return files, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:05:24 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *Executor) moduleMeta(args map[string]any) (*TaskResult, error) {
|
|
|
|
|
// meta module controls play execution
|
2026-04-01 19:51:23 +00:00
|
|
|
// 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", "")
|
|
|
|
|
}
|
2026-04-03 12:24:19 +00:00
|
|
|
if action == "" {
|
|
|
|
|
action = getStringArg(args, "action", "")
|
|
|
|
|
}
|
2026-04-01 19:51:23 +00:00
|
|
|
|
2026-04-01 20:03:11 +00:00
|
|
|
result := &TaskResult{Changed: action == "clear_facts"}
|
2026-04-01 19:51:23 +00:00
|
|
|
if action != "" {
|
|
|
|
|
result.Data = map[string]any{"action": action}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result, nil
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:27:53 +00:00
|
|
|
func (e *Executor) moduleSetup(ctx context.Context, host string, client sshFactsRunner, args map[string]any) (*TaskResult, error) {
|
2026-04-03 12:47:29 +00:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:12:44 +00:00
|
|
|
facts, err := e.collectFacts(ctx, client)
|
|
|
|
|
if err != nil {
|
2026-04-03 12:47:29 +00:00
|
|
|
return &TaskResult{Failed: true, Msg: err.Error()}, nil
|
2026-04-01 19:12:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:27:53 +00:00
|
|
|
factMap := factsToMap(facts)
|
2026-04-01 21:35:47 +00:00
|
|
|
factMap = applyGatherSubsetFilter(factMap, normalizeStringList(args["gather_subset"]))
|
2026-04-01 20:27:53 +00:00
|
|
|
filteredFactMap := filterFactsMap(factMap, normalizeStringList(args["filter"]))
|
|
|
|
|
filteredFacts := factsFromMap(filteredFactMap)
|
|
|
|
|
|
2026-04-01 19:12:44 +00:00
|
|
|
e.mu.Lock()
|
2026-04-01 20:27:53 +00:00
|
|
|
e.facts[host] = filteredFacts
|
2026-04-01 19:12:44 +00:00
|
|
|
e.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
return &TaskResult{
|
|
|
|
|
Changed: false,
|
|
|
|
|
Msg: "facts gathered",
|
2026-04-01 20:27:53 +00:00
|
|
|
Data: map[string]any{"ansible_facts": filteredFactMap},
|
2026-04-01 19:12:44 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 21:35:47 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 19:12:44 +00:00
|
|
|
func (e *Executor) collectFacts(ctx context.Context, client sshFactsRunner) (*Facts, error) {
|
|
|
|
|
facts := &Facts{}
|
2026-04-03 12:47:29 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-04-01 19:12:44 +00:00
|
|
|
|
2026-04-03 12:47:29 +00:00
|
|
|
stdout, err := read("hostname -f 2>/dev/null || hostname")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if stdout != "" {
|
2026-04-01 19:12:44 +00:00
|
|
|
facts.FQDN = corexTrimSpace(stdout)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:47:29 +00:00
|
|
|
stdout, err = read("hostname -s 2>/dev/null || hostname")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if stdout != "" {
|
2026-04-01 19:12:44 +00:00
|
|
|
facts.Hostname = corexTrimSpace(stdout)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:47:29 +00:00
|
|
|
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 != "" {
|
2026-04-01 19:12:44 +00:00
|
|
|
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="), "\"'")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:47:29 +00:00
|
|
|
stdout, err = read("uname -m")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if stdout != "" {
|
2026-04-01 19:12:44 +00:00
|
|
|
facts.Architecture = corexTrimSpace(stdout)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:47:29 +00:00
|
|
|
stdout, err = read("uname -r")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if stdout != "" {
|
2026-04-01 19:12:44 +00:00
|
|
|
facts.Kernel = corexTrimSpace(stdout)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:47:29 +00:00
|
|
|
stdout, err = read("nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if stdout != "" {
|
2026-04-01 19:12:44 +00:00
|
|
|
if n, parseErr := strconv.Atoi(corexTrimSpace(stdout)); parseErr == nil {
|
|
|
|
|
facts.CPUs = n
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:47:29 +00:00
|
|
|
stdout, err = read("free -m 2>/dev/null | awk '/^Mem:/ {print $2}'")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if stdout != "" {
|
2026-04-01 19:12:44 +00:00
|
|
|
if n, parseErr := strconv.ParseInt(corexTrimSpace(stdout), 10, 64); parseErr == nil {
|
|
|
|
|
facts.Memory = n
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:47:29 +00:00
|
|
|
stdout, err = read("hostname -I 2>/dev/null | awk '{print $1}'")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if stdout != "" {
|
2026-04-01 19:12:44 +00:00
|
|
|
facts.IPv4 = corexTrimSpace(stdout)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return facts, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:27:53 +00:00
|
|
|
func factsToMap(facts *Facts) map[string]any {
|
2026-04-01 19:12:44 +00:00
|
|
|
if facts == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return map[string]any{
|
2026-04-01 20:27:53 +00:00
|
|
|
"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
|
2026-04-01 19:12:44 +00:00
|
|
|
}
|
2026-04-01 20:27:53 +00:00
|
|
|
|
|
|
|
|
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
|
2026-04-01 19:12:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 ""
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleReboot(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-04-01 22:21:40 +00:00
|
|
|
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")
|
2026-04-03 13:02:11 +00:00
|
|
|
rebootCommand := getStringArg(args, "reboot_command", "")
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
msg := getStringArg(args, "msg", "Reboot initiated by Ansible")
|
2026-04-03 11:49:21 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-03 13:02:11 +00:00
|
|
|
if rebootCommand != "" {
|
|
|
|
|
if preRebootDelay > 0 {
|
|
|
|
|
if result, err := runReboot(sprintf("sleep %d && %s", preRebootDelay, rebootCommand)); err != nil || result != nil {
|
|
|
|
|
return result, err
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if result, err := runReboot(rebootCommand); err != nil || result != nil {
|
|
|
|
|
return result, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if preRebootDelay > 0 {
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd := sprintf("sleep %d && shutdown -r now '%s' &", preRebootDelay, msg)
|
2026-04-03 11:49:21 +00:00
|
|
|
if result, err := runReboot(cmd); err != nil || result != nil {
|
|
|
|
|
return result, err
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
} else {
|
2026-04-03 11:49:21 +00:00
|
|
|
if result, err := runReboot(sprintf("shutdown -r now '%s' &", msg)); err != nil || result != nil {
|
|
|
|
|
return result, err
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:21:40 +00:00
|
|
|
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:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
return &TaskResult{Changed: true, Msg: "Reboot initiated"}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleUFW(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
rule := getStringArg(args, "rule", "")
|
|
|
|
|
port := getStringArg(args, "port", "")
|
|
|
|
|
proto := getStringArg(args, "proto", "tcp")
|
|
|
|
|
state := getStringArg(args, "state", "")
|
2026-04-02 00:12:13 +00:00
|
|
|
logging := getStringArg(args, "logging", "")
|
2026-04-03 12:50:19 +00:00
|
|
|
deleteRule := getBoolArg(args, "delete", false)
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
var cmd string
|
|
|
|
|
|
2026-04-02 00:12:13 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
// 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":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("ufw allow %s/%s", port, proto)
|
2026-03-09 11:37:27 +00:00
|
|
|
case "deny":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("ufw deny %s/%s", port, proto)
|
2026-03-09 11:37:27 +00:00
|
|
|
case "reject":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("ufw reject %s/%s", port, proto)
|
2026-03-09 11:37:27 +00:00
|
|
|
case "limit":
|
2026-03-26 14:47:37 +00:00
|
|
|
cmd = sprintf("ufw limit %s/%s", port, proto)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-04-03 12:50:19 +00:00
|
|
|
if deleteRule && cmd != "" {
|
|
|
|
|
cmd = "ufw delete " + corexTrimPrefix(cmd, "ufw ")
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleAuthorizedKey(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
user := getStringArg(args, "user", "")
|
|
|
|
|
key := getStringArg(args, "key", "")
|
|
|
|
|
state := getStringArg(args, "state", "present")
|
2026-04-01 22:32:56 +00:00
|
|
|
exclusive := getBoolArg(args, "exclusive", false)
|
2026-04-02 00:52:53 +00:00
|
|
|
manageDir := getBoolArg(args, "manage_dir", true)
|
|
|
|
|
pathArg := getStringArg(args, "path", "")
|
2026-04-03 14:11:57 +00:00
|
|
|
keyOptions := getStringArg(args, "key_options", "")
|
|
|
|
|
comment := getStringArg(args, "comment", "")
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
if user == "" || key == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleAuthorizedKey", "user and key required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get user's home directory
|
2026-03-26 14:47:37 +00:00
|
|
|
stdout, _, _, err := client.Run(ctx, sprintf("getent passwd %s | cut -d: -f6", user))
|
2026-03-09 11:37:27 +00:00
|
|
|
if err != nil {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleAuthorizedKey", "get home dir", err)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-03-26 14:47:37 +00:00
|
|
|
home := corexTrimSpace(stdout)
|
2026-03-09 11:37:27 +00:00
|
|
|
if home == "" {
|
|
|
|
|
home = "/root"
|
|
|
|
|
if user != "root" {
|
|
|
|
|
home = "/home/" + user
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:52:53 +00:00
|
|
|
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")
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-03 14:11:57 +00:00
|
|
|
line := authorizedKeyLine(key, keyOptions, comment)
|
|
|
|
|
base := authorizedKeyBase(line)
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
if state == "absent" {
|
2026-04-03 14:11:57 +00:00
|
|
|
content, ok := remoteFileText(ctx, client, authKeysPath)
|
|
|
|
|
if !ok || !authorizedKeyContainsBase(content, base) {
|
2026-04-03 10:51:55 +00:00
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
2026-04-03 14:11:57 +00:00
|
|
|
|
|
|
|
|
updated, changed := rewriteAuthorizedKeyContent(content, base, "")
|
|
|
|
|
if !changed {
|
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
|
|
|
}
|
|
|
|
|
if err := client.Upload(ctx, newReader(updated), authKeysPath, 0600); err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleAuthorizedKey", "upload authorised keys", err)
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:11:57 +00:00
|
|
|
if content, ok := remoteFileText(ctx, client, authKeysPath); ok {
|
|
|
|
|
updated, changed := rewriteAuthorizedKeyContent(content, base, line)
|
|
|
|
|
if !changed {
|
|
|
|
|
return &TaskResult{Changed: false, Msg: sprintf("already up to date: %s", authKeysPath)}, nil
|
|
|
|
|
}
|
|
|
|
|
if err := client.Upload(ctx, newReader(updated), authKeysPath, 0600); err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleAuthorizedKey", "upload authorised keys", err)
|
|
|
|
|
}
|
|
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chmod 600 %q && chown %s:%s %q",
|
|
|
|
|
authKeysPath, user, user, authKeysPath))
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
2026-04-03 10:51:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 00:52:53 +00:00
|
|
|
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)))
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
2026-04-01 22:32:56 +00:00
|
|
|
if exclusive {
|
2026-04-03 14:11:57 +00:00
|
|
|
if err := client.Upload(ctx, newReader(line+"\n"), authKeysPath, 0600); err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleAuthorizedKey", "upload authorised keys", err)
|
2026-04-01 22:32:56 +00:00
|
|
|
}
|
|
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chmod 600 %q && chown %s:%s %q",
|
|
|
|
|
authKeysPath, user, user, authKeysPath))
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:11:57 +00:00
|
|
|
var updated string
|
|
|
|
|
if content, ok := remoteFileText(ctx, client, authKeysPath); ok {
|
|
|
|
|
updated, _ = rewriteAuthorizedKeyContent(content, base, line)
|
|
|
|
|
} else {
|
|
|
|
|
updated = line + "\n"
|
|
|
|
|
}
|
|
|
|
|
if err := client.Upload(ctx, newReader(updated), authKeysPath, 0600); err != nil {
|
|
|
|
|
return nil, coreerr.E("Executor.moduleAuthorizedKey", "upload authorised keys", err)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
2026-03-26 14:47:37 +00:00
|
|
|
_, _, _, _ = client.Run(ctx, sprintf("chmod 600 %q && chown %s:%s %q",
|
2026-03-09 11:37:27 +00:00
|
|
|
authKeysPath, user, user, authKeysPath))
|
|
|
|
|
|
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:11:57 +00:00
|
|
|
func authorizedKeyLine(key, keyOptions, comment string) string {
|
|
|
|
|
key = corexTrimSpace(key)
|
|
|
|
|
keyOptions = corexTrimSpace(keyOptions)
|
|
|
|
|
comment = corexTrimSpace(comment)
|
|
|
|
|
|
|
|
|
|
if keyOptions == "" && comment == "" {
|
|
|
|
|
return key
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
base := authorizedKeyBase(key)
|
|
|
|
|
if base == "" {
|
|
|
|
|
base = key
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parts := make([]string, 0, 3)
|
|
|
|
|
if keyOptions != "" {
|
|
|
|
|
parts = append(parts, keyOptions)
|
|
|
|
|
}
|
|
|
|
|
if base != "" {
|
|
|
|
|
parts = append(parts, base)
|
|
|
|
|
}
|
|
|
|
|
if comment != "" {
|
|
|
|
|
parts = append(parts, comment)
|
|
|
|
|
}
|
|
|
|
|
return join(" ", parts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func authorizedKeyBase(line string) string {
|
|
|
|
|
line = corexTrimSpace(line)
|
|
|
|
|
if line == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fields := strings.Fields(line)
|
|
|
|
|
for i, field := range fields {
|
|
|
|
|
if isAuthorizedKeyType(field) {
|
|
|
|
|
if i+1 >= len(fields) {
|
|
|
|
|
return field
|
|
|
|
|
}
|
|
|
|
|
return field + " " + fields[i+1]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return line
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isAuthorizedKeyType(value string) bool {
|
|
|
|
|
return strings.HasPrefix(value, "ssh-") ||
|
|
|
|
|
strings.HasPrefix(value, "ecdsa-") ||
|
|
|
|
|
strings.HasPrefix(value, "sk-")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func authorizedKeyContainsBase(content, base string) bool {
|
|
|
|
|
if content == "" || base == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, line := range strings.Split(content, "\n") {
|
|
|
|
|
if authorizedKeyBase(line) == base {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:24:33 +00:00
|
|
|
func sedExactLinePattern(value string) string {
|
|
|
|
|
pattern := regexp.QuoteMeta(value)
|
|
|
|
|
return replaceAll(pattern, "|", "\\|")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:11:57 +00:00
|
|
|
func rewriteAuthorizedKeyContent(content, base, line string) (string, bool) {
|
|
|
|
|
if base == "" {
|
|
|
|
|
base = authorizedKeyBase(line)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lines := strings.Split(content, "\n")
|
|
|
|
|
matches := 0
|
|
|
|
|
exactMatches := 0
|
|
|
|
|
for _, current := range lines {
|
|
|
|
|
if current == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if authorizedKeyBase(current) != base {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
matches++
|
|
|
|
|
if current == line {
|
|
|
|
|
exactMatches++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if line != "" && matches == 1 && exactMatches == 1 {
|
|
|
|
|
return content, false
|
|
|
|
|
}
|
|
|
|
|
if line == "" && matches == 0 {
|
|
|
|
|
return content, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
kept := make([]string, 0, len(lines)+1)
|
|
|
|
|
for _, current := range lines {
|
|
|
|
|
if current == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if authorizedKeyBase(current) == base {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
kept = append(kept, current)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if line != "" {
|
|
|
|
|
kept = append(kept, line)
|
|
|
|
|
}
|
|
|
|
|
if len(kept) == 0 {
|
|
|
|
|
return "", true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return join("\n", kept) + "\n", true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:00:45 +00:00
|
|
|
func (e *Executor) moduleDockerCompose(ctx context.Context, client sshExecutorClient, args map[string]any) (*TaskResult, error) {
|
2026-03-09 11:37:27 +00:00
|
|
|
projectSrc := getStringArg(args, "project_src", "")
|
|
|
|
|
state := getStringArg(args, "state", "present")
|
2026-04-03 12:07:24 +00:00
|
|
|
projectName := getStringArg(args, "project_name", "")
|
|
|
|
|
files := normalizeStringArgs(args["files"])
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
if projectSrc == "" {
|
2026-03-16 19:50:03 +00:00
|
|
|
return nil, coreerr.E("Executor.moduleDockerCompose", "project_src required", nil)
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:07:24 +00:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
switch state {
|
|
|
|
|
case "present":
|
2026-04-03 12:07:24 +00:00
|
|
|
cmdParts = append(cmdParts, "up", "-d")
|
2026-03-09 11:37:27 +00:00
|
|
|
case "absent":
|
2026-04-03 12:07:24 +00:00
|
|
|
cmdParts = append(cmdParts, "down")
|
2026-04-02 02:36:45 +00:00
|
|
|
case "stopped":
|
2026-04-03 12:07:24 +00:00
|
|
|
cmdParts = append(cmdParts, "stop")
|
2026-03-09 11:37:27 +00:00
|
|
|
case "restarted":
|
2026-04-03 12:07:24 +00:00
|
|
|
cmdParts = append(cmdParts, "restart")
|
2026-03-09 11:37:27 +00:00
|
|
|
default:
|
2026-04-03 12:07:24 +00:00
|
|
|
cmdParts = append(cmdParts, "up", "-d")
|
2026-03-09 11:37:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 12:07:24 +00:00
|
|
|
cmd := join(" ", cmdParts)
|
|
|
|
|
|
2026-03-09 11:37:27 +00:00
|
|
|
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
|
2026-04-01 20:43:28 +00:00
|
|
|
changed := true
|
|
|
|
|
if contains(stdout, "Up to date") || contains(stderr, "Up to date") {
|
|
|
|
|
changed = false
|
|
|
|
|
}
|
2026-03-09 11:37:27 +00:00
|
|
|
|
|
|
|
|
return &TaskResult{Changed: changed, Stdout: stdout}, nil
|
|
|
|
|
}
|