* ci: consolidate duplicate workflows and merge CodeQL configs Remove 17 duplicate workflow files that were split copies of the combined originals. Each family (CI, CodeQL, Coverage, PR Build, Alpha Release) had the same job duplicated across separate push/pull_request/schedule/manual trigger files. Merge codeql.yml and codescan.yml into a single codeql.yml with a language matrix covering go, javascript-typescript, python, and actions — matching the previous default setup coverage. Remaining workflows (one per family): - ci.yml (push + PR + manual) - codeql.yml (push + PR + schedule, all languages) - coverage.yml (push + PR + manual) - alpha-release.yml (push + manual) - pr-build.yml (PR + manual) - release.yml (tag push) - agent-verify.yml, auto-label.yml, auto-project.yml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add collect, config, crypt, plugin packages and fix all lint issues Add four new infrastructure packages with CLI commands: - pkg/config: layered configuration (defaults → file → env → flags) - pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums) - pkg/plugin: plugin system with GitHub-based install/update/remove - pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate) Fix all golangci-lint issues across the entire codebase (~100 errcheck, staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that `core go qa` passes with 0 issues. Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1434 lines
40 KiB
Go
1434 lines
40 KiB
Go
package ansible
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// executeModule dispatches to the appropriate module handler.
|
|
func (e *Executor) executeModule(ctx context.Context, host string, client *SSHClient, task *Task, play *Play) (*TaskResult, error) {
|
|
module := NormalizeModule(task.Module)
|
|
|
|
// Apply task-level become
|
|
if task.Become != nil && *task.Become {
|
|
// Save old state to restore
|
|
oldBecome := client.become
|
|
oldUser := client.becomeUser
|
|
oldPass := client.becomePass
|
|
|
|
client.SetBecome(true, task.BecomeUser, "")
|
|
|
|
defer client.SetBecome(oldBecome, oldUser, oldPass)
|
|
}
|
|
|
|
// Template the args
|
|
args := e.templateArgs(task.Args, host, task)
|
|
|
|
switch module {
|
|
// Command execution
|
|
case "ansible.builtin.shell":
|
|
return e.moduleShell(ctx, client, args)
|
|
case "ansible.builtin.command":
|
|
return e.moduleCommand(ctx, client, args)
|
|
case "ansible.builtin.raw":
|
|
return e.moduleRaw(ctx, client, args)
|
|
case "ansible.builtin.script":
|
|
return e.moduleScript(ctx, client, args)
|
|
|
|
// File operations
|
|
case "ansible.builtin.copy":
|
|
return e.moduleCopy(ctx, client, args, host, task)
|
|
case "ansible.builtin.template":
|
|
return e.moduleTemplate(ctx, client, args, host, task)
|
|
case "ansible.builtin.file":
|
|
return e.moduleFile(ctx, client, args)
|
|
case "ansible.builtin.lineinfile":
|
|
return e.moduleLineinfile(ctx, client, args)
|
|
case "ansible.builtin.stat":
|
|
return e.moduleStat(ctx, client, args)
|
|
case "ansible.builtin.slurp":
|
|
return e.moduleSlurp(ctx, client, args)
|
|
case "ansible.builtin.fetch":
|
|
return e.moduleFetch(ctx, client, args)
|
|
case "ansible.builtin.get_url":
|
|
return e.moduleGetURL(ctx, client, args)
|
|
|
|
// Package management
|
|
case "ansible.builtin.apt":
|
|
return e.moduleApt(ctx, client, args)
|
|
case "ansible.builtin.apt_key":
|
|
return e.moduleAptKey(ctx, client, args)
|
|
case "ansible.builtin.apt_repository":
|
|
return e.moduleAptRepository(ctx, client, args)
|
|
case "ansible.builtin.package":
|
|
return e.modulePackage(ctx, client, args)
|
|
case "ansible.builtin.pip":
|
|
return e.modulePip(ctx, client, args)
|
|
|
|
// Service management
|
|
case "ansible.builtin.service":
|
|
return e.moduleService(ctx, client, args)
|
|
case "ansible.builtin.systemd":
|
|
return e.moduleSystemd(ctx, client, args)
|
|
|
|
// User/Group
|
|
case "ansible.builtin.user":
|
|
return e.moduleUser(ctx, client, args)
|
|
case "ansible.builtin.group":
|
|
return e.moduleGroup(ctx, client, args)
|
|
|
|
// HTTP
|
|
case "ansible.builtin.uri":
|
|
return e.moduleURI(ctx, client, args)
|
|
|
|
// Misc
|
|
case "ansible.builtin.debug":
|
|
return e.moduleDebug(args)
|
|
case "ansible.builtin.fail":
|
|
return e.moduleFail(args)
|
|
case "ansible.builtin.assert":
|
|
return e.moduleAssert(args, host)
|
|
case "ansible.builtin.set_fact":
|
|
return e.moduleSetFact(args)
|
|
case "ansible.builtin.pause":
|
|
return e.modulePause(ctx, args)
|
|
case "ansible.builtin.wait_for":
|
|
return e.moduleWaitFor(ctx, client, args)
|
|
case "ansible.builtin.git":
|
|
return e.moduleGit(ctx, client, args)
|
|
case "ansible.builtin.unarchive":
|
|
return e.moduleUnarchive(ctx, client, args)
|
|
|
|
// Additional modules
|
|
case "ansible.builtin.hostname":
|
|
return e.moduleHostname(ctx, client, args)
|
|
case "ansible.builtin.sysctl":
|
|
return e.moduleSysctl(ctx, client, args)
|
|
case "ansible.builtin.cron":
|
|
return e.moduleCron(ctx, client, args)
|
|
case "ansible.builtin.blockinfile":
|
|
return e.moduleBlockinfile(ctx, client, args)
|
|
case "ansible.builtin.include_vars":
|
|
return e.moduleIncludeVars(args)
|
|
case "ansible.builtin.meta":
|
|
return e.moduleMeta(args)
|
|
case "ansible.builtin.setup":
|
|
return e.moduleSetup(ctx, client)
|
|
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)
|
|
|
|
default:
|
|
// For unknown modules, try to execute as shell if it looks like a command
|
|
if strings.Contains(task.Module, " ") || task.Module == "" {
|
|
return e.moduleShell(ctx, client, args)
|
|
}
|
|
return nil, fmt.Errorf("unsupported module: %s", module)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
e.vars["inventory_hostname"] = host
|
|
|
|
result := make(map[string]any)
|
|
for k, v := range args {
|
|
switch val := v.(type) {
|
|
case string:
|
|
result[k] = e.templateString(val, host, task)
|
|
case map[string]any:
|
|
// Recurse for nested maps
|
|
result[k] = e.templateArgs(val, host, task)
|
|
case []any:
|
|
// Template strings in arrays
|
|
templated := make([]any, len(val))
|
|
for i, item := range val {
|
|
if s, ok := item.(string); ok {
|
|
templated[i] = e.templateString(s, host, task)
|
|
} else {
|
|
templated[i] = item
|
|
}
|
|
}
|
|
result[k] = templated
|
|
default:
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- Command Modules ---
|
|
|
|
func (e *Executor) moduleShell(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
cmd := getStringArg(args, "_raw_params", "")
|
|
if cmd == "" {
|
|
cmd = getStringArg(args, "cmd", "")
|
|
}
|
|
if cmd == "" {
|
|
return nil, fmt.Errorf("shell: no command specified")
|
|
}
|
|
|
|
// Handle chdir
|
|
if chdir := getStringArg(args, "chdir", ""); chdir != "" {
|
|
cmd = fmt.Sprintf("cd %q && %s", chdir, cmd)
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.RunScript(ctx, cmd)
|
|
if err != nil {
|
|
return &TaskResult{Failed: true, Msg: err.Error(), Stdout: stdout, Stderr: stderr, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{
|
|
Changed: true,
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
RC: rc,
|
|
Failed: rc != 0,
|
|
}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleCommand(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
cmd := getStringArg(args, "_raw_params", "")
|
|
if cmd == "" {
|
|
cmd = getStringArg(args, "cmd", "")
|
|
}
|
|
if cmd == "" {
|
|
return nil, fmt.Errorf("command: no command specified")
|
|
}
|
|
|
|
// Handle chdir
|
|
if chdir := getStringArg(args, "chdir", ""); chdir != "" {
|
|
cmd = fmt.Sprintf("cd %q && %s", chdir, cmd)
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil {
|
|
return &TaskResult{Failed: true, Msg: err.Error()}, nil
|
|
}
|
|
|
|
return &TaskResult{
|
|
Changed: true,
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
RC: rc,
|
|
Failed: rc != 0,
|
|
}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleRaw(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
cmd := getStringArg(args, "_raw_params", "")
|
|
if cmd == "" {
|
|
return nil, fmt.Errorf("raw: no command specified")
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil {
|
|
return &TaskResult{Failed: true, Msg: err.Error()}, nil
|
|
}
|
|
|
|
return &TaskResult{
|
|
Changed: true,
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
RC: rc,
|
|
}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleScript(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
script := getStringArg(args, "_raw_params", "")
|
|
if script == "" {
|
|
return nil, fmt.Errorf("script: no script specified")
|
|
}
|
|
|
|
// Read local script
|
|
content, err := os.ReadFile(script)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read script: %w", err)
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.RunScript(ctx, string(content))
|
|
if err != nil {
|
|
return &TaskResult{Failed: true, Msg: err.Error()}, nil
|
|
}
|
|
|
|
return &TaskResult{
|
|
Changed: true,
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
RC: rc,
|
|
Failed: rc != 0,
|
|
}, nil
|
|
}
|
|
|
|
// --- File Modules ---
|
|
|
|
func (e *Executor) moduleCopy(ctx context.Context, client *SSHClient, args map[string]any, host string, task *Task) (*TaskResult, error) {
|
|
dest := getStringArg(args, "dest", "")
|
|
if dest == "" {
|
|
return nil, fmt.Errorf("copy: dest required")
|
|
}
|
|
|
|
var content []byte
|
|
var err error
|
|
|
|
if src := getStringArg(args, "src", ""); src != "" {
|
|
content, err = os.ReadFile(src)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read src: %w", err)
|
|
}
|
|
} else if c := getStringArg(args, "content", ""); c != "" {
|
|
content = []byte(c)
|
|
} else {
|
|
return nil, fmt.Errorf("copy: src or content required")
|
|
}
|
|
|
|
mode := os.FileMode(0644)
|
|
if m := getStringArg(args, "mode", ""); m != "" {
|
|
if parsed, err := strconv.ParseInt(m, 8, 32); err == nil {
|
|
mode = os.FileMode(parsed)
|
|
}
|
|
}
|
|
|
|
err = client.Upload(ctx, strings.NewReader(string(content)), dest, mode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Handle owner/group (best-effort, errors ignored)
|
|
if owner := getStringArg(args, "owner", ""); owner != "" {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chown %s %q", owner, dest))
|
|
}
|
|
if group := getStringArg(args, "group", ""); group != "" {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chgrp %s %q", group, dest))
|
|
}
|
|
|
|
return &TaskResult{Changed: true, Msg: fmt.Sprintf("copied to %s", dest)}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleTemplate(ctx context.Context, client *SSHClient, args map[string]any, host string, task *Task) (*TaskResult, error) {
|
|
src := getStringArg(args, "src", "")
|
|
dest := getStringArg(args, "dest", "")
|
|
if src == "" || dest == "" {
|
|
return nil, fmt.Errorf("template: src and dest required")
|
|
}
|
|
|
|
// Process template
|
|
content, err := e.TemplateFile(src, host, task)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("template: %w", err)
|
|
}
|
|
|
|
mode := os.FileMode(0644)
|
|
if m := getStringArg(args, "mode", ""); m != "" {
|
|
if parsed, err := strconv.ParseInt(m, 8, 32); err == nil {
|
|
mode = os.FileMode(parsed)
|
|
}
|
|
}
|
|
|
|
err = client.Upload(ctx, strings.NewReader(content), dest, mode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &TaskResult{Changed: true, Msg: fmt.Sprintf("templated to %s", dest)}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleFile(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
path := getStringArg(args, "path", "")
|
|
if path == "" {
|
|
path = getStringArg(args, "dest", "")
|
|
}
|
|
if path == "" {
|
|
return nil, fmt.Errorf("file: path required")
|
|
}
|
|
|
|
state := getStringArg(args, "state", "file")
|
|
|
|
switch state {
|
|
case "directory":
|
|
mode := getStringArg(args, "mode", "0755")
|
|
cmd := fmt.Sprintf("mkdir -p %q && chmod %s %q", path, mode, path)
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
case "absent":
|
|
cmd := fmt.Sprintf("rm -rf %q", path)
|
|
_, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil
|
|
}
|
|
|
|
case "touch":
|
|
cmd := fmt.Sprintf("touch %q", path)
|
|
_, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil
|
|
}
|
|
|
|
case "link":
|
|
src := getStringArg(args, "src", "")
|
|
if src == "" {
|
|
return nil, fmt.Errorf("file: src required for link state")
|
|
}
|
|
cmd := fmt.Sprintf("ln -sf %q %q", src, path)
|
|
_, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil
|
|
}
|
|
|
|
case "file":
|
|
// Ensure file exists and set permissions
|
|
if mode := getStringArg(args, "mode", ""); mode != "" {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chmod %s %q", mode, path))
|
|
}
|
|
}
|
|
|
|
// Handle owner/group (best-effort, errors ignored)
|
|
if owner := getStringArg(args, "owner", ""); owner != "" {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chown %s %q", owner, path))
|
|
}
|
|
if group := getStringArg(args, "group", ""); group != "" {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chgrp %s %q", group, path))
|
|
}
|
|
if recurse := getBoolArg(args, "recurse", false); recurse {
|
|
if owner := getStringArg(args, "owner", ""); owner != "" {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chown -R %s %q", owner, path))
|
|
}
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleLineinfile(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
path := getStringArg(args, "path", "")
|
|
if path == "" {
|
|
path = getStringArg(args, "dest", "")
|
|
}
|
|
if path == "" {
|
|
return nil, fmt.Errorf("lineinfile: path required")
|
|
}
|
|
|
|
line := getStringArg(args, "line", "")
|
|
regexp := getStringArg(args, "regexp", "")
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
if state == "absent" {
|
|
if regexp != "" {
|
|
cmd := fmt.Sprintf("sed -i '/%s/d' %q", regexp, path)
|
|
_, stderr, rc, _ := client.Run(ctx, cmd)
|
|
if rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil
|
|
}
|
|
}
|
|
} else {
|
|
// state == present
|
|
if regexp != "" {
|
|
// Replace line matching regexp
|
|
escapedLine := strings.ReplaceAll(line, "/", "\\/")
|
|
cmd := fmt.Sprintf("sed -i 's/%s/%s/' %q", regexp, escapedLine, path)
|
|
_, _, rc, _ := client.Run(ctx, cmd)
|
|
if rc != 0 {
|
|
// Line not found, append
|
|
cmd = fmt.Sprintf("echo %q >> %q", line, path)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
}
|
|
} else if line != "" {
|
|
// Ensure line is present
|
|
cmd := fmt.Sprintf("grep -qxF %q %q || echo %q >> %q", line, path, line, path)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
}
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleStat(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
path := getStringArg(args, "path", "")
|
|
if path == "" {
|
|
return nil, fmt.Errorf("stat: path required")
|
|
}
|
|
|
|
stat, err := client.Stat(ctx, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &TaskResult{
|
|
Changed: false,
|
|
Data: map[string]any{"stat": stat},
|
|
}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleSlurp(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
path := getStringArg(args, "path", "")
|
|
if path == "" {
|
|
path = getStringArg(args, "src", "")
|
|
}
|
|
if path == "" {
|
|
return nil, fmt.Errorf("slurp: path required")
|
|
}
|
|
|
|
content, err := client.Download(ctx, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
encoded := base64.StdEncoding.EncodeToString(content)
|
|
|
|
return &TaskResult{
|
|
Changed: false,
|
|
Data: map[string]any{"content": encoded, "encoding": "base64"},
|
|
}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleFetch(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
src := getStringArg(args, "src", "")
|
|
dest := getStringArg(args, "dest", "")
|
|
if src == "" || dest == "" {
|
|
return nil, fmt.Errorf("fetch: src and dest required")
|
|
}
|
|
|
|
content, err := client.Download(ctx, src)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create dest directory
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := os.WriteFile(dest, content, 0644); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &TaskResult{Changed: true, Msg: fmt.Sprintf("fetched %s to %s", src, dest)}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleGetURL(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
url := getStringArg(args, "url", "")
|
|
dest := getStringArg(args, "dest", "")
|
|
if url == "" || dest == "" {
|
|
return nil, fmt.Errorf("get_url: url and dest required")
|
|
}
|
|
|
|
// Use curl or wget
|
|
cmd := fmt.Sprintf("curl -fsSL -o %q %q || wget -q -O %q %q", dest, url, dest, url)
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
// Set mode if specified (best-effort)
|
|
if mode := getStringArg(args, "mode", ""); mode != "" {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chmod %s %q", mode, dest))
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
// --- Package Modules ---
|
|
|
|
func (e *Executor) moduleApt(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
name := getStringArg(args, "name", "")
|
|
state := getStringArg(args, "state", "present")
|
|
updateCache := getBoolArg(args, "update_cache", false)
|
|
|
|
var cmd string
|
|
|
|
if updateCache {
|
|
_, _, _, _ = client.Run(ctx, "apt-get update -qq")
|
|
}
|
|
|
|
switch state {
|
|
case "present", "installed":
|
|
if name != "" {
|
|
cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s", name)
|
|
}
|
|
case "absent", "removed":
|
|
cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq %s", name)
|
|
case "latest":
|
|
cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade %s", name)
|
|
}
|
|
|
|
if cmd == "" {
|
|
return &TaskResult{Changed: false}, nil
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleAptKey(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
url := getStringArg(args, "url", "")
|
|
keyring := getStringArg(args, "keyring", "")
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
if state == "absent" {
|
|
if keyring != "" {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("rm -f %q", keyring))
|
|
}
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
if url == "" {
|
|
return nil, fmt.Errorf("apt_key: url required")
|
|
}
|
|
|
|
var cmd string
|
|
if keyring != "" {
|
|
cmd = fmt.Sprintf("curl -fsSL %q | gpg --dearmor -o %q", url, keyring)
|
|
} else {
|
|
cmd = fmt.Sprintf("curl -fsSL %q | apt-key add -", url)
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleAptRepository(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
repo := getStringArg(args, "repo", "")
|
|
filename := getStringArg(args, "filename", "")
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
if repo == "" {
|
|
return nil, fmt.Errorf("apt_repository: repo required")
|
|
}
|
|
|
|
if filename == "" {
|
|
// Generate filename from repo
|
|
filename = strings.ReplaceAll(repo, " ", "-")
|
|
filename = strings.ReplaceAll(filename, "/", "-")
|
|
filename = strings.ReplaceAll(filename, ":", "")
|
|
}
|
|
|
|
path := fmt.Sprintf("/etc/apt/sources.list.d/%s.list", filename)
|
|
|
|
if state == "absent" {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("rm -f %q", path))
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
cmd := fmt.Sprintf("echo %q > %q", repo, path)
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
// Update apt cache (best-effort)
|
|
if getBoolArg(args, "update_cache", true) {
|
|
_, _, _, _ = client.Run(ctx, "apt-get update -qq")
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) modulePackage(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
// Detect package manager and delegate
|
|
stdout, _, _, _ := client.Run(ctx, "which apt-get yum dnf 2>/dev/null | head -1")
|
|
stdout = strings.TrimSpace(stdout)
|
|
|
|
if strings.Contains(stdout, "apt") {
|
|
return e.moduleApt(ctx, client, args)
|
|
}
|
|
|
|
// Default to apt
|
|
return e.moduleApt(ctx, client, args)
|
|
}
|
|
|
|
func (e *Executor) modulePip(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
name := getStringArg(args, "name", "")
|
|
state := getStringArg(args, "state", "present")
|
|
executable := getStringArg(args, "executable", "pip3")
|
|
|
|
var cmd string
|
|
switch state {
|
|
case "present", "installed":
|
|
cmd = fmt.Sprintf("%s install %s", executable, name)
|
|
case "absent", "removed":
|
|
cmd = fmt.Sprintf("%s uninstall -y %s", executable, name)
|
|
case "latest":
|
|
cmd = fmt.Sprintf("%s install --upgrade %s", executable, name)
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
// --- Service Modules ---
|
|
|
|
func (e *Executor) moduleService(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
name := getStringArg(args, "name", "")
|
|
state := getStringArg(args, "state", "")
|
|
enabled := args["enabled"]
|
|
|
|
if name == "" {
|
|
return nil, fmt.Errorf("service: name required")
|
|
}
|
|
|
|
var cmds []string
|
|
|
|
if state != "" {
|
|
switch state {
|
|
case "started":
|
|
cmds = append(cmds, fmt.Sprintf("systemctl start %s", name))
|
|
case "stopped":
|
|
cmds = append(cmds, fmt.Sprintf("systemctl stop %s", name))
|
|
case "restarted":
|
|
cmds = append(cmds, fmt.Sprintf("systemctl restart %s", name))
|
|
case "reloaded":
|
|
cmds = append(cmds, fmt.Sprintf("systemctl reload %s", name))
|
|
}
|
|
}
|
|
|
|
if enabled != nil {
|
|
if getBoolArg(args, "enabled", false) {
|
|
cmds = append(cmds, fmt.Sprintf("systemctl enable %s", name))
|
|
} else {
|
|
cmds = append(cmds, fmt.Sprintf("systemctl disable %s", name))
|
|
}
|
|
}
|
|
|
|
for _, cmd := range cmds {
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
}
|
|
|
|
return &TaskResult{Changed: len(cmds) > 0}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleSystemd(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
// systemd is similar to service
|
|
if getBoolArg(args, "daemon_reload", false) {
|
|
_, _, _, _ = client.Run(ctx, "systemctl daemon-reload")
|
|
}
|
|
|
|
return e.moduleService(ctx, client, args)
|
|
}
|
|
|
|
// --- User/Group Modules ---
|
|
|
|
func (e *Executor) moduleUser(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
name := getStringArg(args, "name", "")
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
if name == "" {
|
|
return nil, fmt.Errorf("user: name required")
|
|
}
|
|
|
|
if state == "absent" {
|
|
cmd := fmt.Sprintf("userdel -r %s 2>/dev/null || true", name)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
// Build useradd/usermod command
|
|
var opts []string
|
|
|
|
if uid := getStringArg(args, "uid", ""); uid != "" {
|
|
opts = append(opts, "-u", uid)
|
|
}
|
|
if group := getStringArg(args, "group", ""); group != "" {
|
|
opts = append(opts, "-g", group)
|
|
}
|
|
if groups := getStringArg(args, "groups", ""); groups != "" {
|
|
opts = append(opts, "-G", groups)
|
|
}
|
|
if home := getStringArg(args, "home", ""); home != "" {
|
|
opts = append(opts, "-d", home)
|
|
}
|
|
if shell := getStringArg(args, "shell", ""); shell != "" {
|
|
opts = append(opts, "-s", shell)
|
|
}
|
|
if getBoolArg(args, "system", false) {
|
|
opts = append(opts, "-r")
|
|
}
|
|
if getBoolArg(args, "create_home", true) {
|
|
opts = append(opts, "-m")
|
|
}
|
|
|
|
// Try usermod first, then useradd
|
|
optsStr := strings.Join(opts, " ")
|
|
var cmd string
|
|
if optsStr == "" {
|
|
cmd = fmt.Sprintf("id %s >/dev/null 2>&1 || useradd %s", name, name)
|
|
} else {
|
|
cmd = fmt.Sprintf("id %s >/dev/null 2>&1 && usermod %s %s || useradd %s %s",
|
|
name, optsStr, name, optsStr, name)
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleGroup(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
name := getStringArg(args, "name", "")
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
if name == "" {
|
|
return nil, fmt.Errorf("group: name required")
|
|
}
|
|
|
|
if state == "absent" {
|
|
cmd := fmt.Sprintf("groupdel %s 2>/dev/null || true", name)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
var opts []string
|
|
if gid := getStringArg(args, "gid", ""); gid != "" {
|
|
opts = append(opts, "-g", gid)
|
|
}
|
|
if getBoolArg(args, "system", false) {
|
|
opts = append(opts, "-r")
|
|
}
|
|
|
|
cmd := fmt.Sprintf("getent group %s >/dev/null 2>&1 || groupadd %s %s",
|
|
name, strings.Join(opts, " "), name)
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
// --- HTTP Module ---
|
|
|
|
func (e *Executor) moduleURI(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
url := getStringArg(args, "url", "")
|
|
method := getStringArg(args, "method", "GET")
|
|
|
|
if url == "" {
|
|
return nil, fmt.Errorf("uri: url required")
|
|
}
|
|
|
|
var curlOpts []string
|
|
curlOpts = append(curlOpts, "-s", "-S")
|
|
curlOpts = append(curlOpts, "-X", method)
|
|
|
|
// Headers
|
|
if headers, ok := args["headers"].(map[string]any); ok {
|
|
for k, v := range headers {
|
|
curlOpts = append(curlOpts, "-H", fmt.Sprintf("%s: %v", k, v))
|
|
}
|
|
}
|
|
|
|
// Body
|
|
if body := getStringArg(args, "body", ""); body != "" {
|
|
curlOpts = append(curlOpts, "-d", body)
|
|
}
|
|
|
|
// Status code
|
|
curlOpts = append(curlOpts, "-w", "\\n%{http_code}")
|
|
|
|
cmd := fmt.Sprintf("curl %s %q", strings.Join(curlOpts, " "), url)
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil {
|
|
return &TaskResult{Failed: true, Msg: err.Error()}, nil
|
|
}
|
|
|
|
// Parse status code from last line
|
|
lines := strings.Split(strings.TrimSpace(stdout), "\n")
|
|
statusCode := 0
|
|
if len(lines) > 0 {
|
|
statusCode, _ = strconv.Atoi(lines[len(lines)-1])
|
|
}
|
|
|
|
// Check expected status
|
|
expectedStatus := 200
|
|
if s, ok := args["status_code"].(int); ok {
|
|
expectedStatus = s
|
|
}
|
|
|
|
failed := rc != 0 || statusCode != expectedStatus
|
|
|
|
return &TaskResult{
|
|
Changed: false,
|
|
Failed: failed,
|
|
Stdout: stdout,
|
|
Stderr: stderr,
|
|
RC: statusCode,
|
|
Data: map[string]any{"status": statusCode},
|
|
}, nil
|
|
}
|
|
|
|
// --- Misc Modules ---
|
|
|
|
func (e *Executor) moduleDebug(args map[string]any) (*TaskResult, error) {
|
|
msg := getStringArg(args, "msg", "")
|
|
if v, ok := args["var"]; ok {
|
|
msg = fmt.Sprintf("%v = %v", v, e.vars[fmt.Sprintf("%v", v)])
|
|
}
|
|
|
|
return &TaskResult{
|
|
Changed: false,
|
|
Msg: msg,
|
|
}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleFail(args map[string]any) (*TaskResult, error) {
|
|
msg := getStringArg(args, "msg", "Failed as requested")
|
|
return &TaskResult{
|
|
Failed: true,
|
|
Msg: msg,
|
|
}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleAssert(args map[string]any, host string) (*TaskResult, error) {
|
|
that, ok := args["that"]
|
|
if !ok {
|
|
return nil, fmt.Errorf("assert: 'that' required")
|
|
}
|
|
|
|
conditions := normalizeConditions(that)
|
|
for _, cond := range conditions {
|
|
if !e.evalCondition(cond, host) {
|
|
msg := getStringArg(args, "fail_msg", fmt.Sprintf("Assertion failed: %s", cond))
|
|
return &TaskResult{Failed: true, Msg: msg}, nil
|
|
}
|
|
}
|
|
|
|
return &TaskResult{Changed: false, Msg: "All assertions passed"}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleSetFact(args map[string]any) (*TaskResult, error) {
|
|
for k, v := range args {
|
|
if k != "cacheable" {
|
|
e.vars[k] = v
|
|
}
|
|
}
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) modulePause(ctx context.Context, args map[string]any) (*TaskResult, error) {
|
|
seconds := 0
|
|
if s, ok := args["seconds"].(int); ok {
|
|
seconds = s
|
|
}
|
|
if s, ok := args["seconds"].(string); ok {
|
|
seconds, _ = strconv.Atoi(s)
|
|
}
|
|
|
|
if seconds > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-ctxSleep(ctx, seconds):
|
|
}
|
|
}
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
}
|
|
|
|
func ctxSleep(ctx context.Context, seconds int) <-chan struct{} {
|
|
ch := make(chan struct{})
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-sleepChan(seconds):
|
|
}
|
|
close(ch)
|
|
}()
|
|
return ch
|
|
}
|
|
|
|
func sleepChan(seconds int) <-chan struct{} {
|
|
ch := make(chan struct{})
|
|
go func() {
|
|
for i := 0; i < seconds; i++ {
|
|
select {
|
|
case <-ch:
|
|
return
|
|
default:
|
|
// Sleep 1 second at a time
|
|
}
|
|
}
|
|
close(ch)
|
|
}()
|
|
return ch
|
|
}
|
|
|
|
func (e *Executor) moduleWaitFor(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
port := 0
|
|
if p, ok := args["port"].(int); ok {
|
|
port = p
|
|
}
|
|
host := getStringArg(args, "host", "127.0.0.1")
|
|
state := getStringArg(args, "state", "started")
|
|
timeout := 300
|
|
if t, ok := args["timeout"].(int); ok {
|
|
timeout = t
|
|
}
|
|
|
|
if port > 0 && state == "started" {
|
|
cmd := fmt.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
|
|
}
|
|
|
|
func (e *Executor) moduleGit(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
repo := getStringArg(args, "repo", "")
|
|
dest := getStringArg(args, "dest", "")
|
|
version := getStringArg(args, "version", "HEAD")
|
|
|
|
if repo == "" || dest == "" {
|
|
return nil, fmt.Errorf("git: repo and dest required")
|
|
}
|
|
|
|
// Check if dest exists
|
|
exists, _ := client.FileExists(ctx, dest+"/.git")
|
|
|
|
var cmd string
|
|
if exists {
|
|
// Fetch and checkout (force to ensure clean state)
|
|
cmd = fmt.Sprintf("cd %q && git fetch --all && git checkout --force %q", dest, version)
|
|
} else {
|
|
cmd = fmt.Sprintf("git clone %q %q && cd %q && git checkout %q",
|
|
repo, dest, dest, version)
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleUnarchive(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
src := getStringArg(args, "src", "")
|
|
dest := getStringArg(args, "dest", "")
|
|
remote := getBoolArg(args, "remote_src", false)
|
|
|
|
if src == "" || dest == "" {
|
|
return nil, fmt.Errorf("unarchive: src and dest required")
|
|
}
|
|
|
|
// Create dest directory (best-effort)
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("mkdir -p %q", dest))
|
|
|
|
var cmd string
|
|
if !remote {
|
|
// Upload local file first
|
|
content, err := os.ReadFile(src)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read src: %w", err)
|
|
}
|
|
tmpPath := "/tmp/ansible_unarchive_" + filepath.Base(src)
|
|
err = client.Upload(ctx, strings.NewReader(string(content)), tmpPath, 0644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
src = tmpPath
|
|
defer func() { _, _, _, _ = client.Run(ctx, fmt.Sprintf("rm -f %q", tmpPath)) }()
|
|
}
|
|
|
|
// Detect archive type and extract
|
|
if strings.HasSuffix(src, ".tar.gz") || strings.HasSuffix(src, ".tgz") {
|
|
cmd = fmt.Sprintf("tar -xzf %q -C %q", src, dest)
|
|
} else if strings.HasSuffix(src, ".tar.xz") {
|
|
cmd = fmt.Sprintf("tar -xJf %q -C %q", src, dest)
|
|
} else if strings.HasSuffix(src, ".tar.bz2") {
|
|
cmd = fmt.Sprintf("tar -xjf %q -C %q", src, dest)
|
|
} else if strings.HasSuffix(src, ".tar") {
|
|
cmd = fmt.Sprintf("tar -xf %q -C %q", src, dest)
|
|
} else if strings.HasSuffix(src, ".zip") {
|
|
cmd = fmt.Sprintf("unzip -o %q -d %q", src, dest)
|
|
} else {
|
|
cmd = fmt.Sprintf("tar -xf %q -C %q", src, dest) // Guess tar
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func getStringArg(args map[string]any, key, def string) string {
|
|
if v, ok := args[key]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
return def
|
|
}
|
|
|
|
func getBoolArg(args map[string]any, key string, def bool) bool {
|
|
if v, ok := args[key]; ok {
|
|
switch b := v.(type) {
|
|
case bool:
|
|
return b
|
|
case string:
|
|
lower := strings.ToLower(b)
|
|
return lower == "true" || lower == "yes" || lower == "1"
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
// --- Additional Modules ---
|
|
|
|
func (e *Executor) moduleHostname(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
name := getStringArg(args, "name", "")
|
|
if name == "" {
|
|
return nil, fmt.Errorf("hostname: name required")
|
|
}
|
|
|
|
// Set hostname
|
|
cmd := fmt.Sprintf("hostnamectl set-hostname %q || hostname %q", name, name)
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
// Update /etc/hosts if needed (best-effort)
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("sed -i 's/127.0.1.1.*/127.0.1.1\t%s/' /etc/hosts", name))
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleSysctl(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
name := getStringArg(args, "name", "")
|
|
value := getStringArg(args, "value", "")
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
if name == "" {
|
|
return nil, fmt.Errorf("sysctl: name required")
|
|
}
|
|
|
|
if state == "absent" {
|
|
// Remove from sysctl.conf
|
|
cmd := fmt.Sprintf("sed -i '/%s/d' /etc/sysctl.conf", name)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
// Set value
|
|
cmd := fmt.Sprintf("sysctl -w %s=%s", name, value)
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
// Persist if requested (best-effort)
|
|
if getBoolArg(args, "sysctl_set", true) {
|
|
cmd = fmt.Sprintf("grep -q '^%s' /etc/sysctl.conf && sed -i 's/^%s.*/%s=%s/' /etc/sysctl.conf || echo '%s=%s' >> /etc/sysctl.conf",
|
|
name, name, name, value, name, value)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleCron(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
name := getStringArg(args, "name", "")
|
|
job := getStringArg(args, "job", "")
|
|
state := getStringArg(args, "state", "present")
|
|
user := getStringArg(args, "user", "root")
|
|
|
|
minute := getStringArg(args, "minute", "*")
|
|
hour := getStringArg(args, "hour", "*")
|
|
day := getStringArg(args, "day", "*")
|
|
month := getStringArg(args, "month", "*")
|
|
weekday := getStringArg(args, "weekday", "*")
|
|
|
|
if state == "absent" {
|
|
if name != "" {
|
|
// Remove by name (comment marker)
|
|
cmd := fmt.Sprintf("crontab -u %s -l 2>/dev/null | grep -v '# %s' | grep -v '%s' | crontab -u %s -",
|
|
user, name, job, user)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
}
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
// Build cron entry
|
|
schedule := fmt.Sprintf("%s %s %s %s %s", minute, hour, day, month, weekday)
|
|
entry := fmt.Sprintf("%s %s # %s", schedule, job, name)
|
|
|
|
// Add to crontab
|
|
cmd := fmt.Sprintf("(crontab -u %s -l 2>/dev/null | grep -v '# %s' ; echo %q) | crontab -u %s -",
|
|
user, name, entry, user)
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleBlockinfile(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
path := getStringArg(args, "path", "")
|
|
if path == "" {
|
|
path = getStringArg(args, "dest", "")
|
|
}
|
|
if path == "" {
|
|
return nil, fmt.Errorf("blockinfile: path required")
|
|
}
|
|
|
|
block := getStringArg(args, "block", "")
|
|
marker := getStringArg(args, "marker", "# {mark} ANSIBLE MANAGED BLOCK")
|
|
state := getStringArg(args, "state", "present")
|
|
create := getBoolArg(args, "create", false)
|
|
|
|
beginMarker := strings.Replace(marker, "{mark}", "BEGIN", 1)
|
|
endMarker := strings.Replace(marker, "{mark}", "END", 1)
|
|
|
|
if state == "absent" {
|
|
// Remove block
|
|
cmd := fmt.Sprintf("sed -i '/%s/,/%s/d' %q",
|
|
strings.ReplaceAll(beginMarker, "/", "\\/"),
|
|
strings.ReplaceAll(endMarker, "/", "\\/"),
|
|
path)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
// Create file if needed (best-effort)
|
|
if create {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("touch %q", path))
|
|
}
|
|
|
|
// Remove existing block and add new one
|
|
escapedBlock := strings.ReplaceAll(block, "'", "'\\''")
|
|
cmd := fmt.Sprintf(`
|
|
sed -i '/%s/,/%s/d' %q 2>/dev/null || true
|
|
cat >> %q << 'BLOCK_EOF'
|
|
%s
|
|
%s
|
|
%s
|
|
BLOCK_EOF
|
|
`, strings.ReplaceAll(beginMarker, "/", "\\/"),
|
|
strings.ReplaceAll(endMarker, "/", "\\/"),
|
|
path, path, beginMarker, escapedBlock, endMarker)
|
|
|
|
stdout, stderr, rc, err := client.RunScript(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleIncludeVars(args map[string]any) (*TaskResult, error) {
|
|
file := getStringArg(args, "file", "")
|
|
if file == "" {
|
|
file = getStringArg(args, "_raw_params", "")
|
|
}
|
|
|
|
if file != "" {
|
|
// Would need to read and parse the vars file
|
|
// For now, just acknowledge
|
|
return &TaskResult{Changed: false, Msg: "include_vars: " + file}, nil
|
|
}
|
|
|
|
return &TaskResult{Changed: false}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleMeta(args map[string]any) (*TaskResult, error) {
|
|
// meta module controls play execution
|
|
// Most actions are no-ops for us
|
|
return &TaskResult{Changed: false}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleSetup(ctx context.Context, client *SSHClient) (*TaskResult, error) {
|
|
// Gather facts - similar to what we do in gatherFacts
|
|
return &TaskResult{Changed: false, Msg: "facts gathered"}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleReboot(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
preRebootDelay := 0
|
|
if d, ok := args["pre_reboot_delay"].(int); ok {
|
|
preRebootDelay = d
|
|
}
|
|
|
|
msg := getStringArg(args, "msg", "Reboot initiated by Ansible")
|
|
|
|
if preRebootDelay > 0 {
|
|
cmd := fmt.Sprintf("sleep %d && shutdown -r now '%s' &", preRebootDelay, msg)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
} else {
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("shutdown -r now '%s' &", msg))
|
|
}
|
|
|
|
return &TaskResult{Changed: true, Msg: "Reboot initiated"}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleUFW(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
rule := getStringArg(args, "rule", "")
|
|
port := getStringArg(args, "port", "")
|
|
proto := getStringArg(args, "proto", "tcp")
|
|
state := getStringArg(args, "state", "")
|
|
|
|
var cmd string
|
|
|
|
// Handle state (enable/disable)
|
|
if state != "" {
|
|
switch state {
|
|
case "enabled":
|
|
cmd = "ufw --force enable"
|
|
case "disabled":
|
|
cmd = "ufw disable"
|
|
case "reloaded":
|
|
cmd = "ufw reload"
|
|
case "reset":
|
|
cmd = "ufw --force reset"
|
|
}
|
|
if cmd != "" {
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
}
|
|
|
|
// Handle rule
|
|
if rule != "" && port != "" {
|
|
switch rule {
|
|
case "allow":
|
|
cmd = fmt.Sprintf("ufw allow %s/%s", port, proto)
|
|
case "deny":
|
|
cmd = fmt.Sprintf("ufw deny %s/%s", port, proto)
|
|
case "reject":
|
|
cmd = fmt.Sprintf("ufw reject %s/%s", port, proto)
|
|
case "limit":
|
|
cmd = fmt.Sprintf("ufw limit %s/%s", port, proto)
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
}
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleAuthorizedKey(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
user := getStringArg(args, "user", "")
|
|
key := getStringArg(args, "key", "")
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
if user == "" || key == "" {
|
|
return nil, fmt.Errorf("authorized_key: user and key required")
|
|
}
|
|
|
|
// Get user's home directory
|
|
stdout, _, _, err := client.Run(ctx, fmt.Sprintf("getent passwd %s | cut -d: -f6", user))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get home dir: %w", err)
|
|
}
|
|
home := strings.TrimSpace(stdout)
|
|
if home == "" {
|
|
home = "/root"
|
|
if user != "root" {
|
|
home = "/home/" + user
|
|
}
|
|
}
|
|
|
|
authKeysPath := filepath.Join(home, ".ssh", "authorized_keys")
|
|
|
|
if state == "absent" {
|
|
// Remove key
|
|
escapedKey := strings.ReplaceAll(key, "/", "\\/")
|
|
cmd := fmt.Sprintf("sed -i '/%s/d' %q 2>/dev/null || true", escapedKey[:40], authKeysPath)
|
|
_, _, _, _ = client.Run(ctx, cmd)
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
// Ensure .ssh directory exists (best-effort)
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q",
|
|
filepath.Dir(authKeysPath), filepath.Dir(authKeysPath), user, user, filepath.Dir(authKeysPath)))
|
|
|
|
// Add key if not present
|
|
cmd := fmt.Sprintf("grep -qF %q %q 2>/dev/null || echo %q >> %q",
|
|
key[:40], authKeysPath, key, authKeysPath)
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
// Fix permissions (best-effort)
|
|
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chmod 600 %q && chown %s:%s %q",
|
|
authKeysPath, user, user, authKeysPath))
|
|
|
|
return &TaskResult{Changed: true}, nil
|
|
}
|
|
|
|
func (e *Executor) moduleDockerCompose(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
|
|
projectSrc := getStringArg(args, "project_src", "")
|
|
state := getStringArg(args, "state", "present")
|
|
|
|
if projectSrc == "" {
|
|
return nil, fmt.Errorf("docker_compose: project_src required")
|
|
}
|
|
|
|
var cmd string
|
|
switch state {
|
|
case "present":
|
|
cmd = fmt.Sprintf("cd %q && docker compose up -d", projectSrc)
|
|
case "absent":
|
|
cmd = fmt.Sprintf("cd %q && docker compose down", projectSrc)
|
|
case "restarted":
|
|
cmd = fmt.Sprintf("cd %q && docker compose restart", projectSrc)
|
|
default:
|
|
cmd = fmt.Sprintf("cd %q && docker compose up -d", projectSrc)
|
|
}
|
|
|
|
stdout, stderr, rc, err := client.Run(ctx, cmd)
|
|
if err != nil || rc != 0 {
|
|
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
|
|
}
|
|
|
|
// Heuristic for changed
|
|
changed := !strings.Contains(stdout, "Up to date") && !strings.Contains(stderr, "Up to date")
|
|
|
|
return &TaskResult{Changed: changed, Stdout: stdout}, nil
|
|
}
|