cli/pkg/ansible/modules.go

1435 lines
40 KiB
Go
Raw Normal View History

feat: wire release command, add tar.xz support, unified installers (#277) * feat(cli): wire release command and add installer scripts - Wire up `core build release` subcommand (was orphaned) - Wire up `core monitor` command (missing import in full variant) - Add installer scripts for Unix (.sh) and Windows (.bat) - setup: Interactive with variant selection - ci: Minimal for CI/CD environments - dev: Full development variant - go/php/agent: Targeted development variants - All scripts include security hardening: - Secure temp directories (mktemp -d) - Architecture validation - Version validation after GitHub API call - Proper cleanup on exit - PowerShell PATH updates on Windows (avoids setx truncation) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(build): add tar.xz support and unified installer scripts - Add tar.xz archive support using Borg's compress package - ArchiveXZ() and ArchiveWithFormat() for configurable compression - Better compression ratio than gzip for release artifacts - Consolidate 12 installer scripts into 2 unified scripts - install.sh and install.bat with BunnyCDN edge variable support - Subdomains: setup.core.help, ci.core.help, dev.core.help, etc. - MODE and VARIANT transformed at edge based on subdomain - Installers prefer tar.xz with automatic fallback to tar.gz - Fixed CodeRabbit issues: HTTP status patterns, tar error handling, verify_install params, VARIANT validation, CI PATH persistence Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add build and release config files - .core/build.yaml - cross-platform build configuration - .core/release.yaml - release workflow configuration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: move plans from docs/ to tasks/ Consolidate planning documents in tasks/plans/ directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(install): address CodeRabbit review feedback - Add curl timeout (--max-time) to prevent hanging on slow networks - Rename TMPDIR to WORK_DIR to avoid clobbering system env var - Add chmod +x to ensure binary has execute permissions - Add error propagation after subroutine calls in batch file - Remove System32 install attempt in CI mode (use consistent INSTALL_DIR) - Fix HTTP status regex for HTTP/2 compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(rag): add Go RAG implementation with Qdrant + Ollama Add RAG (Retrieval Augmented Generation) tools for storing documentation in Qdrant vector database and querying with semantic search. This replaces the Python tools/rag implementation with a native Go solution. New commands: - core rag ingest [directory] - Ingest markdown files into Qdrant - core rag query [question] - Query vector database with semantic search - core rag collections - List and manage Qdrant collections Features: - Markdown chunking by sections and paragraphs with overlap - UTF-8 safe text handling for international content - Automatic category detection from file paths - Multiple output formats: text, JSON, LLM context injection - Environment variable support for host configuration Dependencies: - github.com/qdrant/go-client (gRPC client) - github.com/ollama/ollama/api (embeddings API) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(deploy): add pure-Go Ansible executor and Coolify API integration Implement infrastructure deployment system with: - pkg/ansible: Pure Go Ansible executor - Playbook/inventory parsing (types.go, parser.go) - Full execution engine with variable templating, loops, blocks, conditionals, handlers, and fact gathering (executor.go) - SSH client with key/password auth and privilege escalation (ssh.go) - 35+ module implementations: shell, command, copy, template, file, apt, service, systemd, user, group, git, docker_compose, etc. (modules.go) - pkg/deploy/coolify: Coolify API client wrapping Python swagger client - List/get servers, projects, applications, databases, services - Generic Call() for any OpenAPI operation - pkg/deploy/python: Embedded Python runtime for swagger client integration - internal/cmd/deploy: CLI commands - core deploy servers/projects/apps/databases/services/team - core deploy call <operation> [params-json] This enables Docker-free infrastructure deployment with Ansible-compatible playbooks executed natively in Go. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(deploy): address linter warnings and build errors - Fix fmt.Sprintf format verb error in ssh.go (remove unused stat command) - Fix errcheck warnings by explicitly ignoring best-effort operations - Fix ineffassign warning in cmd_ansible.go All golangci-lint checks now pass for deploy packages. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style(deploy): fix gofmt formatting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(deploy): use known_hosts for SSH host key verification Address CodeQL security alert by using the user's known_hosts file for SSH host key verification when available. Falls back to accepting any key only when known_hosts doesn't exist (common in containerized or ephemeral environments). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(ai,security,ide): add agentic MVP, security jobs, and Core IDE desktop app Wire up AI infrastructure with unified pkg/ai package (metrics JSONL, RAG integration), move RAG under `core ai rag`, add `core ai metrics` command, and enrich task context with Qdrant documentation. Add `--target` flag to all security commands for external repo scanning, `core security jobs` for distributing findings as GitHub Issues, and consistent error logging across scan/deps/alerts/secrets commands. Add Core IDE Wails v3 desktop app with Angular 20 frontend, MCP bridge (loopback-only HTTP server), WebSocket hub, and Claude Code bridge. Production-ready with Lethean CIC branding, macOS code signing support, and security hardening (origin validation, body size limits, URL scheme checks, memory leak prevention, XSS mitigation). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review comments from CodeRabbit, Copilot, and Gemini Fixes across 25 files addressing 46+ review comments: - pkg/ai/metrics.go: handle error from Close() on writable file handle - pkg/ansible: restore loop vars after loop, restore become settings, fix Upload with become=true and no password (use sudo -n), honour SSH timeout config, use E() helper for contextual errors, quote git refs in checkout commands - pkg/rag: validate chunk config, guard negative-to-uint64 conversion, use E() helper for errors, add context timeout to Ollama HTTP calls - pkg/deploy/python: fix exec.ExitError type assertion (was os.PathError), handle os.UserHomeDir() error - pkg/build/buildcmd: use cmd.Context() instead of context.Background() for proper Ctrl+C cancellation - install.bat: add curl timeouts, CRLF line endings, use --connect-timeout for archive downloads - install.sh: use absolute path for version check in CI mode - tools/rag: fix broken ingest.py function def, escape HTML in query.py, pin qdrant-client version, add markdown code block languages - internal/cmd/rag: add chunk size validation, env override handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(build): make release dry-run by default and remove darwin/amd64 target Replace --dry-run (default false) with --we-are-go-for-launch (default false) so `core build release` is safe by default. Remove darwin/amd64 from default build targets (arm64 only for macOS). Fix cmd_project.go to use command context instead of context.Background(). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:49:57 +00:00
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, "")
feat: wire release command, add tar.xz support, unified installers (#277) * feat(cli): wire release command and add installer scripts - Wire up `core build release` subcommand (was orphaned) - Wire up `core monitor` command (missing import in full variant) - Add installer scripts for Unix (.sh) and Windows (.bat) - setup: Interactive with variant selection - ci: Minimal for CI/CD environments - dev: Full development variant - go/php/agent: Targeted development variants - All scripts include security hardening: - Secure temp directories (mktemp -d) - Architecture validation - Version validation after GitHub API call - Proper cleanup on exit - PowerShell PATH updates on Windows (avoids setx truncation) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(build): add tar.xz support and unified installer scripts - Add tar.xz archive support using Borg's compress package - ArchiveXZ() and ArchiveWithFormat() for configurable compression - Better compression ratio than gzip for release artifacts - Consolidate 12 installer scripts into 2 unified scripts - install.sh and install.bat with BunnyCDN edge variable support - Subdomains: setup.core.help, ci.core.help, dev.core.help, etc. - MODE and VARIANT transformed at edge based on subdomain - Installers prefer tar.xz with automatic fallback to tar.gz - Fixed CodeRabbit issues: HTTP status patterns, tar error handling, verify_install params, VARIANT validation, CI PATH persistence Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add build and release config files - .core/build.yaml - cross-platform build configuration - .core/release.yaml - release workflow configuration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: move plans from docs/ to tasks/ Consolidate planning documents in tasks/plans/ directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(install): address CodeRabbit review feedback - Add curl timeout (--max-time) to prevent hanging on slow networks - Rename TMPDIR to WORK_DIR to avoid clobbering system env var - Add chmod +x to ensure binary has execute permissions - Add error propagation after subroutine calls in batch file - Remove System32 install attempt in CI mode (use consistent INSTALL_DIR) - Fix HTTP status regex for HTTP/2 compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(rag): add Go RAG implementation with Qdrant + Ollama Add RAG (Retrieval Augmented Generation) tools for storing documentation in Qdrant vector database and querying with semantic search. This replaces the Python tools/rag implementation with a native Go solution. New commands: - core rag ingest [directory] - Ingest markdown files into Qdrant - core rag query [question] - Query vector database with semantic search - core rag collections - List and manage Qdrant collections Features: - Markdown chunking by sections and paragraphs with overlap - UTF-8 safe text handling for international content - Automatic category detection from file paths - Multiple output formats: text, JSON, LLM context injection - Environment variable support for host configuration Dependencies: - github.com/qdrant/go-client (gRPC client) - github.com/ollama/ollama/api (embeddings API) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(deploy): add pure-Go Ansible executor and Coolify API integration Implement infrastructure deployment system with: - pkg/ansible: Pure Go Ansible executor - Playbook/inventory parsing (types.go, parser.go) - Full execution engine with variable templating, loops, blocks, conditionals, handlers, and fact gathering (executor.go) - SSH client with key/password auth and privilege escalation (ssh.go) - 35+ module implementations: shell, command, copy, template, file, apt, service, systemd, user, group, git, docker_compose, etc. (modules.go) - pkg/deploy/coolify: Coolify API client wrapping Python swagger client - List/get servers, projects, applications, databases, services - Generic Call() for any OpenAPI operation - pkg/deploy/python: Embedded Python runtime for swagger client integration - internal/cmd/deploy: CLI commands - core deploy servers/projects/apps/databases/services/team - core deploy call <operation> [params-json] This enables Docker-free infrastructure deployment with Ansible-compatible playbooks executed natively in Go. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(deploy): address linter warnings and build errors - Fix fmt.Sprintf format verb error in ssh.go (remove unused stat command) - Fix errcheck warnings by explicitly ignoring best-effort operations - Fix ineffassign warning in cmd_ansible.go All golangci-lint checks now pass for deploy packages. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style(deploy): fix gofmt formatting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(deploy): use known_hosts for SSH host key verification Address CodeQL security alert by using the user's known_hosts file for SSH host key verification when available. Falls back to accepting any key only when known_hosts doesn't exist (common in containerized or ephemeral environments). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(ai,security,ide): add agentic MVP, security jobs, and Core IDE desktop app Wire up AI infrastructure with unified pkg/ai package (metrics JSONL, RAG integration), move RAG under `core ai rag`, add `core ai metrics` command, and enrich task context with Qdrant documentation. Add `--target` flag to all security commands for external repo scanning, `core security jobs` for distributing findings as GitHub Issues, and consistent error logging across scan/deps/alerts/secrets commands. Add Core IDE Wails v3 desktop app with Angular 20 frontend, MCP bridge (loopback-only HTTP server), WebSocket hub, and Claude Code bridge. Production-ready with Lethean CIC branding, macOS code signing support, and security hardening (origin validation, body size limits, URL scheme checks, memory leak prevention, XSS mitigation). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review comments from CodeRabbit, Copilot, and Gemini Fixes across 25 files addressing 46+ review comments: - pkg/ai/metrics.go: handle error from Close() on writable file handle - pkg/ansible: restore loop vars after loop, restore become settings, fix Upload with become=true and no password (use sudo -n), honour SSH timeout config, use E() helper for contextual errors, quote git refs in checkout commands - pkg/rag: validate chunk config, guard negative-to-uint64 conversion, use E() helper for errors, add context timeout to Ollama HTTP calls - pkg/deploy/python: fix exec.ExitError type assertion (was os.PathError), handle os.UserHomeDir() error - pkg/build/buildcmd: use cmd.Context() instead of context.Background() for proper Ctrl+C cancellation - install.bat: add curl timeouts, CRLF line endings, use --connect-timeout for archive downloads - install.sh: use absolute path for version check in CI mode - tools/rag: fix broken ingest.py function def, escape HTML in query.py, pin qdrant-client version, add markdown code block languages - internal/cmd/rag: add chunk size validation, env override handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(build): make release dry-run by default and remove darwin/amd64 target Replace --dry-run (default false) with --we-are-go-for-launch (default false) so `core build release` is safe by default. Remove darwin/amd64 from default build targets (arm64 only for macOS). Fix cmd_project.go to use command context instead of context.Background(). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:49:57 +00:00
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")
feat: wire release command, add tar.xz support, unified installers (#277) * feat(cli): wire release command and add installer scripts - Wire up `core build release` subcommand (was orphaned) - Wire up `core monitor` command (missing import in full variant) - Add installer scripts for Unix (.sh) and Windows (.bat) - setup: Interactive with variant selection - ci: Minimal for CI/CD environments - dev: Full development variant - go/php/agent: Targeted development variants - All scripts include security hardening: - Secure temp directories (mktemp -d) - Architecture validation - Version validation after GitHub API call - Proper cleanup on exit - PowerShell PATH updates on Windows (avoids setx truncation) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(build): add tar.xz support and unified installer scripts - Add tar.xz archive support using Borg's compress package - ArchiveXZ() and ArchiveWithFormat() for configurable compression - Better compression ratio than gzip for release artifacts - Consolidate 12 installer scripts into 2 unified scripts - install.sh and install.bat with BunnyCDN edge variable support - Subdomains: setup.core.help, ci.core.help, dev.core.help, etc. - MODE and VARIANT transformed at edge based on subdomain - Installers prefer tar.xz with automatic fallback to tar.gz - Fixed CodeRabbit issues: HTTP status patterns, tar error handling, verify_install params, VARIANT validation, CI PATH persistence Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: add build and release config files - .core/build.yaml - cross-platform build configuration - .core/release.yaml - release workflow configuration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: move plans from docs/ to tasks/ Consolidate planning documents in tasks/plans/ directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(install): address CodeRabbit review feedback - Add curl timeout (--max-time) to prevent hanging on slow networks - Rename TMPDIR to WORK_DIR to avoid clobbering system env var - Add chmod +x to ensure binary has execute permissions - Add error propagation after subroutine calls in batch file - Remove System32 install attempt in CI mode (use consistent INSTALL_DIR) - Fix HTTP status regex for HTTP/2 compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(rag): add Go RAG implementation with Qdrant + Ollama Add RAG (Retrieval Augmented Generation) tools for storing documentation in Qdrant vector database and querying with semantic search. This replaces the Python tools/rag implementation with a native Go solution. New commands: - core rag ingest [directory] - Ingest markdown files into Qdrant - core rag query [question] - Query vector database with semantic search - core rag collections - List and manage Qdrant collections Features: - Markdown chunking by sections and paragraphs with overlap - UTF-8 safe text handling for international content - Automatic category detection from file paths - Multiple output formats: text, JSON, LLM context injection - Environment variable support for host configuration Dependencies: - github.com/qdrant/go-client (gRPC client) - github.com/ollama/ollama/api (embeddings API) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(deploy): add pure-Go Ansible executor and Coolify API integration Implement infrastructure deployment system with: - pkg/ansible: Pure Go Ansible executor - Playbook/inventory parsing (types.go, parser.go) - Full execution engine with variable templating, loops, blocks, conditionals, handlers, and fact gathering (executor.go) - SSH client with key/password auth and privilege escalation (ssh.go) - 35+ module implementations: shell, command, copy, template, file, apt, service, systemd, user, group, git, docker_compose, etc. (modules.go) - pkg/deploy/coolify: Coolify API client wrapping Python swagger client - List/get servers, projects, applications, databases, services - Generic Call() for any OpenAPI operation - pkg/deploy/python: Embedded Python runtime for swagger client integration - internal/cmd/deploy: CLI commands - core deploy servers/projects/apps/databases/services/team - core deploy call <operation> [params-json] This enables Docker-free infrastructure deployment with Ansible-compatible playbooks executed natively in Go. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(deploy): address linter warnings and build errors - Fix fmt.Sprintf format verb error in ssh.go (remove unused stat command) - Fix errcheck warnings by explicitly ignoring best-effort operations - Fix ineffassign warning in cmd_ansible.go All golangci-lint checks now pass for deploy packages. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style(deploy): fix gofmt formatting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(deploy): use known_hosts for SSH host key verification Address CodeQL security alert by using the user's known_hosts file for SSH host key verification when available. Falls back to accepting any key only when known_hosts doesn't exist (common in containerized or ephemeral environments). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(ai,security,ide): add agentic MVP, security jobs, and Core IDE desktop app Wire up AI infrastructure with unified pkg/ai package (metrics JSONL, RAG integration), move RAG under `core ai rag`, add `core ai metrics` command, and enrich task context with Qdrant documentation. Add `--target` flag to all security commands for external repo scanning, `core security jobs` for distributing findings as GitHub Issues, and consistent error logging across scan/deps/alerts/secrets commands. Add Core IDE Wails v3 desktop app with Angular 20 frontend, MCP bridge (loopback-only HTTP server), WebSocket hub, and Claude Code bridge. Production-ready with Lethean CIC branding, macOS code signing support, and security hardening (origin validation, body size limits, URL scheme checks, memory leak prevention, XSS mitigation). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review comments from CodeRabbit, Copilot, and Gemini Fixes across 25 files addressing 46+ review comments: - pkg/ai/metrics.go: handle error from Close() on writable file handle - pkg/ansible: restore loop vars after loop, restore become settings, fix Upload with become=true and no password (use sudo -n), honour SSH timeout config, use E() helper for contextual errors, quote git refs in checkout commands - pkg/rag: validate chunk config, guard negative-to-uint64 conversion, use E() helper for errors, add context timeout to Ollama HTTP calls - pkg/deploy/python: fix exec.ExitError type assertion (was os.PathError), handle os.UserHomeDir() error - pkg/build/buildcmd: use cmd.Context() instead of context.Background() for proper Ctrl+C cancellation - install.bat: add curl timeouts, CRLF line endings, use --connect-timeout for archive downloads - install.sh: use absolute path for version check in CI mode - tools/rag: fix broken ingest.py function def, escape HTML in query.py, pin qdrant-client version, add markdown code block languages - internal/cmd/rag: add chunk size validation, env override handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(build): make release dry-run by default and remove darwin/amd64 target Replace --dry-run (default false) with --we-are-go-for-launch (default false) so `core build release` is safe by default. Remove darwin/amd64 from default build targets (arm64 only for macOS). Fix cmd_project.go to use command context instead of context.Background(). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:49:57 +00:00
return &TaskResult{Changed: changed, Stdout: stdout}, nil
}