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

View file

@ -9,25 +9,25 @@ func Register(c *core.Core) {
c.Command("ansible", core.Command{ c.Command("ansible", core.Command{
Description: "Run Ansible playbooks natively (no Python required)", Description: "Run Ansible playbooks natively (no Python required)",
Action: runAnsible, Action: runAnsible,
Flags: core.Options{ Flags: core.NewOptions(
{Key: "inventory", Value: ""}, core.Option{Key: "inventory", Value: ""},
{Key: "limit", Value: ""}, core.Option{Key: "limit", Value: ""},
{Key: "tags", Value: ""}, core.Option{Key: "tags", Value: ""},
{Key: "skip-tags", Value: ""}, core.Option{Key: "skip-tags", Value: ""},
{Key: "extra-vars", Value: ""}, core.Option{Key: "extra-vars", Value: ""},
{Key: "verbose", Value: 0}, core.Option{Key: "verbose", Value: 0},
{Key: "check", Value: false}, core.Option{Key: "check", Value: false},
}, ),
}) })
c.Command("ansible/test", core.Command{ c.Command("ansible/test", core.Command{
Description: "Test SSH connectivity to a host", Description: "Test SSH connectivity to a host",
Action: runAnsibleTest, Action: runAnsibleTest,
Flags: core.Options{ Flags: core.NewOptions(
{Key: "user", Value: "root"}, core.Option{Key: "user", Value: "root"},
{Key: "password", Value: ""}, core.Option{Key: "password", Value: ""},
{Key: "key", Value: ""}, core.Option{Key: "key", Value: ""},
{Key: "port", Value: 22}, 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 ( import (
"context" "context"
"fmt"
"os"
"regexp" "regexp"
"slices" "slices"
"strings"
"sync" "sync"
"text/template" "text/template"
"time" "time"
@ -86,7 +83,7 @@ func (e *Executor) Run(ctx context.Context, playbookPath string) error {
for i := range plays { for i := range plays {
if err := e.runPlay(ctx, &plays[i]); err != nil { 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 // Parse role tasks
tasks, err := e.parser.ParseRole(roleRef.Role, roleRef.TasksFrom) tasks, err := e.parser.ParseRole(roleRef.Role, roleRef.TasksFrom)
if err != nil { 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 // Merge role vars
@ -267,7 +264,7 @@ func (e *Executor) runTaskOnHost(ctx context.Context, host string, task *Task, p
// Get SSH client // Get SSH client
client, err := e.getClient(host, play) client, err := e.getClient(host, play)
if err != nil { 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 // Handle loops
@ -485,7 +482,7 @@ func (e *Executor) getHosts(pattern string) []string {
var filtered []string var filtered []string
for _, h := range hosts { 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) filtered = append(filtered, h)
} }
} }
@ -582,32 +579,32 @@ func (e *Executor) gatherFacts(ctx context.Context, host string, play *Play) err
// Hostname // Hostname
stdout, _, _, err := client.Run(ctx, "hostname -f 2>/dev/null || hostname") stdout, _, _, err := client.Run(ctx, "hostname -f 2>/dev/null || hostname")
if err == nil { if err == nil {
facts.FQDN = strings.TrimSpace(stdout) facts.FQDN = corexTrimSpace(stdout)
} }
stdout, _, _, err = client.Run(ctx, "hostname -s 2>/dev/null || hostname") stdout, _, _, err = client.Run(ctx, "hostname -s 2>/dev/null || hostname")
if err == nil { if err == nil {
facts.Hostname = strings.TrimSpace(stdout) facts.Hostname = corexTrimSpace(stdout)
} }
// OS info // OS info
stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep -E '^(ID|VERSION_ID)=' | head -2") 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") { for _, line := range split(stdout, "\n") {
if strings.HasPrefix(line, "ID=") { if corexHasPrefix(line, "ID=") {
facts.Distribution = strings.Trim(strings.TrimPrefix(line, "ID="), "\"") facts.Distribution = trimCutset(corexTrimPrefix(line, "ID="), "\"")
} }
if strings.HasPrefix(line, "VERSION_ID=") { if corexHasPrefix(line, "VERSION_ID=") {
facts.Version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"") facts.Version = trimCutset(corexTrimPrefix(line, "VERSION_ID="), "\"")
} }
} }
// Architecture // Architecture
stdout, _, _, _ = client.Run(ctx, "uname -m") stdout, _, _, _ = client.Run(ctx, "uname -m")
facts.Architecture = strings.TrimSpace(stdout) facts.Architecture = corexTrimSpace(stdout)
// Kernel // Kernel
stdout, _, _, _ = client.Run(ctx, "uname -r") stdout, _, _, _ = client.Run(ctx, "uname -r")
facts.Kernel = strings.TrimSpace(stdout) facts.Kernel = corexTrimSpace(stdout)
e.mu.Lock() e.mu.Lock()
e.facts[host] = facts e.facts[host] = facts
@ -650,11 +647,11 @@ func normalizeConditions(when any) []string {
// evalCondition evaluates a single condition. // evalCondition evaluates a single condition.
func (e *Executor) evalCondition(cond string, host string) bool { func (e *Executor) evalCondition(cond string, host string) bool {
cond = strings.TrimSpace(cond) cond = corexTrimSpace(cond)
// Handle negation // Handle negation
if strings.HasPrefix(cond, "not ") { if corexHasPrefix(cond, "not ") {
return !e.evalCondition(strings.TrimPrefix(cond, "not "), host) return !e.evalCondition(corexTrimPrefix(cond, "not "), host)
} }
// Handle boolean literals // Handle boolean literals
@ -667,10 +664,10 @@ func (e *Executor) evalCondition(cond string, host string) bool {
// Handle registered variable checks // Handle registered variable checks
// e.g., "result is success", "result.rc == 0" // e.g., "result is success", "result.rc == 0"
if strings.Contains(cond, " is ") { if contains(cond, " is ") {
parts := strings.SplitN(cond, " is ", 2) parts := splitN(cond, " is ", 2)
varName := strings.TrimSpace(parts[0]) varName := corexTrimSpace(parts[0])
check := strings.TrimSpace(parts[1]) check := corexTrimSpace(parts[1])
result := e.getRegisteredVar(host, varName) result := e.getRegisteredVar(host, varName)
if result == nil { if result == nil {
@ -694,7 +691,7 @@ func (e *Executor) evalCondition(cond string, host string) bool {
} }
// Handle simple var checks // Handle simple var checks
if strings.Contains(cond, " | default(") { if contains(cond, " | default(") {
// Extract var name and check if defined // Extract var name and check if defined
re := regexp.MustCompile(`(\w+)\s*\|\s*default\([^)]*\)`) re := regexp.MustCompile(`(\w+)\s*\|\s*default\([^)]*\)`)
if match := re.FindStringSubmatch(cond); len(match) > 1 { 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() defer e.mu.RUnlock()
// Handle dotted access (e.g., "result.stdout") // Handle dotted access (e.g., "result.stdout")
parts := strings.SplitN(name, ".", 2) parts := splitN(name, ".", 2)
varName := parts[0] varName := parts[0]
if hostResults, ok := e.results[host]; ok { 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*\}\}`) re := regexp.MustCompile(`\{\{\s*([^}]+)\s*\}\}`)
return re.ReplaceAllStringFunc(s, func(match string) string { 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) 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. // resolveExpr resolves a template expression.
func (e *Executor) resolveExpr(expr string, host string, task *Task) string { func (e *Executor) resolveExpr(expr string, host string, task *Task) string {
// Handle filters // Handle filters
if strings.Contains(expr, " | ") { if contains(expr, " | ") {
parts := strings.SplitN(expr, " | ", 2) parts := splitN(expr, " | ", 2)
value := e.resolveExpr(parts[0], host, task) value := e.resolveExpr(parts[0], host, task)
return e.applyFilter(value, parts[1]) return e.applyFilter(value, parts[1])
} }
// Handle lookups // Handle lookups
if strings.HasPrefix(expr, "lookup(") { if corexHasPrefix(expr, "lookup(") {
return e.handleLookup(expr) return e.handleLookup(expr)
} }
// Handle registered vars // Handle registered vars
if strings.Contains(expr, ".") { if contains(expr, ".") {
parts := strings.SplitN(expr, ".", 2) parts := splitN(expr, ".", 2)
if result := e.getRegisteredVar(host, parts[0]); result != nil { if result := e.getRegisteredVar(host, parts[0]); result != nil {
switch parts[1] { switch parts[1] {
case "stdout": case "stdout":
@ -777,24 +774,24 @@ func (e *Executor) resolveExpr(expr string, host string, task *Task) string {
case "stderr": case "stderr":
return result.Stderr return result.Stderr
case "rc": case "rc":
return fmt.Sprintf("%d", result.RC) return sprintf("%d", result.RC)
case "changed": case "changed":
return fmt.Sprintf("%t", result.Changed) return sprintf("%t", result.Changed)
case "failed": case "failed":
return fmt.Sprintf("%t", result.Failed) return sprintf("%t", result.Failed)
} }
} }
} }
// Check vars // Check vars
if val, ok := e.vars[expr]; ok { if val, ok := e.vars[expr]; ok {
return fmt.Sprintf("%v", val) return sprintf("%v", val)
} }
// Check task vars // Check task vars
if task != nil { if task != nil {
if val, ok := task.Vars[expr]; ok { 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 { if e.inventory != nil {
hostVars := GetHostVars(e.inventory, host) hostVars := GetHostVars(e.inventory, host)
if val, ok := hostVars[expr]; ok { 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. // applyFilter applies a Jinja2 filter.
func (e *Executor) applyFilter(value, filter string) string { func (e *Executor) applyFilter(value, filter string) string {
filter = strings.TrimSpace(filter) filter = corexTrimSpace(filter)
// Handle default filter // Handle default filter
if strings.HasPrefix(filter, "default(") { if corexHasPrefix(filter, "default(") {
if value == "" || value == "{{ "+filter+" }}" { if value == "" || value == "{{ "+filter+" }}" {
// Extract default value // Extract default value
re := regexp.MustCompile(`default\(([^)]*)\)`) re := regexp.MustCompile(`default\(([^)]*)\)`)
if match := re.FindStringSubmatch(filter); len(match) > 1 { if match := re.FindStringSubmatch(filter); len(match) > 1 {
return strings.Trim(match[1], "'\"") return trimCutset(match[1], "'\"")
} }
} }
return value return value
@ -845,8 +842,8 @@ func (e *Executor) applyFilter(value, filter string) string {
// Handle bool filter // Handle bool filter
if filter == "bool" { if filter == "bool" {
lower := strings.ToLower(value) lowered := lower(value)
if lower == "true" || lower == "yes" || lower == "1" { if lowered == "true" || lowered == "yes" || lowered == "1" {
return "true" return "true"
} }
return "false" return "false"
@ -854,7 +851,7 @@ func (e *Executor) applyFilter(value, filter string) string {
// Handle trim // Handle trim
if filter == "trim" { if filter == "trim" {
return strings.TrimSpace(value) return corexTrimSpace(value)
} }
// Handle b64decode // Handle b64decode
@ -880,7 +877,7 @@ func (e *Executor) handleLookup(expr string) string {
switch lookupType { switch lookupType {
case "env": case "env":
return os.Getenv(arg) return env(arg)
case "file": case "file":
if data, err := coreio.Local.Read(arg); err == nil { if data, err := coreio.Local.Read(arg); err == nil {
return data 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) // Convert Jinja2 to Go template syntax (basic conversion)
tmplContent := content tmplContent := content
tmplContent = strings.ReplaceAll(tmplContent, "{{", "{{ .") tmplContent = replaceAll(tmplContent, "{{", "{{ .")
tmplContent = strings.ReplaceAll(tmplContent, "{%", "{{") tmplContent = replaceAll(tmplContent, "{%", "{{")
tmplContent = strings.ReplaceAll(tmplContent, "%}", "}}") tmplContent = replaceAll(tmplContent, "%}", "}}")
tmpl, err := template.New("template").Parse(tmplContent) tmpl, err := template.New("template").Parse(tmplContent)
if err != nil { if err != nil {
@ -1010,8 +1007,8 @@ func (e *Executor) TemplateFile(src, host string, task *Task) (string, error) {
context["ansible_kernel"] = facts.Kernel context["ansible_kernel"] = facts.Kernel
} }
var buf strings.Builder buf := newBuilder()
if err := tmpl.Execute(&buf, context); err != nil { if err := tmpl.Execute(buf, context); err != nil {
return e.templateString(content, host, task), 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 go 1.26.0
require ( 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/io v0.2.0
dappco.re/go/core/log v0.1.0 dappco.re/go/core/log v0.1.0
github.com/stretchr/testify v1.11.1 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.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= 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 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=

View file

@ -3,11 +3,8 @@ package ansible
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"fmt"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings"
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log" coreerr "dappco.re/go/core/log"
@ -135,7 +132,7 @@ func (e *Executor) executeModule(ctx context.Context, host string, client *SSHCl
default: default:
// For unknown modules, try to execute as shell if it looks like a command // 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 e.moduleShell(ctx, client, args)
} }
return nil, coreerr.E("Executor.executeModule", "unsupported module: "+module, nil) 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 // Handle chdir
if chdir := getStringArg(args, "chdir", ""); 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) 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 // Handle chdir
if chdir := getStringArg(args, "chdir", ""); 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) 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 { if err != nil {
return nil, err return nil, err
} }
// Handle owner/group (best-effort, errors ignored) // Handle owner/group (best-effort, errors ignored)
if owner := getStringArg(args, "owner", ""); owner != "" { 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 != "" { 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) { 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 { if err != nil {
return nil, err 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) { 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 { switch state {
case "directory": case "directory":
mode := getStringArg(args, "mode", "0755") 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) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
} }
case "absent": case "absent":
cmd := fmt.Sprintf("rm -rf %q", path) cmd := sprintf("rm -rf %q", path)
_, stderr, rc, err := client.Run(ctx, cmd) _, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil
} }
case "touch": case "touch":
cmd := fmt.Sprintf("touch %q", path) cmd := sprintf("touch %q", path)
_, stderr, rc, err := client.Run(ctx, cmd) _, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil 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 == "" { if src == "" {
return nil, coreerr.E("Executor.moduleFile", "src required for link state", nil) 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) _, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil 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": case "file":
// Ensure file exists and set permissions // Ensure file exists and set permissions
if mode := getStringArg(args, "mode", ""); mode != "" { 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) // Handle owner/group (best-effort, errors ignored)
if owner := getStringArg(args, "owner", ""); owner != "" { 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 != "" { 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 recurse := getBoolArg(args, "recurse", false); recurse {
if owner := getStringArg(args, "owner", ""); owner != "" { 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 state == "absent" {
if regexp != "" { 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) _, stderr, rc, _ := client.Run(ctx, cmd)
if rc != 0 { if rc != 0 {
return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil 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 // state == present
if regexp != "" { if regexp != "" {
// Replace line matching regexp // Replace line matching regexp
escapedLine := strings.ReplaceAll(line, "/", "\\/") escapedLine := replaceAll(line, "/", "\\/")
cmd := fmt.Sprintf("sed -i 's/%s/%s/' %q", regexp, escapedLine, path) cmd := sprintf("sed -i 's/%s/%s/' %q", regexp, escapedLine, path)
_, _, rc, _ := client.Run(ctx, cmd) _, _, rc, _ := client.Run(ctx, cmd)
if rc != 0 { if rc != 0 {
// Line not found, append // Line not found, append
cmd = fmt.Sprintf("echo %q >> %q", line, path) cmd = sprintf("echo %q >> %q", line, path)
_, _, _, _ = client.Run(ctx, cmd) _, _, _, _ = client.Run(ctx, cmd)
} }
} else if line != "" { } else if line != "" {
// Ensure line is present // 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) _, _, _, _ = client.Run(ctx, cmd)
} }
} }
@ -512,7 +509,7 @@ func (e *Executor) moduleFetch(ctx context.Context, client *SSHClient, args map[
} }
// Create dest directory // 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 return nil, err
} }
@ -520,7 +517,7 @@ func (e *Executor) moduleFetch(ctx context.Context, client *SSHClient, args map[
return nil, err 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) { 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 // 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) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil 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) // Set mode if specified (best-effort)
if mode := getStringArg(args, "mode", ""); mode != "" { 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 return &TaskResult{Changed: true}, nil
@ -561,12 +558,12 @@ func (e *Executor) moduleApt(ctx context.Context, client *SSHClient, args map[st
switch state { switch state {
case "present", "installed": case "present", "installed":
if name != "" { 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": 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": 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 == "" { if cmd == "" {
@ -588,7 +585,7 @@ func (e *Executor) moduleAptKey(ctx context.Context, client *SSHClient, args map
if state == "absent" { if state == "absent" {
if keyring != "" { if keyring != "" {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("rm -f %q", keyring)) _, _, _, _ = client.Run(ctx, sprintf("rm -f %q", keyring))
} }
return &TaskResult{Changed: true}, nil return &TaskResult{Changed: true}, nil
} }
@ -599,9 +596,9 @@ func (e *Executor) moduleAptKey(ctx context.Context, client *SSHClient, args map
var cmd string var cmd string
if keyring != "" { 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 { } 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) stdout, stderr, rc, err := client.Run(ctx, cmd)
@ -623,19 +620,19 @@ func (e *Executor) moduleAptRepository(ctx context.Context, client *SSHClient, a
if filename == "" { if filename == "" {
// Generate filename from repo // Generate filename from repo
filename = strings.ReplaceAll(repo, " ", "-") filename = replaceAll(repo, " ", "-")
filename = strings.ReplaceAll(filename, "/", "-") filename = replaceAll(filename, "/", "-")
filename = strings.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" { 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 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) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil 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) { func (e *Executor) modulePackage(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) {
// Detect package manager and delegate // Detect package manager and delegate
stdout, _, _, _ := client.Run(ctx, "which apt-get yum dnf 2>/dev/null | head -1") 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) 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 var cmd string
switch state { switch state {
case "present", "installed": case "present", "installed":
cmd = fmt.Sprintf("%s install %s", executable, name) cmd = sprintf("%s install %s", executable, name)
case "absent", "removed": case "absent", "removed":
cmd = fmt.Sprintf("%s uninstall -y %s", executable, name) cmd = sprintf("%s uninstall -y %s", executable, name)
case "latest": 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) 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 != "" { if state != "" {
switch state { switch state {
case "started": case "started":
cmds = append(cmds, fmt.Sprintf("systemctl start %s", name)) cmds = append(cmds, sprintf("systemctl start %s", name))
case "stopped": case "stopped":
cmds = append(cmds, fmt.Sprintf("systemctl stop %s", name)) cmds = append(cmds, sprintf("systemctl stop %s", name))
case "restarted": case "restarted":
cmds = append(cmds, fmt.Sprintf("systemctl restart %s", name)) cmds = append(cmds, sprintf("systemctl restart %s", name))
case "reloaded": case "reloaded":
cmds = append(cmds, fmt.Sprintf("systemctl reload %s", name)) cmds = append(cmds, sprintf("systemctl reload %s", name))
} }
} }
if enabled != nil { if enabled != nil {
if getBoolArg(args, "enabled", false) { if getBoolArg(args, "enabled", false) {
cmds = append(cmds, fmt.Sprintf("systemctl enable %s", name)) cmds = append(cmds, sprintf("systemctl enable %s", name))
} else { } 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" { 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) _, _, _, _ = client.Run(ctx, cmd)
return &TaskResult{Changed: true}, nil 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 // Try usermod first, then useradd
optsStr := strings.Join(opts, " ") optsStr := join(" ", opts)
var cmd string var cmd string
if optsStr == "" { 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 { } 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) name, optsStr, name, optsStr, name)
} }
@ -806,7 +803,7 @@ func (e *Executor) moduleGroup(ctx context.Context, client *SSHClient, args map[
} }
if state == "absent" { 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) _, _, _, _ = client.Run(ctx, cmd)
return &TaskResult{Changed: true}, nil return &TaskResult{Changed: true}, nil
} }
@ -819,8 +816,8 @@ func (e *Executor) moduleGroup(ctx context.Context, client *SSHClient, args map[
opts = append(opts, "-r") opts = append(opts, "-r")
} }
cmd := fmt.Sprintf("getent group %s >/dev/null 2>&1 || groupadd %s %s", cmd := sprintf("getent group %s >/dev/null 2>&1 || groupadd %s %s",
name, strings.Join(opts, " "), name) name, join(" ", opts), name)
stdout, stderr, rc, err := client.Run(ctx, cmd) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
@ -847,7 +844,7 @@ func (e *Executor) moduleURI(ctx context.Context, client *SSHClient, args map[st
// Headers // Headers
if headers, ok := args["headers"].(map[string]any); ok { if headers, ok := args["headers"].(map[string]any); ok {
for k, v := range headers { 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 // Status code
curlOpts = append(curlOpts, "-w", "\\n%{http_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) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil { if err != nil {
return &TaskResult{Failed: true, Msg: err.Error()}, nil return &TaskResult{Failed: true, Msg: err.Error()}, nil
} }
// Parse status code from last line // Parse status code from last line
lines := strings.Split(strings.TrimSpace(stdout), "\n") lines := split(corexTrimSpace(stdout), "\n")
statusCode := 0 statusCode := 0
if len(lines) > 0 { if len(lines) > 0 {
statusCode, _ = strconv.Atoi(lines[len(lines)-1]) 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) { func (e *Executor) moduleDebug(args map[string]any) (*TaskResult, error) {
msg := getStringArg(args, "msg", "") msg := getStringArg(args, "msg", "")
if v, ok := args["var"]; ok { 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{ return &TaskResult{
@ -921,7 +918,7 @@ func (e *Executor) moduleAssert(args map[string]any, host string) (*TaskResult,
conditions := normalizeConditions(that) conditions := normalizeConditions(that)
for _, cond := range conditions { for _, cond := range conditions {
if !e.evalCondition(cond, host) { 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 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" { 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) timeout, host, port)
stdout, stderr, rc, err := client.Run(ctx, cmd) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
@ -1025,9 +1022,9 @@ func (e *Executor) moduleGit(ctx context.Context, client *SSHClient, args map[st
var cmd string var cmd string
if exists { if exists {
// Fetch and checkout (force to ensure clean state) // 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 { } 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) repo, dest, dest, version)
} }
@ -1049,7 +1046,7 @@ func (e *Executor) moduleUnarchive(ctx context.Context, client *SSHClient, args
} }
// Create dest directory (best-effort) // 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 var cmd string
if !remote { if !remote {
@ -1058,28 +1055,28 @@ func (e *Executor) moduleUnarchive(ctx context.Context, client *SSHClient, args
if err != nil { if err != nil {
return nil, coreerr.E("Executor.moduleUnarchive", "read src", err) return nil, coreerr.E("Executor.moduleUnarchive", "read src", err)
} }
tmpPath := "/tmp/ansible_unarchive_" + filepath.Base(src) tmpPath := "/tmp/ansible_unarchive_" + pathBase(src)
err = client.Upload(ctx, strings.NewReader(data), tmpPath, 0644) err = client.Upload(ctx, newReader(data), tmpPath, 0644)
if err != nil { if err != nil {
return nil, err return nil, err
} }
src = tmpPath 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 // Detect archive type and extract
if strings.HasSuffix(src, ".tar.gz") || strings.HasSuffix(src, ".tgz") { if hasSuffix(src, ".tar.gz") || hasSuffix(src, ".tgz") {
cmd = fmt.Sprintf("tar -xzf %q -C %q", src, dest) cmd = sprintf("tar -xzf %q -C %q", src, dest)
} else if strings.HasSuffix(src, ".tar.xz") { } else if hasSuffix(src, ".tar.xz") {
cmd = fmt.Sprintf("tar -xJf %q -C %q", src, dest) cmd = sprintf("tar -xJf %q -C %q", src, dest)
} else if strings.HasSuffix(src, ".tar.bz2") { } else if hasSuffix(src, ".tar.bz2") {
cmd = fmt.Sprintf("tar -xjf %q -C %q", src, dest) cmd = sprintf("tar -xjf %q -C %q", src, dest)
} else if strings.HasSuffix(src, ".tar") { } else if hasSuffix(src, ".tar") {
cmd = fmt.Sprintf("tar -xf %q -C %q", src, dest) cmd = sprintf("tar -xf %q -C %q", src, dest)
} else if strings.HasSuffix(src, ".zip") { } else if hasSuffix(src, ".zip") {
cmd = fmt.Sprintf("unzip -o %q -d %q", src, dest) cmd = sprintf("unzip -o %q -d %q", src, dest)
} else { } 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) 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 { if s, ok := v.(string); ok {
return s return s
} }
return fmt.Sprintf("%v", v) return sprintf("%v", v)
} }
return def return def
} }
@ -1108,8 +1105,8 @@ func getBoolArg(args map[string]any, key string, def bool) bool {
case bool: case bool:
return b return b
case string: case string:
lower := strings.ToLower(b) lowered := lower(b)
return lower == "true" || lower == "yes" || lower == "1" return lowered == "true" || lowered == "yes" || lowered == "1"
} }
} }
return def return def
@ -1124,14 +1121,14 @@ func (e *Executor) moduleHostname(ctx context.Context, client *SSHClient, args m
} }
// Set hostname // 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) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil
} }
// Update /etc/hosts if needed (best-effort) // 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 return &TaskResult{Changed: true}, nil
} }
@ -1147,13 +1144,13 @@ func (e *Executor) moduleSysctl(ctx context.Context, client *SSHClient, args map
if state == "absent" { if state == "absent" {
// Remove from sysctl.conf // 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) _, _, _, _ = client.Run(ctx, cmd)
return &TaskResult{Changed: true}, nil return &TaskResult{Changed: true}, nil
} }
// Set value // 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) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil 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) // Persist if requested (best-effort)
if getBoolArg(args, "sysctl_set", true) { 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) name, name, name, value, name, value)
_, _, _, _ = client.Run(ctx, cmd) _, _, _, _ = client.Run(ctx, cmd)
} }
@ -1184,7 +1181,7 @@ func (e *Executor) moduleCron(ctx context.Context, client *SSHClient, args map[s
if state == "absent" { if state == "absent" {
if name != "" { if name != "" {
// Remove by name (comment marker) // 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) user, name, job, user)
_, _, _, _ = client.Run(ctx, cmd) _, _, _, _ = client.Run(ctx, cmd)
} }
@ -1192,11 +1189,11 @@ func (e *Executor) moduleCron(ctx context.Context, client *SSHClient, args map[s
} }
// Build cron entry // Build cron entry
schedule := fmt.Sprintf("%s %s %s %s %s", minute, hour, day, month, weekday) schedule := sprintf("%s %s %s %s %s", minute, hour, day, month, weekday)
entry := fmt.Sprintf("%s %s # %s", schedule, job, name) entry := sprintf("%s %s # %s", schedule, job, name)
// Add to crontab // 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) user, name, entry, user)
stdout, stderr, rc, err := client.Run(ctx, cmd) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
@ -1220,14 +1217,14 @@ func (e *Executor) moduleBlockinfile(ctx context.Context, client *SSHClient, arg
state := getStringArg(args, "state", "present") state := getStringArg(args, "state", "present")
create := getBoolArg(args, "create", false) create := getBoolArg(args, "create", false)
beginMarker := strings.Replace(marker, "{mark}", "BEGIN", 1) beginMarker := replaceN(marker, "{mark}", "BEGIN", 1)
endMarker := strings.Replace(marker, "{mark}", "END", 1) endMarker := replaceN(marker, "{mark}", "END", 1)
if state == "absent" { if state == "absent" {
// Remove block // Remove block
cmd := fmt.Sprintf("sed -i '/%s/,/%s/d' %q", cmd := sprintf("sed -i '/%s/,/%s/d' %q",
strings.ReplaceAll(beginMarker, "/", "\\/"), replaceAll(beginMarker, "/", "\\/"),
strings.ReplaceAll(endMarker, "/", "\\/"), replaceAll(endMarker, "/", "\\/"),
path) path)
_, _, _, _ = client.Run(ctx, cmd) _, _, _, _ = client.Run(ctx, cmd)
return &TaskResult{Changed: true}, nil 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) // Create file if needed (best-effort)
if create { if create {
_, _, _, _ = client.Run(ctx, fmt.Sprintf("touch %q", path)) _, _, _, _ = client.Run(ctx, sprintf("touch %q", path))
} }
// Remove existing block and add new one // Remove existing block and add new one
escapedBlock := strings.ReplaceAll(block, "'", "'\\''") escapedBlock := replaceAll(block, "'", "'\\''")
cmd := fmt.Sprintf(` cmd := sprintf(`
sed -i '/%s/,/%s/d' %q 2>/dev/null || true sed -i '/%s/,/%s/d' %q 2>/dev/null || true
cat >> %q << 'BLOCK_EOF' cat >> %q << 'BLOCK_EOF'
%s %s
%s %s
%s %s
BLOCK_EOF BLOCK_EOF
`, strings.ReplaceAll(beginMarker, "/", "\\/"), `, replaceAll(beginMarker, "/", "\\/"),
strings.ReplaceAll(endMarker, "/", "\\/"), replaceAll(endMarker, "/", "\\/"),
path, path, beginMarker, escapedBlock, endMarker) path, path, beginMarker, escapedBlock, endMarker)
stdout, stderr, rc, err := client.RunScript(ctx, cmd) 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") msg := getStringArg(args, "msg", "Reboot initiated by Ansible")
if preRebootDelay > 0 { 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) _, _, _, _ = client.Run(ctx, cmd)
} else { } 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 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 != "" { if rule != "" && port != "" {
switch rule { switch rule {
case "allow": case "allow":
cmd = fmt.Sprintf("ufw allow %s/%s", port, proto) cmd = sprintf("ufw allow %s/%s", port, proto)
case "deny": case "deny":
cmd = fmt.Sprintf("ufw deny %s/%s", port, proto) cmd = sprintf("ufw deny %s/%s", port, proto)
case "reject": case "reject":
cmd = fmt.Sprintf("ufw reject %s/%s", port, proto) cmd = sprintf("ufw reject %s/%s", port, proto)
case "limit": 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) 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 // 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 { if err != nil {
return nil, coreerr.E("Executor.moduleAuthorizedKey", "get home dir", err) return nil, coreerr.E("Executor.moduleAuthorizedKey", "get home dir", err)
} }
home := strings.TrimSpace(stdout) home := corexTrimSpace(stdout)
if home == "" { if home == "" {
home = "/root" home = "/root"
if user != "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" { if state == "absent" {
// Remove key // Remove key
escapedKey := strings.ReplaceAll(key, "/", "\\/") escapedKey := replaceAll(key, "/", "\\/")
cmd := fmt.Sprintf("sed -i '/%s/d' %q 2>/dev/null || true", escapedKey[:40], authKeysPath) cmd := sprintf("sed -i '/%s/d' %q 2>/dev/null || true", escapedKey[:40], authKeysPath)
_, _, _, _ = client.Run(ctx, cmd) _, _, _, _ = client.Run(ctx, cmd)
return &TaskResult{Changed: true}, nil return &TaskResult{Changed: true}, nil
} }
// Ensure .ssh directory exists (best-effort) // Ensure .ssh directory exists (best-effort)
_, _, _, _ = client.Run(ctx, fmt.Sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q", _, _, _, _ = client.Run(ctx, sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q",
filepath.Dir(authKeysPath), filepath.Dir(authKeysPath), user, user, filepath.Dir(authKeysPath))) pathDir(authKeysPath), pathDir(authKeysPath), user, user, pathDir(authKeysPath)))
// Add key if not present // 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) key[:40], authKeysPath, key, authKeysPath)
stdout, stderr, rc, err := client.Run(ctx, cmd) stdout, stderr, rc, err := client.Run(ctx, cmd)
if err != nil || rc != 0 { if err != nil || rc != 0 {
@ -1399,7 +1396,7 @@ func (e *Executor) moduleAuthorizedKey(ctx context.Context, client *SSHClient, a
} }
// Fix permissions (best-effort) // 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)) authKeysPath, user, user, authKeysPath))
return &TaskResult{Changed: true}, nil return &TaskResult{Changed: true}, nil
@ -1416,13 +1413,13 @@ func (e *Executor) moduleDockerCompose(ctx context.Context, client *SSHClient, a
var cmd string var cmd string
switch state { switch state {
case "present": case "present":
cmd = fmt.Sprintf("cd %q && docker compose up -d", projectSrc) cmd = sprintf("cd %q && docker compose up -d", projectSrc)
case "absent": case "absent":
cmd = fmt.Sprintf("cd %q && docker compose down", projectSrc) cmd = sprintf("cd %q && docker compose down", projectSrc)
case "restarted": case "restarted":
cmd = fmt.Sprintf("cd %q && docker compose restart", projectSrc) cmd = sprintf("cd %q && docker compose restart", projectSrc)
default: 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) 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 // 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 return &TaskResult{Changed: changed, Stdout: stdout}, nil
} }

View file

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

80
ssh.go
View file

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