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
feat: infrastructure packages and lint cleanup (#281) * 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>
2026-02-04 11:34:43 +00:00
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
}