refactor(ansible): upgrade core to v0.8.0-alpha.1

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-26 14:47:37 +00:00
parent 55a0f4fcfb
commit 4f33c15d6c
10 changed files with 739 additions and 299 deletions

View file

@ -2,9 +2,6 @@ package anscmd
import (
"context"
"fmt"
"path/filepath"
"strings"
"time"
"dappco.re/go/core"
@ -16,7 +13,7 @@ import (
// args extracts all positional arguments from Options.
func args(opts core.Options) []string {
var out []string
for _, o := range opts {
for _, o := range opts.Items() {
if o.Key == "_arg" {
if s, ok := o.Value.(string); ok {
out = append(out, s)
@ -34,16 +31,16 @@ func runAnsible(opts core.Options) core.Result {
playbookPath := positional[0]
// Resolve playbook path
if !filepath.IsAbs(playbookPath) {
playbookPath, _ = filepath.Abs(playbookPath)
if !pathIsAbs(playbookPath) {
playbookPath = absPath(playbookPath)
}
if !coreio.Local.Exists(playbookPath) {
return core.Result{Value: coreerr.E("runAnsible", fmt.Sprintf("playbook not found: %s", playbookPath), nil)}
return core.Result{Value: coreerr.E("runAnsible", sprintf("playbook not found: %s", playbookPath), nil)}
}
// Create executor
basePath := filepath.Dir(playbookPath)
basePath := pathDir(playbookPath)
executor := ansible.NewExecutor(basePath)
defer executor.Close()
@ -53,16 +50,16 @@ func runAnsible(opts core.Options) core.Result {
executor.Verbose = opts.Int("verbose")
if tags := opts.String("tags"); tags != "" {
executor.Tags = strings.Split(tags, ",")
executor.Tags = split(tags, ",")
}
if skipTags := opts.String("skip-tags"); skipTags != "" {
executor.SkipTags = strings.Split(skipTags, ",")
executor.SkipTags = split(skipTags, ",")
}
// Parse extra vars
if extraVars := opts.String("extra-vars"); extraVars != "" {
for _, v := range strings.Split(extraVars, ",") {
parts := strings.SplitN(v, "=", 2)
for _, v := range split(extraVars, ",") {
parts := splitN(v, "=", 2)
if len(parts) == 2 {
executor.SetVar(parts[0], parts[1])
}
@ -71,17 +68,17 @@ func runAnsible(opts core.Options) core.Result {
// Load inventory
if invPath := opts.String("inventory"); invPath != "" {
if !filepath.IsAbs(invPath) {
invPath, _ = filepath.Abs(invPath)
if !pathIsAbs(invPath) {
invPath = absPath(invPath)
}
if !coreio.Local.Exists(invPath) {
return core.Result{Value: coreerr.E("runAnsible", fmt.Sprintf("inventory not found: %s", invPath), nil)}
return core.Result{Value: coreerr.E("runAnsible", sprintf("inventory not found: %s", invPath), nil)}
}
if coreio.Local.IsDir(invPath) {
for _, name := range []string{"inventory.yml", "hosts.yml", "inventory.yaml", "hosts.yaml"} {
p := filepath.Join(invPath, name)
p := joinPath(invPath, name)
if coreio.Local.Exists(p) {
invPath = p
break
@ -96,8 +93,9 @@ func runAnsible(opts core.Options) core.Result {
// Set up callbacks
executor.OnPlayStart = func(play *ansible.Play) {
fmt.Printf("\nPLAY [%s]\n", play.Name)
fmt.Println(strings.Repeat("*", 70))
print("")
print("PLAY [%s]", play.Name)
print("%s", repeat("*", 70))
}
executor.OnTaskStart = func(host string, task *ansible.Task) {
@ -105,9 +103,10 @@ func runAnsible(opts core.Options) core.Result {
if taskName == "" {
taskName = task.Module
}
fmt.Printf("\nTASK [%s]\n", taskName)
print("")
print("TASK [%s]", taskName)
if executor.Verbose > 0 {
fmt.Printf("host: %s\n", host)
print("host: %s", host)
}
}
@ -121,41 +120,42 @@ func runAnsible(opts core.Options) core.Result {
status = "changed"
}
fmt.Printf("%s: [%s]", status, host)
line := sprintf("%s: [%s]", status, host)
if result.Msg != "" && executor.Verbose > 0 {
fmt.Printf(" => %s", result.Msg)
line = sprintf("%s => %s", line, result.Msg)
}
if result.Duration > 0 && executor.Verbose > 1 {
fmt.Printf(" (%s)", result.Duration.Round(time.Millisecond))
line = sprintf("%s (%s)", line, result.Duration.Round(time.Millisecond))
}
fmt.Println()
print("%s", line)
if result.Failed && result.Stderr != "" {
fmt.Printf("%s\n", result.Stderr)
print("%s", result.Stderr)
}
if executor.Verbose > 1 {
if result.Stdout != "" {
fmt.Printf("stdout: %s\n", strings.TrimSpace(result.Stdout))
print("stdout: %s", trimSpace(result.Stdout))
}
}
}
executor.OnPlayEnd = func(play *ansible.Play) {
fmt.Println()
print("")
}
// Run playbook
ctx := context.Background()
start := time.Now()
fmt.Printf("Running playbook: %s\n", playbookPath)
print("Running playbook: %s", playbookPath)
if err := executor.Run(ctx, playbookPath); err != nil {
return core.Result{Value: coreerr.E("runAnsible", "playbook failed", err)}
}
fmt.Printf("\nPlaybook completed in %s\n", time.Since(start).Round(time.Millisecond))
print("")
print("Playbook completed in %s", time.Since(start).Round(time.Millisecond))
return core.Result{OK: true}
}
@ -167,7 +167,7 @@ func runAnsibleTest(opts core.Options) core.Result {
}
host := positional[0]
fmt.Printf("Testing SSH connection to %s...\n", host)
print("Testing SSH connection to %s...", host)
cfg := ansible.SSHConfig{
Host: host,
@ -194,46 +194,48 @@ func runAnsibleTest(opts core.Options) core.Result {
}
connectTime := time.Since(start)
fmt.Printf("Connected in %s\n", connectTime.Round(time.Millisecond))
print("Connected in %s", connectTime.Round(time.Millisecond))
// Gather facts
fmt.Println("\nGathering facts...")
print("")
print("Gathering facts...")
stdout, _, _, _ := client.Run(ctx, "hostname -f 2>/dev/null || hostname")
fmt.Printf(" Hostname: %s\n", strings.TrimSpace(stdout))
print(" Hostname: %s", trimSpace(stdout))
stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
if stdout != "" {
fmt.Printf(" OS: %s\n", strings.TrimSpace(stdout))
print(" OS: %s", trimSpace(stdout))
}
stdout, _, _, _ = client.Run(ctx, "uname -r")
fmt.Printf(" Kernel: %s\n", strings.TrimSpace(stdout))
print(" Kernel: %s", trimSpace(stdout))
stdout, _, _, _ = client.Run(ctx, "uname -m")
fmt.Printf(" Architecture: %s\n", strings.TrimSpace(stdout))
print(" Architecture: %s", trimSpace(stdout))
stdout, _, _, _ = client.Run(ctx, "free -h | grep Mem | awk '{print $2}'")
fmt.Printf(" Memory: %s\n", strings.TrimSpace(stdout))
print(" Memory: %s", trimSpace(stdout))
stdout, _, _, _ = client.Run(ctx, "df -h / | tail -1 | awk '{print $2 \" total, \" $4 \" available\"}'")
fmt.Printf(" Disk: %s\n", strings.TrimSpace(stdout))
print(" Disk: %s", trimSpace(stdout))
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null")
if err == nil {
fmt.Printf(" Docker: %s\n", strings.TrimSpace(stdout))
print(" Docker: %s", trimSpace(stdout))
} else {
fmt.Printf(" Docker: not installed\n")
print(" Docker: not installed")
}
stdout, _, _, _ = client.Run(ctx, "docker ps 2>/dev/null | grep -q coolify && echo 'running' || echo 'not running'")
if strings.TrimSpace(stdout) == "running" {
fmt.Printf(" Coolify: running\n")
if trimSpace(stdout) == "running" {
print(" Coolify: running")
} else {
fmt.Printf(" Coolify: not installed\n")
print(" Coolify: not installed")
}
fmt.Printf("\nSSH test passed\n")
print("")
print("SSH test passed")
return core.Result{OK: true}
}

View file

@ -9,25 +9,25 @@ func Register(c *core.Core) {
c.Command("ansible", core.Command{
Description: "Run Ansible playbooks natively (no Python required)",
Action: runAnsible,
Flags: core.Options{
{Key: "inventory", Value: ""},
{Key: "limit", Value: ""},
{Key: "tags", Value: ""},
{Key: "skip-tags", Value: ""},
{Key: "extra-vars", Value: ""},
{Key: "verbose", Value: 0},
{Key: "check", Value: false},
},
Flags: core.NewOptions(
core.Option{Key: "inventory", Value: ""},
core.Option{Key: "limit", Value: ""},
core.Option{Key: "tags", Value: ""},
core.Option{Key: "skip-tags", Value: ""},
core.Option{Key: "extra-vars", Value: ""},
core.Option{Key: "verbose", Value: 0},
core.Option{Key: "check", Value: false},
),
})
c.Command("ansible/test", core.Command{
Description: "Test SSH connectivity to a host",
Action: runAnsibleTest,
Flags: core.Options{
{Key: "user", Value: "root"},
{Key: "password", Value: ""},
{Key: "key", Value: ""},
{Key: "port", Value: 22},
},
Flags: core.NewOptions(
core.Option{Key: "user", Value: "root"},
core.Option{Key: "password", Value: ""},
core.Option{Key: "key", Value: ""},
core.Option{Key: "port", Value: 22},
),
})
}

View file

@ -0,0 +1,160 @@
package anscmd
import (
"unicode"
"unicode/utf8"
"dappco.re/go/core"
)
func absPath(path string) string {
if path == "" {
return core.Env("DIR_CWD")
}
if core.PathIsAbs(path) {
return cleanPath(path)
}
cwd := core.Env("DIR_CWD")
if cwd == "" {
cwd = "."
}
return joinPath(cwd, path)
}
func joinPath(parts ...string) string {
ds := dirSep()
path := ""
for _, part := range parts {
if part == "" {
continue
}
if path == "" {
path = part
continue
}
path = core.TrimSuffix(path, ds)
part = core.TrimPrefix(part, ds)
path = core.Concat(path, ds, part)
}
if path == "" {
return "."
}
return core.CleanPath(path, ds)
}
func cleanPath(path string) string {
if path == "" {
return "."
}
return core.CleanPath(path, dirSep())
}
func pathDir(path string) string {
return core.PathDir(path)
}
func pathIsAbs(path string) bool {
return core.PathIsAbs(path)
}
func sprintf(format string, args ...any) string {
return core.Sprintf(format, args...)
}
func split(s, sep string) []string {
return core.Split(s, sep)
}
func splitN(s, sep string, n int) []string {
return core.SplitN(s, sep, n)
}
func trimSpace(s string) string {
return core.Trim(s)
}
func repeat(s string, count int) string {
if count <= 0 {
return ""
}
buf := core.NewBuilder()
for i := 0; i < count; i++ {
buf.WriteString(s)
}
return buf.String()
}
func print(format string, args ...any) {
core.Print(nil, format, args...)
}
func println(args ...any) {
core.Println(args...)
}
func dirSep() string {
ds := core.Env("DS")
if ds == "" {
return "/"
}
return ds
}
func containsRune(cutset string, target rune) bool {
for _, candidate := range cutset {
if candidate == target {
return true
}
}
return false
}
func trimCutset(s, cutset string) string {
start := 0
end := len(s)
for start < end {
r, size := utf8.DecodeRuneInString(s[start:end])
if !containsRune(cutset, r) {
break
}
start += size
}
for start < end {
r, size := utf8.DecodeLastRuneInString(s[start:end])
if !containsRune(cutset, r) {
break
}
end -= size
}
return s[start:end]
}
func fields(s string) []string {
var out []string
start := -1
for i, r := range s {
if unicode.IsSpace(r) {
if start >= 0 {
out = append(out, s[start:i])
start = -1
}
continue
}
if start < 0 {
start = i
}
}
if start >= 0 {
out = append(out, s[start:])
}
return out
}

291
core_primitives.go Normal file
View file

@ -0,0 +1,291 @@
package ansible
import (
"unicode"
"unicode/utf8"
core "dappco.re/go/core"
)
type stringBuffer interface {
Write([]byte) (int, error)
WriteString(string) (int, error)
String() string
}
func dirSep() string {
ds := core.Env("DS")
if ds == "" {
return "/"
}
return ds
}
func corexAbsPath(path string) string {
if path == "" {
return core.Env("DIR_CWD")
}
if core.PathIsAbs(path) {
return corexCleanPath(path)
}
cwd := core.Env("DIR_CWD")
if cwd == "" {
cwd = "."
}
return corexJoinPath(cwd, path)
}
func corexJoinPath(parts ...string) string {
ds := dirSep()
path := ""
for _, part := range parts {
if part == "" {
continue
}
if path == "" {
path = part
continue
}
path = core.TrimSuffix(path, ds)
part = core.TrimPrefix(part, ds)
path = core.Concat(path, ds, part)
}
if path == "" {
return "."
}
return core.CleanPath(path, ds)
}
func corexCleanPath(path string) string {
if path == "" {
return "."
}
return core.CleanPath(path, dirSep())
}
func corexPathDir(path string) string {
return core.PathDir(path)
}
func corexPathBase(path string) string {
return core.PathBase(path)
}
func corexPathIsAbs(path string) bool {
return core.PathIsAbs(path)
}
func corexEnv(key string) string {
return core.Env(key)
}
func corexSprintf(format string, args ...any) string {
return core.Sprintf(format, args...)
}
func corexSprint(args ...any) string {
return core.Sprint(args...)
}
func corexContains(s, substr string) bool {
return core.Contains(s, substr)
}
func corexHasPrefix(s, prefix string) bool {
return core.HasPrefix(s, prefix)
}
func corexHasSuffix(s, suffix string) bool {
return core.HasSuffix(s, suffix)
}
func corexSplit(s, sep string) []string {
return core.Split(s, sep)
}
func corexSplitN(s, sep string, n int) []string {
return core.SplitN(s, sep, n)
}
func corexJoin(sep string, parts []string) string {
return core.Join(sep, parts...)
}
func corexLower(s string) string {
return core.Lower(s)
}
func corexReplaceAll(s, old, new string) string {
return core.Replace(s, old, new)
}
func corexReplaceN(s, old, new string, n int) string {
if n == 0 || old == "" {
return s
}
if n < 0 {
return corexReplaceAll(s, old, new)
}
result := s
for i := 0; i < n; i++ {
index := corexStringIndex(result, old)
if index < 0 {
break
}
result = core.Concat(result[:index], new, result[index+len(old):])
}
return result
}
func corexTrimSpace(s string) string {
return core.Trim(s)
}
func corexTrimPrefix(s, prefix string) string {
return core.TrimPrefix(s, prefix)
}
func corexTrimCutset(s, cutset string) string {
start := 0
end := len(s)
for start < end {
r, size := utf8.DecodeRuneInString(s[start:end])
if !corexContainsRune(cutset, r) {
break
}
start += size
}
for start < end {
r, size := utf8.DecodeLastRuneInString(s[start:end])
if !corexContainsRune(cutset, r) {
break
}
end -= size
}
return s[start:end]
}
func corexRepeat(s string, count int) string {
if count <= 0 {
return ""
}
buf := core.NewBuilder()
for i := 0; i < count; i++ {
buf.WriteString(s)
}
return buf.String()
}
func corexFields(s string) []string {
var out []string
start := -1
for i, r := range s {
if unicode.IsSpace(r) {
if start >= 0 {
out = append(out, s[start:i])
start = -1
}
continue
}
if start < 0 {
start = i
}
}
if start >= 0 {
out = append(out, s[start:])
}
return out
}
func corexNewBuilder() stringBuffer {
return core.NewBuilder()
}
func corexNewReader(s string) interface {
Read([]byte) (int, error)
} {
return core.NewReader(s)
}
func corexReadAllString(reader any) (string, error) {
result := core.ReadAll(reader)
if !result.OK {
if err, ok := result.Value.(error); ok {
return "", err
}
return "", core.NewError("read content")
}
if data, ok := result.Value.(string); ok {
return data, nil
}
return corexSprint(result.Value), nil
}
func corexWriteString(writer interface {
Write([]byte) (int, error)
}, value string) {
_, _ = writer.Write([]byte(value))
}
func corexContainsRune(cutset string, target rune) bool {
for _, candidate := range cutset {
if candidate == target {
return true
}
}
return false
}
func corexStringIndex(s, needle string) int {
if needle == "" {
return 0
}
if len(needle) > len(s) {
return -1
}
for i := 0; i+len(needle) <= len(s); i++ {
if s[i:i+len(needle)] == needle {
return i
}
}
return -1
}
func absPath(path string) string { return corexAbsPath(path) }
func joinPath(parts ...string) string { return corexJoinPath(parts...) }
func cleanPath(path string) string { return corexCleanPath(path) }
func pathDir(path string) string { return corexPathDir(path) }
func pathBase(path string) string { return corexPathBase(path) }
func pathIsAbs(path string) bool { return corexPathIsAbs(path) }
func env(key string) string { return corexEnv(key) }
func sprintf(format string, args ...any) string { return corexSprintf(format, args...) }
func sprint(args ...any) string { return corexSprint(args...) }
func contains(s, substr string) bool { return corexContains(s, substr) }
func hasSuffix(s, suffix string) bool { return corexHasSuffix(s, suffix) }
func split(s, sep string) []string { return corexSplit(s, sep) }
func splitN(s, sep string, n int) []string { return corexSplitN(s, sep, n) }
func join(sep string, parts []string) string { return corexJoin(sep, parts) }
func lower(s string) string { return corexLower(s) }
func replaceAll(s, old, new string) string { return corexReplaceAll(s, old, new) }
func replaceN(s, old, new string, n int) string { return corexReplaceN(s, old, new, n) }
func trimCutset(s, cutset string) string { return corexTrimCutset(s, cutset) }
func repeat(s string, count int) string { return corexRepeat(s, count) }
func fields(s string) []string { return corexFields(s) }
func newBuilder() stringBuffer { return corexNewBuilder() }
func newReader(s string) interface{ Read([]byte) (int, error) } { return corexNewReader(s) }
func readAllString(reader any) (string, error) { return corexReadAllString(reader) }
func writeString(writer interface{ Write([]byte) (int, error) }, value string) {
corexWriteString(writer, value)
}

View file

@ -2,11 +2,8 @@ package ansible
import (
"context"
"fmt"
"os"
"regexp"
"slices"
"strings"
"sync"
"text/template"
"time"
@ -86,7 +83,7 @@ func (e *Executor) Run(ctx context.Context, playbookPath string) error {
for i := range plays {
if err := e.runPlay(ctx, &plays[i]); err != nil {
return coreerr.E("Executor.Run", fmt.Sprintf("play %d (%s)", i, plays[i].Name), err)
return coreerr.E("Executor.Run", sprintf("play %d (%s)", i, plays[i].Name), err)
}
}
@ -180,7 +177,7 @@ func (e *Executor) runRole(ctx context.Context, hosts []string, roleRef *RoleRef
// Parse role tasks
tasks, err := e.parser.ParseRole(roleRef.Role, roleRef.TasksFrom)
if err != nil {
return coreerr.E("executor.runRole", fmt.Sprintf("parse role %s", roleRef.Role), err)
return coreerr.E("executor.runRole", sprintf("parse role %s", roleRef.Role), err)
}
// Merge role vars
@ -267,7 +264,7 @@ func (e *Executor) runTaskOnHost(ctx context.Context, host string, task *Task, p
// Get SSH client
client, err := e.getClient(host, play)
if err != nil {
return coreerr.E("Executor.runTaskOnHost", fmt.Sprintf("get client for %s", host), err)
return coreerr.E("Executor.runTaskOnHost", sprintf("get client for %s", host), err)
}
// Handle loops
@ -485,7 +482,7 @@ func (e *Executor) getHosts(pattern string) []string {
var filtered []string
for _, h := range hosts {
if limitSet[h] || h == e.Limit || strings.Contains(h, e.Limit) {
if limitSet[h] || h == e.Limit || contains(h, e.Limit) {
filtered = append(filtered, h)
}
}
@ -582,32 +579,32 @@ func (e *Executor) gatherFacts(ctx context.Context, host string, play *Play) err
// Hostname
stdout, _, _, err := client.Run(ctx, "hostname -f 2>/dev/null || hostname")
if err == nil {
facts.FQDN = strings.TrimSpace(stdout)
facts.FQDN = corexTrimSpace(stdout)
}
stdout, _, _, err = client.Run(ctx, "hostname -s 2>/dev/null || hostname")
if err == nil {
facts.Hostname = strings.TrimSpace(stdout)
facts.Hostname = corexTrimSpace(stdout)
}
// OS info
stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep -E '^(ID|VERSION_ID)=' | head -2")
for line := range strings.SplitSeq(stdout, "\n") {
if strings.HasPrefix(line, "ID=") {
facts.Distribution = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
for _, line := range split(stdout, "\n") {
if corexHasPrefix(line, "ID=") {
facts.Distribution = trimCutset(corexTrimPrefix(line, "ID="), "\"")
}
if strings.HasPrefix(line, "VERSION_ID=") {
facts.Version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
if corexHasPrefix(line, "VERSION_ID=") {
facts.Version = trimCutset(corexTrimPrefix(line, "VERSION_ID="), "\"")
}
}
// Architecture
stdout, _, _, _ = client.Run(ctx, "uname -m")
facts.Architecture = strings.TrimSpace(stdout)
facts.Architecture = corexTrimSpace(stdout)
// Kernel
stdout, _, _, _ = client.Run(ctx, "uname -r")
facts.Kernel = strings.TrimSpace(stdout)
facts.Kernel = corexTrimSpace(stdout)
e.mu.Lock()
e.facts[host] = facts
@ -650,11 +647,11 @@ func normalizeConditions(when any) []string {
// evalCondition evaluates a single condition.
func (e *Executor) evalCondition(cond string, host string) bool {
cond = strings.TrimSpace(cond)
cond = corexTrimSpace(cond)
// Handle negation
if strings.HasPrefix(cond, "not ") {
return !e.evalCondition(strings.TrimPrefix(cond, "not "), host)
if corexHasPrefix(cond, "not ") {
return !e.evalCondition(corexTrimPrefix(cond, "not "), host)
}
// Handle boolean literals
@ -667,10 +664,10 @@ func (e *Executor) evalCondition(cond string, host string) bool {
// Handle registered variable checks
// e.g., "result is success", "result.rc == 0"
if strings.Contains(cond, " is ") {
parts := strings.SplitN(cond, " is ", 2)
varName := strings.TrimSpace(parts[0])
check := strings.TrimSpace(parts[1])
if contains(cond, " is ") {
parts := splitN(cond, " is ", 2)
varName := corexTrimSpace(parts[0])
check := corexTrimSpace(parts[1])
result := e.getRegisteredVar(host, varName)
if result == nil {
@ -694,7 +691,7 @@ func (e *Executor) evalCondition(cond string, host string) bool {
}
// Handle simple var checks
if strings.Contains(cond, " | default(") {
if contains(cond, " | default(") {
// Extract var name and check if defined
re := regexp.MustCompile(`(\w+)\s*\|\s*default\([^)]*\)`)
if match := re.FindStringSubmatch(cond); len(match) > 1 {
@ -730,7 +727,7 @@ func (e *Executor) getRegisteredVar(host string, name string) *TaskResult {
defer e.mu.RUnlock()
// Handle dotted access (e.g., "result.stdout")
parts := strings.SplitN(name, ".", 2)
parts := splitN(name, ".", 2)
varName := parts[0]
if hostResults, ok := e.results[host]; ok {
@ -748,7 +745,7 @@ func (e *Executor) templateString(s string, host string, task *Task) string {
re := regexp.MustCompile(`\{\{\s*([^}]+)\s*\}\}`)
return re.ReplaceAllStringFunc(s, func(match string) string {
expr := strings.TrimSpace(match[2 : len(match)-2])
expr := corexTrimSpace(match[2 : len(match)-2])
return e.resolveExpr(expr, host, task)
})
}
@ -756,20 +753,20 @@ func (e *Executor) templateString(s string, host string, task *Task) string {
// resolveExpr resolves a template expression.
func (e *Executor) resolveExpr(expr string, host string, task *Task) string {
// Handle filters
if strings.Contains(expr, " | ") {
parts := strings.SplitN(expr, " | ", 2)
if contains(expr, " | ") {
parts := splitN(expr, " | ", 2)
value := e.resolveExpr(parts[0], host, task)
return e.applyFilter(value, parts[1])
}
// Handle lookups
if strings.HasPrefix(expr, "lookup(") {
if corexHasPrefix(expr, "lookup(") {
return e.handleLookup(expr)
}
// Handle registered vars
if strings.Contains(expr, ".") {
parts := strings.SplitN(expr, ".", 2)
if contains(expr, ".") {
parts := splitN(expr, ".", 2)
if result := e.getRegisteredVar(host, parts[0]); result != nil {
switch parts[1] {
case "stdout":
@ -777,24 +774,24 @@ func (e *Executor) resolveExpr(expr string, host string, task *Task) string {
case "stderr":
return result.Stderr
case "rc":
return fmt.Sprintf("%d", result.RC)
return sprintf("%d", result.RC)
case "changed":
return fmt.Sprintf("%t", result.Changed)
return sprintf("%t", result.Changed)
case "failed":
return fmt.Sprintf("%t", result.Failed)
return sprintf("%t", result.Failed)
}
}
}
// Check vars
if val, ok := e.vars[expr]; ok {
return fmt.Sprintf("%v", val)
return sprintf("%v", val)
}
// Check task vars
if task != nil {
if val, ok := task.Vars[expr]; ok {
return fmt.Sprintf("%v", val)
return sprintf("%v", val)
}
}
@ -802,7 +799,7 @@ func (e *Executor) resolveExpr(expr string, host string, task *Task) string {
if e.inventory != nil {
hostVars := GetHostVars(e.inventory, host)
if val, ok := hostVars[expr]; ok {
return fmt.Sprintf("%v", val)
return sprintf("%v", val)
}
}
@ -829,15 +826,15 @@ func (e *Executor) resolveExpr(expr string, host string, task *Task) string {
// applyFilter applies a Jinja2 filter.
func (e *Executor) applyFilter(value, filter string) string {
filter = strings.TrimSpace(filter)
filter = corexTrimSpace(filter)
// Handle default filter
if strings.HasPrefix(filter, "default(") {
if corexHasPrefix(filter, "default(") {
if value == "" || value == "{{ "+filter+" }}" {
// Extract default value
re := regexp.MustCompile(`default\(([^)]*)\)`)
if match := re.FindStringSubmatch(filter); len(match) > 1 {
return strings.Trim(match[1], "'\"")
return trimCutset(match[1], "'\"")
}
}
return value
@ -845,8 +842,8 @@ func (e *Executor) applyFilter(value, filter string) string {
// Handle bool filter
if filter == "bool" {
lower := strings.ToLower(value)
if lower == "true" || lower == "yes" || lower == "1" {
lowered := lower(value)
if lowered == "true" || lowered == "yes" || lowered == "1" {
return "true"
}
return "false"
@ -854,7 +851,7 @@ func (e *Executor) applyFilter(value, filter string) string {
// Handle trim
if filter == "trim" {
return strings.TrimSpace(value)
return corexTrimSpace(value)
}
// Handle b64decode
@ -880,7 +877,7 @@ func (e *Executor) handleLookup(expr string) string {
switch lookupType {
case "env":
return os.Getenv(arg)
return env(arg)
case "file":
if data, err := coreio.Local.Read(arg); err == nil {
return data
@ -978,9 +975,9 @@ func (e *Executor) TemplateFile(src, host string, task *Task) (string, error) {
// Convert Jinja2 to Go template syntax (basic conversion)
tmplContent := content
tmplContent = strings.ReplaceAll(tmplContent, "{{", "{{ .")
tmplContent = strings.ReplaceAll(tmplContent, "{%", "{{")
tmplContent = strings.ReplaceAll(tmplContent, "%}", "}}")
tmplContent = replaceAll(tmplContent, "{{", "{{ .")
tmplContent = replaceAll(tmplContent, "{%", "{{")
tmplContent = replaceAll(tmplContent, "%}", "}}")
tmpl, err := template.New("template").Parse(tmplContent)
if err != nil {
@ -1010,8 +1007,8 @@ func (e *Executor) TemplateFile(src, host string, task *Task) (string, error) {
context["ansible_kernel"] = facts.Kernel
}
var buf strings.Builder
if err := tmpl.Execute(&buf, context); err != nil {
buf := newBuilder()
if err := tmpl.Execute(buf, context); err != nil {
return e.templateString(content, host, task), nil
}

2
go.mod
View file

@ -3,7 +3,7 @@ module dappco.re/go/core/ansible
go 1.26.0
require (
dappco.re/go/core v0.5.0
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/io v0.2.0
dappco.re/go/core/log v0.1.0
github.com/stretchr/testify v1.11.1

4
go.sum
View file

@ -1,5 +1,5 @@
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=

View file

@ -3,11 +3,8 @@ package ansible
import (
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
@ -135,7 +132,7 @@ func (e *Executor) executeModule(ctx context.Context, host string, client *SSHCl
default:
// For unknown modules, try to execute as shell if it looks like a command
if strings.Contains(task.Module, " ") || task.Module == "" {
if contains(task.Module, " ") || task.Module == "" {
return e.moduleShell(ctx, client, args)
}
return nil, coreerr.E("Executor.executeModule", "unsupported module: "+module, nil)
@ -186,7 +183,7 @@ func (e *Executor) moduleShell(ctx context.Context, client *SSHClient, args map[
// Handle chdir
if chdir := getStringArg(args, "chdir", ""); chdir != "" {
cmd = fmt.Sprintf("cd %q && %s", chdir, cmd)
cmd = sprintf("cd %q && %s", chdir, cmd)
}
stdout, stderr, rc, err := client.RunScript(ctx, cmd)
@ -214,7 +211,7 @@ func (e *Executor) moduleCommand(ctx context.Context, client *SSHClient, args ma
// Handle chdir
if chdir := getStringArg(args, "chdir", ""); chdir != "" {
cmd = fmt.Sprintf("cd %q && %s", chdir, cmd)
cmd = sprintf("cd %q && %s", chdir, cmd)
}
stdout, stderr, rc, err := client.Run(ctx, cmd)
@ -305,20 +302,20 @@ func (e *Executor) moduleCopy(ctx context.Context, client *SSHClient, args map[s
}
}
err = client.Upload(ctx, strings.NewReader(content), dest, mode)
err = client.Upload(ctx, newReader(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))
_, _, _, _ = client.Run(ctx, sprintf("chown %s %q", owner, dest))
}
if group := getStringArg(args, "group", ""); group != "" {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chgrp %s %q", group, dest))
_, _, _, _ = client.Run(ctx, sprintf("chgrp %s %q", group, dest))
}
return &TaskResult{Changed: true, Msg: fmt.Sprintf("copied to %s", dest)}, nil
return &TaskResult{Changed: true, Msg: 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) {
@ -341,12 +338,12 @@ func (e *Executor) moduleTemplate(ctx context.Context, client *SSHClient, args m
}
}
err = client.Upload(ctx, strings.NewReader(content), dest, mode)
err = client.Upload(ctx, newReader(content), dest, mode)
if err != nil {
return nil, err
}
return &TaskResult{Changed: true, Msg: fmt.Sprintf("templated to %s", dest)}, nil
return &TaskResult{Changed: true, Msg: sprintf("templated to %s", dest)}, nil
}
func (e *Executor) moduleFile(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
@ -363,21 +360,21 @@ func (e *Executor) moduleFile(ctx context.Context, client *SSHClient, args map[s
switch state {
case "directory":
mode := getStringArg(args, "mode", "0755")
cmd := fmt.Sprintf("mkdir -p %q && chmod %s %q", path, mode, path)
cmd := 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)
cmd := 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)
cmd := 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
@ -388,7 +385,7 @@ func (e *Executor) moduleFile(ctx context.Context, client *SSHClient, args map[s
if src == "" {
return nil, coreerr.E("Executor.moduleFile", "src required for link state", nil)
}
cmd := fmt.Sprintf("ln -sf %q %q", src, path)
cmd := 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
@ -397,20 +394,20 @@ func (e *Executor) moduleFile(ctx context.Context, client *SSHClient, args map[s
case "file":
// Ensure file exists and set permissions
if mode := getStringArg(args, "mode", ""); mode != "" {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chmod %s %q", mode, path))
_, _, _, _ = client.Run(ctx, 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))
_, _, _, _ = client.Run(ctx, sprintf("chown %s %q", owner, path))
}
if group := getStringArg(args, "group", ""); group != "" {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chgrp %s %q", group, path))
_, _, _, _ = client.Run(ctx, 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))
_, _, _, _ = client.Run(ctx, sprintf("chown -R %s %q", owner, path))
}
}
@ -432,7 +429,7 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client *SSHClient, args
if state == "absent" {
if regexp != "" {
cmd := fmt.Sprintf("sed -i '/%s/d' %q", regexp, path)
cmd := 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
@ -442,17 +439,17 @@ func (e *Executor) moduleLineinfile(ctx context.Context, client *SSHClient, args
// state == present
if regexp != "" {
// Replace line matching regexp
escapedLine := strings.ReplaceAll(line, "/", "\\/")
cmd := fmt.Sprintf("sed -i 's/%s/%s/' %q", regexp, escapedLine, path)
escapedLine := replaceAll(line, "/", "\\/")
cmd := 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)
cmd = 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)
cmd := sprintf("grep -qxF %q %q || echo %q >> %q", line, path, line, path)
_, _, _, _ = client.Run(ctx, cmd)
}
}
@ -512,7 +509,7 @@ func (e *Executor) moduleFetch(ctx context.Context, client *SSHClient, args map[
}
// Create dest directory
if err := coreio.Local.EnsureDir(filepath.Dir(dest)); err != nil {
if err := coreio.Local.EnsureDir(pathDir(dest)); err != nil {
return nil, err
}
@ -520,7 +517,7 @@ func (e *Executor) moduleFetch(ctx context.Context, client *SSHClient, args map[
return nil, err
}
return &TaskResult{Changed: true, Msg: fmt.Sprintf("fetched %s to %s", src, dest)}, nil
return &TaskResult{Changed: true, Msg: sprintf("fetched %s to %s", src, dest)}, nil
}
func (e *Executor) moduleGetURL(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
@ -531,7 +528,7 @@ func (e *Executor) moduleGetURL(ctx context.Context, client *SSHClient, args map
}
// Use curl or wget
cmd := fmt.Sprintf("curl -fsSL -o %q %q || wget -q -O %q %q", dest, url, dest, url)
cmd := 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
@ -539,7 +536,7 @@ func (e *Executor) moduleGetURL(ctx context.Context, client *SSHClient, args map
// Set mode if specified (best-effort)
if mode := getStringArg(args, "mode", ""); mode != "" {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chmod %s %q", mode, dest))
_, _, _, _ = client.Run(ctx, sprintf("chmod %s %q", mode, dest))
}
return &TaskResult{Changed: true}, nil
@ -561,12 +558,12 @@ func (e *Executor) moduleApt(ctx context.Context, client *SSHClient, args map[st
switch state {
case "present", "installed":
if name != "" {
cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s", name)
cmd = 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)
cmd = 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)
cmd = sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade %s", name)
}
if cmd == "" {
@ -588,7 +585,7 @@ func (e *Executor) moduleAptKey(ctx context.Context, client *SSHClient, args map
if state == "absent" {
if keyring != "" {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("rm -f %q", keyring))
_, _, _, _ = client.Run(ctx, sprintf("rm -f %q", keyring))
}
return &TaskResult{Changed: true}, nil
}
@ -599,9 +596,9 @@ func (e *Executor) moduleAptKey(ctx context.Context, client *SSHClient, args map
var cmd string
if keyring != "" {
cmd = fmt.Sprintf("curl -fsSL %q | gpg --dearmor -o %q", url, keyring)
cmd = sprintf("curl -fsSL %q | gpg --dearmor -o %q", url, keyring)
} else {
cmd = fmt.Sprintf("curl -fsSL %q | apt-key add -", url)
cmd = sprintf("curl -fsSL %q | apt-key add -", url)
}
stdout, stderr, rc, err := client.Run(ctx, cmd)
@ -623,19 +620,19 @@ func (e *Executor) moduleAptRepository(ctx context.Context, client *SSHClient, a
if filename == "" {
// Generate filename from repo
filename = strings.ReplaceAll(repo, " ", "-")
filename = strings.ReplaceAll(filename, "/", "-")
filename = strings.ReplaceAll(filename, ":", "")
filename = replaceAll(repo, " ", "-")
filename = replaceAll(filename, "/", "-")
filename = replaceAll(filename, ":", "")
}
path := fmt.Sprintf("/etc/apt/sources.list.d/%s.list", filename)
path := sprintf("/etc/apt/sources.list.d/%s.list", filename)
if state == "absent" {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("rm -f %q", path))
_, _, _, _ = client.Run(ctx, sprintf("rm -f %q", path))
return &TaskResult{Changed: true}, nil
}
cmd := fmt.Sprintf("echo %q > %q", repo, path)
cmd := 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
@ -652,9 +649,9 @@ func (e *Executor) moduleAptRepository(ctx context.Context, client *SSHClient, a
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)
stdout = corexTrimSpace(stdout)
if strings.Contains(stdout, "apt") {
if contains(stdout, "apt") {
return e.moduleApt(ctx, client, args)
}
@ -670,11 +667,11 @@ func (e *Executor) modulePip(ctx context.Context, client *SSHClient, args map[st
var cmd string
switch state {
case "present", "installed":
cmd = fmt.Sprintf("%s install %s", executable, name)
cmd = sprintf("%s install %s", executable, name)
case "absent", "removed":
cmd = fmt.Sprintf("%s uninstall -y %s", executable, name)
cmd = sprintf("%s uninstall -y %s", executable, name)
case "latest":
cmd = fmt.Sprintf("%s install --upgrade %s", executable, name)
cmd = sprintf("%s install --upgrade %s", executable, name)
}
stdout, stderr, rc, err := client.Run(ctx, cmd)
@ -701,21 +698,21 @@ func (e *Executor) moduleService(ctx context.Context, client *SSHClient, args ma
if state != "" {
switch state {
case "started":
cmds = append(cmds, fmt.Sprintf("systemctl start %s", name))
cmds = append(cmds, sprintf("systemctl start %s", name))
case "stopped":
cmds = append(cmds, fmt.Sprintf("systemctl stop %s", name))
cmds = append(cmds, sprintf("systemctl stop %s", name))
case "restarted":
cmds = append(cmds, fmt.Sprintf("systemctl restart %s", name))
cmds = append(cmds, sprintf("systemctl restart %s", name))
case "reloaded":
cmds = append(cmds, fmt.Sprintf("systemctl reload %s", name))
cmds = append(cmds, sprintf("systemctl reload %s", name))
}
}
if enabled != nil {
if getBoolArg(args, "enabled", false) {
cmds = append(cmds, fmt.Sprintf("systemctl enable %s", name))
cmds = append(cmds, sprintf("systemctl enable %s", name))
} else {
cmds = append(cmds, fmt.Sprintf("systemctl disable %s", name))
cmds = append(cmds, sprintf("systemctl disable %s", name))
}
}
@ -749,7 +746,7 @@ func (e *Executor) moduleUser(ctx context.Context, client *SSHClient, args map[s
}
if state == "absent" {
cmd := fmt.Sprintf("userdel -r %s 2>/dev/null || true", name)
cmd := sprintf("userdel -r %s 2>/dev/null || true", name)
_, _, _, _ = client.Run(ctx, cmd)
return &TaskResult{Changed: true}, nil
}
@ -780,12 +777,12 @@ func (e *Executor) moduleUser(ctx context.Context, client *SSHClient, args map[s
}
// Try usermod first, then useradd
optsStr := strings.Join(opts, " ")
optsStr := join(" ", opts)
var cmd string
if optsStr == "" {
cmd = fmt.Sprintf("id %s >/dev/null 2>&1 || useradd %s", name, name)
cmd = 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",
cmd = sprintf("id %s >/dev/null 2>&1 && usermod %s %s || useradd %s %s",
name, optsStr, name, optsStr, name)
}
@ -806,7 +803,7 @@ func (e *Executor) moduleGroup(ctx context.Context, client *SSHClient, args map[
}
if state == "absent" {
cmd := fmt.Sprintf("groupdel %s 2>/dev/null || true", name)
cmd := sprintf("groupdel %s 2>/dev/null || true", name)
_, _, _, _ = client.Run(ctx, cmd)
return &TaskResult{Changed: true}, nil
}
@ -819,8 +816,8 @@ func (e *Executor) moduleGroup(ctx context.Context, client *SSHClient, args map[
opts = append(opts, "-r")
}
cmd := fmt.Sprintf("getent group %s >/dev/null 2>&1 || groupadd %s %s",
name, strings.Join(opts, " "), name)
cmd := sprintf("getent group %s >/dev/null 2>&1 || groupadd %s %s",
name, join(" ", opts), name)
stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 {
@ -847,7 +844,7 @@ func (e *Executor) moduleURI(ctx context.Context, client *SSHClient, args map[st
// 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))
curlOpts = append(curlOpts, "-H", sprintf("%s: %v", k, v))
}
}
@ -859,14 +856,14 @@ func (e *Executor) moduleURI(ctx context.Context, client *SSHClient, args map[st
// Status code
curlOpts = append(curlOpts, "-w", "\\n%{http_code}")
cmd := fmt.Sprintf("curl %s %q", strings.Join(curlOpts, " "), url)
cmd := sprintf("curl %s %q", 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")
lines := split(corexTrimSpace(stdout), "\n")
statusCode := 0
if len(lines) > 0 {
statusCode, _ = strconv.Atoi(lines[len(lines)-1])
@ -895,7 +892,7 @@ func (e *Executor) moduleURI(ctx context.Context, client *SSHClient, args map[st
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)])
msg = sprintf("%v = %v", v, e.vars[sprintf("%v", v)])
}
return &TaskResult{
@ -921,7 +918,7 @@ func (e *Executor) moduleAssert(args map[string]any, host string) (*TaskResult,
conditions := normalizeConditions(that)
for _, cond := range conditions {
if !e.evalCondition(cond, host) {
msg := getStringArg(args, "fail_msg", fmt.Sprintf("Assertion failed: %s", cond))
msg := getStringArg(args, "fail_msg", sprintf("Assertion failed: %s", cond))
return &TaskResult{Failed: true, Msg: msg}, nil
}
}
@ -999,7 +996,7 @@ func (e *Executor) moduleWaitFor(ctx context.Context, client *SSHClient, args ma
}
if port > 0 && state == "started" {
cmd := fmt.Sprintf("timeout %d bash -c 'until nc -z %s %d; do sleep 1; done'",
cmd := sprintf("timeout %d bash -c 'until nc -z %s %d; do sleep 1; done'",
timeout, host, port)
stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 {
@ -1025,9 +1022,9 @@ func (e *Executor) moduleGit(ctx context.Context, client *SSHClient, args map[st
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)
cmd = 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",
cmd = sprintf("git clone %q %q && cd %q && git checkout %q",
repo, dest, dest, version)
}
@ -1049,7 +1046,7 @@ func (e *Executor) moduleUnarchive(ctx context.Context, client *SSHClient, args
}
// Create dest directory (best-effort)
_, _, _, _ = client.Run(ctx, fmt.Sprintf("mkdir -p %q", dest))
_, _, _, _ = client.Run(ctx, sprintf("mkdir -p %q", dest))
var cmd string
if !remote {
@ -1058,28 +1055,28 @@ func (e *Executor) moduleUnarchive(ctx context.Context, client *SSHClient, args
if err != nil {
return nil, coreerr.E("Executor.moduleUnarchive", "read src", err)
}
tmpPath := "/tmp/ansible_unarchive_" + filepath.Base(src)
err = client.Upload(ctx, strings.NewReader(data), tmpPath, 0644)
tmpPath := "/tmp/ansible_unarchive_" + pathBase(src)
err = client.Upload(ctx, newReader(data), tmpPath, 0644)
if err != nil {
return nil, err
}
src = tmpPath
defer func() { _, _, _, _ = client.Run(ctx, fmt.Sprintf("rm -f %q", tmpPath)) }()
defer func() { _, _, _, _ = client.Run(ctx, 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)
if hasSuffix(src, ".tar.gz") || hasSuffix(src, ".tgz") {
cmd = sprintf("tar -xzf %q -C %q", src, dest)
} else if hasSuffix(src, ".tar.xz") {
cmd = sprintf("tar -xJf %q -C %q", src, dest)
} else if hasSuffix(src, ".tar.bz2") {
cmd = sprintf("tar -xjf %q -C %q", src, dest)
} else if hasSuffix(src, ".tar") {
cmd = sprintf("tar -xf %q -C %q", src, dest)
} else if hasSuffix(src, ".zip") {
cmd = sprintf("unzip -o %q -d %q", src, dest)
} else {
cmd = fmt.Sprintf("tar -xf %q -C %q", src, dest) // Guess tar
cmd = sprintf("tar -xf %q -C %q", src, dest) // Guess tar
}
stdout, stderr, rc, err := client.Run(ctx, cmd)
@ -1097,7 +1094,7 @@ func getStringArg(args map[string]any, key, def string) string {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
return sprintf("%v", v)
}
return def
}
@ -1108,8 +1105,8 @@ func getBoolArg(args map[string]any, key string, def bool) bool {
case bool:
return b
case string:
lower := strings.ToLower(b)
return lower == "true" || lower == "yes" || lower == "1"
lowered := lower(b)
return lowered == "true" || lowered == "yes" || lowered == "1"
}
}
return def
@ -1124,14 +1121,14 @@ func (e *Executor) moduleHostname(ctx context.Context, client *SSHClient, args m
}
// Set hostname
cmd := fmt.Sprintf("hostnamectl set-hostname %q || hostname %q", name, name)
cmd := 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))
_, _, _, _ = client.Run(ctx, sprintf("sed -i 's/127.0.1.1.*/127.0.1.1\t%s/' /etc/hosts", name))
return &TaskResult{Changed: true}, nil
}
@ -1147,13 +1144,13 @@ func (e *Executor) moduleSysctl(ctx context.Context, client *SSHClient, args map
if state == "absent" {
// Remove from sysctl.conf
cmd := fmt.Sprintf("sed -i '/%s/d' /etc/sysctl.conf", name)
cmd := 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)
cmd := 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
@ -1161,7 +1158,7 @@ func (e *Executor) moduleSysctl(ctx context.Context, client *SSHClient, args map
// 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",
cmd = 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)
}
@ -1184,7 +1181,7 @@ func (e *Executor) moduleCron(ctx context.Context, client *SSHClient, args map[s
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 -",
cmd := 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)
}
@ -1192,11 +1189,11 @@ func (e *Executor) moduleCron(ctx context.Context, client *SSHClient, args map[s
}
// 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)
schedule := sprintf("%s %s %s %s %s", minute, hour, day, month, weekday)
entry := 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 -",
cmd := 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 {
@ -1220,14 +1217,14 @@ func (e *Executor) moduleBlockinfile(ctx context.Context, client *SSHClient, arg
state := getStringArg(args, "state", "present")
create := getBoolArg(args, "create", false)
beginMarker := strings.Replace(marker, "{mark}", "BEGIN", 1)
endMarker := strings.Replace(marker, "{mark}", "END", 1)
beginMarker := replaceN(marker, "{mark}", "BEGIN", 1)
endMarker := replaceN(marker, "{mark}", "END", 1)
if state == "absent" {
// Remove block
cmd := fmt.Sprintf("sed -i '/%s/,/%s/d' %q",
strings.ReplaceAll(beginMarker, "/", "\\/"),
strings.ReplaceAll(endMarker, "/", "\\/"),
cmd := sprintf("sed -i '/%s/,/%s/d' %q",
replaceAll(beginMarker, "/", "\\/"),
replaceAll(endMarker, "/", "\\/"),
path)
_, _, _, _ = client.Run(ctx, cmd)
return &TaskResult{Changed: true}, nil
@ -1235,20 +1232,20 @@ func (e *Executor) moduleBlockinfile(ctx context.Context, client *SSHClient, arg
// Create file if needed (best-effort)
if create {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("touch %q", path))
_, _, _, _ = client.Run(ctx, sprintf("touch %q", path))
}
// Remove existing block and add new one
escapedBlock := strings.ReplaceAll(block, "'", "'\\''")
cmd := fmt.Sprintf(`
escapedBlock := replaceAll(block, "'", "'\\''")
cmd := 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, "/", "\\/"),
`, replaceAll(beginMarker, "/", "\\/"),
replaceAll(endMarker, "/", "\\/"),
path, path, beginMarker, escapedBlock, endMarker)
stdout, stderr, rc, err := client.RunScript(ctx, cmd)
@ -1294,10 +1291,10 @@ func (e *Executor) moduleReboot(ctx context.Context, client *SSHClient, args map
msg := getStringArg(args, "msg", "Reboot initiated by Ansible")
if preRebootDelay > 0 {
cmd := fmt.Sprintf("sleep %d && shutdown -r now '%s' &", preRebootDelay, msg)
cmd := sprintf("sleep %d && shutdown -r now '%s' &", preRebootDelay, msg)
_, _, _, _ = client.Run(ctx, cmd)
} else {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("shutdown -r now '%s' &", msg))
_, _, _, _ = client.Run(ctx, sprintf("shutdown -r now '%s' &", msg))
}
return &TaskResult{Changed: true, Msg: "Reboot initiated"}, nil
@ -1336,13 +1333,13 @@ func (e *Executor) moduleUFW(ctx context.Context, client *SSHClient, args map[st
if rule != "" && port != "" {
switch rule {
case "allow":
cmd = fmt.Sprintf("ufw allow %s/%s", port, proto)
cmd = sprintf("ufw allow %s/%s", port, proto)
case "deny":
cmd = fmt.Sprintf("ufw deny %s/%s", port, proto)
cmd = sprintf("ufw deny %s/%s", port, proto)
case "reject":
cmd = fmt.Sprintf("ufw reject %s/%s", port, proto)
cmd = sprintf("ufw reject %s/%s", port, proto)
case "limit":
cmd = fmt.Sprintf("ufw limit %s/%s", port, proto)
cmd = sprintf("ufw limit %s/%s", port, proto)
}
stdout, stderr, rc, err := client.Run(ctx, cmd)
@ -1364,11 +1361,11 @@ func (e *Executor) moduleAuthorizedKey(ctx context.Context, client *SSHClient, a
}
// Get user's home directory
stdout, _, _, err := client.Run(ctx, fmt.Sprintf("getent passwd %s | cut -d: -f6", user))
stdout, _, _, err := client.Run(ctx, sprintf("getent passwd %s | cut -d: -f6", user))
if err != nil {
return nil, coreerr.E("Executor.moduleAuthorizedKey", "get home dir", err)
}
home := strings.TrimSpace(stdout)
home := corexTrimSpace(stdout)
if home == "" {
home = "/root"
if user != "root" {
@ -1376,22 +1373,22 @@ func (e *Executor) moduleAuthorizedKey(ctx context.Context, client *SSHClient, a
}
}
authKeysPath := filepath.Join(home, ".ssh", "authorized_keys")
authKeysPath := joinPath(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)
escapedKey := replaceAll(key, "/", "\\/")
cmd := 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)))
_, _, _, _ = client.Run(ctx, sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q",
pathDir(authKeysPath), pathDir(authKeysPath), user, user, pathDir(authKeysPath)))
// Add key if not present
cmd := fmt.Sprintf("grep -qF %q %q 2>/dev/null || echo %q >> %q",
cmd := 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 {
@ -1399,7 +1396,7 @@ func (e *Executor) moduleAuthorizedKey(ctx context.Context, client *SSHClient, a
}
// Fix permissions (best-effort)
_, _, _, _ = client.Run(ctx, fmt.Sprintf("chmod 600 %q && chown %s:%s %q",
_, _, _, _ = client.Run(ctx, sprintf("chmod 600 %q && chown %s:%s %q",
authKeysPath, user, user, authKeysPath))
return &TaskResult{Changed: true}, nil
@ -1416,13 +1413,13 @@ func (e *Executor) moduleDockerCompose(ctx context.Context, client *SSHClient, a
var cmd string
switch state {
case "present":
cmd = fmt.Sprintf("cd %q && docker compose up -d", projectSrc)
cmd = sprintf("cd %q && docker compose up -d", projectSrc)
case "absent":
cmd = fmt.Sprintf("cd %q && docker compose down", projectSrc)
cmd = sprintf("cd %q && docker compose down", projectSrc)
case "restarted":
cmd = fmt.Sprintf("cd %q && docker compose restart", projectSrc)
cmd = sprintf("cd %q && docker compose restart", projectSrc)
default:
cmd = fmt.Sprintf("cd %q && docker compose up -d", projectSrc)
cmd = sprintf("cd %q && docker compose up -d", projectSrc)
}
stdout, stderr, rc, err := client.Run(ctx, cmd)
@ -1431,7 +1428,7 @@ func (e *Executor) moduleDockerCompose(ctx context.Context, client *SSHClient, a
}
// Heuristic for changed
changed := !strings.Contains(stdout, "Up to date") && !strings.Contains(stderr, "Up to date")
changed := !contains(stdout, "Up to date") && !contains(stderr, "Up to date")
return &TaskResult{Changed: changed, Stdout: stdout}, nil
}

View file

@ -1,12 +1,9 @@
package ansible
import (
"fmt"
"iter"
"maps"
"path/filepath"
"slices"
"strings"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
@ -42,7 +39,7 @@ func (p *Parser) ParsePlaybook(path string) ([]Play, error) {
// Process each play
for i := range plays {
if err := p.processPlay(&plays[i]); err != nil {
return nil, coreerr.E("Parser.ParsePlaybook", fmt.Sprintf("process play %d", i), err)
return nil, coreerr.E("Parser.ParsePlaybook", sprintf("process play %d", i), err)
}
}
@ -93,7 +90,7 @@ func (p *Parser) ParseTasks(path string) ([]Task, error) {
for i := range tasks {
if err := p.extractModule(&tasks[i]); err != nil {
return nil, coreerr.E("Parser.ParseTasks", fmt.Sprintf("task %d", i), err)
return nil, coreerr.E("Parser.ParseTasks", sprintf("task %d", i), err)
}
}
@ -124,21 +121,21 @@ func (p *Parser) ParseRole(name string, tasksFrom string) ([]Task, error) {
// Search paths for roles (in order of precedence)
searchPaths := []string{
// Relative to playbook
filepath.Join(p.basePath, "roles", name, "tasks", tasksFrom),
joinPath(p.basePath, "roles", name, "tasks", tasksFrom),
// Parent directory roles
filepath.Join(filepath.Dir(p.basePath), "roles", name, "tasks", tasksFrom),
joinPath(pathDir(p.basePath), "roles", name, "tasks", tasksFrom),
// Sibling roles directory
filepath.Join(p.basePath, "..", "roles", name, "tasks", tasksFrom),
joinPath(p.basePath, "..", "roles", name, "tasks", tasksFrom),
// playbooks/roles pattern
filepath.Join(p.basePath, "playbooks", "roles", name, "tasks", tasksFrom),
joinPath(p.basePath, "playbooks", "roles", name, "tasks", tasksFrom),
// Common DevOps structure
filepath.Join(filepath.Dir(filepath.Dir(p.basePath)), "roles", name, "tasks", tasksFrom),
joinPath(pathDir(pathDir(p.basePath)), "roles", name, "tasks", tasksFrom),
}
var tasksPath string
for _, sp := range searchPaths {
// Clean the path to resolve .. segments
sp = filepath.Clean(sp)
sp = cleanPath(sp)
if coreio.Local.Exists(sp) {
tasksPath = sp
break
@ -146,11 +143,11 @@ func (p *Parser) ParseRole(name string, tasksFrom string) ([]Task, error) {
}
if tasksPath == "" {
return nil, coreerr.E("Parser.ParseRole", fmt.Sprintf("role %s not found in search paths: %v", name, searchPaths), nil)
return nil, coreerr.E("Parser.ParseRole", sprintf("role %s not found in search paths: %v", name, searchPaths), nil)
}
// Load role defaults
defaultsPath := filepath.Join(filepath.Dir(filepath.Dir(tasksPath)), "defaults", "main.yml")
defaultsPath := joinPath(pathDir(pathDir(tasksPath)), "defaults", "main.yml")
if data, err := coreio.Local.Read(defaultsPath); err == nil {
var defaults map[string]any
if yaml.Unmarshal([]byte(data), &defaults) == nil {
@ -163,7 +160,7 @@ func (p *Parser) ParseRole(name string, tasksFrom string) ([]Task, error) {
}
// Load role vars
varsPath := filepath.Join(filepath.Dir(filepath.Dir(tasksPath)), "vars", "main.yml")
varsPath := joinPath(pathDir(pathDir(tasksPath)), "vars", "main.yml")
if data, err := coreio.Local.Read(varsPath); err == nil {
var roleVars map[string]any
if yaml.Unmarshal([]byte(data), &roleVars) == nil {
@ -185,25 +182,25 @@ func (p *Parser) processPlay(play *Play) error {
for i := range play.PreTasks {
if err := p.extractModule(&play.PreTasks[i]); err != nil {
return coreerr.E("Parser.processPlay", fmt.Sprintf("pre_task %d", i), err)
return coreerr.E("Parser.processPlay", sprintf("pre_task %d", i), err)
}
}
for i := range play.Tasks {
if err := p.extractModule(&play.Tasks[i]); err != nil {
return coreerr.E("Parser.processPlay", fmt.Sprintf("task %d", i), err)
return coreerr.E("Parser.processPlay", sprintf("task %d", i), err)
}
}
for i := range play.PostTasks {
if err := p.extractModule(&play.PostTasks[i]); err != nil {
return coreerr.E("Parser.processPlay", fmt.Sprintf("post_task %d", i), err)
return coreerr.E("Parser.processPlay", sprintf("post_task %d", i), err)
}
}
for i := range play.Handlers {
if err := p.extractModule(&play.Handlers[i]); err != nil {
return coreerr.E("Parser.processPlay", fmt.Sprintf("handler %d", i), err)
return coreerr.E("Parser.processPlay", sprintf("handler %d", i), err)
}
}
@ -308,20 +305,20 @@ func isModule(key string) bool {
return true
}
// Also check without ansible.builtin. prefix
if strings.HasPrefix(m, "ansible.builtin.") {
if key == strings.TrimPrefix(m, "ansible.builtin.") {
if corexHasPrefix(m, "ansible.builtin.") {
if key == corexTrimPrefix(m, "ansible.builtin.") {
return true
}
}
}
// Accept any key with dots (likely a module)
return strings.Contains(key, ".")
return contains(key, ".")
}
// NormalizeModule normalizes a module name to its canonical form.
func NormalizeModule(name string) string {
// Add ansible.builtin. prefix if missing
if !strings.Contains(name, ".") {
if !contains(name, ".") {
return "ansible.builtin." + name
}
return name

80
ssh.go
View file

@ -3,12 +3,9 @@ package ansible
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
@ -87,9 +84,8 @@ func (c *SSHClient) Connect(ctx context.Context) error {
// Try key-based auth first
if c.keyFile != "" {
keyPath := c.keyFile
if strings.HasPrefix(keyPath, "~") {
home, _ := os.UserHomeDir()
keyPath = filepath.Join(home, keyPath[1:])
if corexHasPrefix(keyPath, "~") {
keyPath = joinPath(env("DIR_HOME"), keyPath[1:])
}
if key, err := coreio.Local.Read(keyPath); err == nil {
@ -101,10 +97,10 @@ func (c *SSHClient) Connect(ctx context.Context) error {
// Try default SSH keys
if len(authMethods) == 0 {
home, _ := os.UserHomeDir()
home := env("DIR_HOME")
defaultKeys := []string{
filepath.Join(home, ".ssh", "id_ed25519"),
filepath.Join(home, ".ssh", "id_rsa"),
joinPath(home, ".ssh", "id_ed25519"),
joinPath(home, ".ssh", "id_rsa"),
}
for _, keyPath := range defaultKeys {
if key, err := coreio.Local.Read(keyPath); err == nil {
@ -135,15 +131,15 @@ func (c *SSHClient) Connect(ctx context.Context) error {
// Host key verification
var hostKeyCallback ssh.HostKeyCallback
home, err := os.UserHomeDir()
if err != nil {
return coreerr.E("ssh.Connect", "failed to get user home dir", err)
home := env("DIR_HOME")
if home == "" {
return coreerr.E("ssh.Connect", "failed to get user home dir", nil)
}
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
knownHostsPath := joinPath(home, ".ssh", "known_hosts")
// Ensure known_hosts file exists
if !coreio.Local.Exists(knownHostsPath) {
if err := coreio.Local.EnsureDir(filepath.Dir(knownHostsPath)); err != nil {
if err := coreio.Local.EnsureDir(pathDir(knownHostsPath)); err != nil {
return coreerr.E("ssh.Connect", "failed to create .ssh dir", err)
}
if err := coreio.Local.Write(knownHostsPath, ""); err != nil {
@ -164,19 +160,19 @@ func (c *SSHClient) Connect(ctx context.Context) error {
Timeout: c.timeout,
}
addr := fmt.Sprintf("%s:%d", c.host, c.port)
addr := sprintf("%s:%d", c.host, c.port)
// Connect with context timeout
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return coreerr.E("ssh.Connect", fmt.Sprintf("dial %s", addr), err)
return coreerr.E("ssh.Connect", sprintf("dial %s", addr), err)
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
// conn is closed by NewClientConn on error
return coreerr.E("ssh.Connect", fmt.Sprintf("ssh connect %s", addr), err)
return coreerr.E("ssh.Connect", sprintf("ssh connect %s", addr), err)
}
c.client = ssh.NewClient(sshConn, chans, reqs)
@ -219,33 +215,33 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
becomeUser = "root"
}
// Escape single quotes in the command
escapedCmd := strings.ReplaceAll(cmd, "'", "'\\''")
escapedCmd := replaceAll(cmd, "'", "'\\''")
if c.becomePass != "" {
// Use sudo with password via stdin (-S flag)
// We launch a goroutine to write the password to stdin
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
stdin, err := session.StdinPipe()
if err != nil {
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
}
go func() {
defer func() { _ = stdin.Close() }()
_, _ = io.WriteString(stdin, c.becomePass+"\n")
writeString(stdin, c.becomePass+"\n")
}()
} else if c.password != "" {
// Try using connection password for sudo
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
stdin, err := session.StdinPipe()
if err != nil {
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
}
go func() {
defer func() { _ = stdin.Close() }()
_, _ = io.WriteString(stdin, c.password+"\n")
writeString(stdin, c.password+"\n")
}()
} else {
// Try passwordless sudo
cmd = fmt.Sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
cmd = sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
}
}
@ -275,7 +271,7 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
// RunScript runs a script on the remote host.
func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
// Escape the script for heredoc
cmd := fmt.Sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
cmd := sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
return c.Run(ctx, cmd)
}
@ -286,23 +282,23 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
}
// Read content
content, err := io.ReadAll(local)
content, err := readAllString(local)
if err != nil {
return coreerr.E("ssh.Upload", "read content", err)
}
// Create parent directory
dir := filepath.Dir(remote)
dirCmd := fmt.Sprintf("mkdir -p %q", dir)
dir := pathDir(remote)
dirCmd := sprintf("mkdir -p %q", dir)
if c.become {
dirCmd = fmt.Sprintf("sudo mkdir -p %q", dir)
dirCmd = sprintf("sudo mkdir -p %q", dir)
}
if _, _, _, err := c.Run(ctx, dirCmd); err != nil {
return coreerr.E("ssh.Upload", "create parent dir", err)
}
// Use cat to write the file (simpler than SCP)
writeCmd := fmt.Sprintf("cat > %q && chmod %o %q", remote, mode, remote)
writeCmd := sprintf("cat > %q && chmod %o %q", remote, mode, remote)
// If become is needed, we construct a command that reads password then content from stdin
// But we need to be careful with handling stdin for sudo + cat.
@ -335,11 +331,11 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
if pass != "" {
// Use sudo -S with password from stdin
writeCmd = fmt.Sprintf("sudo -S -u %s bash -c 'cat > %q && chmod %o %q'",
writeCmd = sprintf("sudo -S -u %s bash -c 'cat > %q && chmod %o %q'",
becomeUser, remote, mode, remote)
} else {
// Use passwordless sudo (sudo -n) to avoid consuming file content as password
writeCmd = fmt.Sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
writeCmd = sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
becomeUser, remote, mode, remote)
}
@ -350,9 +346,9 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
go func() {
defer func() { _ = stdin.Close() }()
if pass != "" {
_, _ = io.WriteString(stdin, pass+"\n")
writeString(stdin, pass+"\n")
}
_, _ = stdin.Write(content)
_, _ = stdin.Write([]byte(content))
}()
} else {
// Normal write
@ -362,12 +358,12 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
go func() {
defer func() { _ = stdin.Close() }()
_, _ = stdin.Write(content)
_, _ = stdin.Write([]byte(content))
}()
}
if err := session2.Wait(); err != nil {
return coreerr.E("ssh.Upload", fmt.Sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
return coreerr.E("ssh.Upload", sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
}
return nil
@ -379,14 +375,14 @@ func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error)
return nil, err
}
cmd := fmt.Sprintf("cat %q", remote)
cmd := sprintf("cat %q", remote)
stdout, stderr, exitCode, err := c.Run(ctx, cmd)
if err != nil {
return nil, err
}
if exitCode != 0 {
return nil, coreerr.E("ssh.Download", fmt.Sprintf("cat failed: %s", stderr), nil)
return nil, coreerr.E("ssh.Download", sprintf("cat failed: %s", stderr), nil)
}
return []byte(stdout), nil
@ -394,7 +390,7 @@ func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error)
// FileExists checks if a file exists on the remote host.
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
cmd := fmt.Sprintf("test -e %q && echo yes || echo no", path)
cmd := sprintf("test -e %q && echo yes || echo no", path)
stdout, _, exitCode, err := c.Run(ctx, cmd)
if err != nil {
return false, err
@ -403,13 +399,13 @@ func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
// test command failed but didn't error - file doesn't exist
return false, nil
}
return strings.TrimSpace(stdout) == "yes", nil
return corexTrimSpace(stdout) == "yes", nil
}
// Stat returns file info from the remote host.
func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error) {
// Simple approach - get basic file info
cmd := fmt.Sprintf(`
cmd := sprintf(`
if [ -e %q ]; then
if [ -d %q ]; then
echo "exists=true isdir=true"
@ -427,9 +423,9 @@ fi
}
result := make(map[string]any)
parts := strings.Fields(strings.TrimSpace(stdout))
parts := fields(corexTrimSpace(stdout))
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
kv := splitN(part, "=", 2)
if len(kv) == 2 {
result[kv[0]] = kv[1] == "true"
}