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, "")
|
2026-02-04 01:27:01 +00:00
|
|
|
|
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
|
|
|
|
|
}
|