Compare commits

..

No commits in common. "dev" and "v0.2.0" have entirely different histories.
dev ... v0.2.0

34 changed files with 1769 additions and 19718 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
`core/go-ansible` is a pure Go Ansible playbook engine. It parses YAML playbooks, inventories, and roles, then executes tasks on remote hosts via SSH. 42 module handler implementations (plus 3 community modules), Jinja2-compatible templating, privilege escalation (become), and event-driven callbacks. This is a library — there is no standalone binary. The CLI integration lives in `cmd/ansible/` and is compiled as part of the `core` CLI binary.
`core/go-ansible` is a pure Go Ansible playbook engine. It parses YAML playbooks, inventories, and roles, then executes tasks on remote hosts via SSH. 41 module handler implementations (plus 3 community modules), Jinja2-compatible templating, privilege escalation (become), and event-driven callbacks. This is a library — there is no standalone binary. The CLI integration lives in `cmd/ansible/` and is compiled as part of the `core` CLI binary.
## Build & Test
@ -29,10 +29,10 @@ Playbook YAML ──► Parser ──► []Play ──► Executor ──► Mod
Inventory YAML ──► Parser ──► Inventory Callbacks (OnPlayStart, OnTaskEnd, ...)
```
- **`types.go`** — Core structs (`Playbook`, `Play`, `Task`, `TaskResult`, `Inventory`, `Host`, `Facts`) and `KnownModules` registry (96 entries: both FQCN `ansible.builtin.*` and short forms, plus compatibility aliases).
- **`types.go`** — Core structs (`Playbook`, `Play`, `Task`, `TaskResult`, `Inventory`, `Host`, `Facts`) and `KnownModules` registry (80 entries: both FQCN `ansible.builtin.*` and short forms).
- **`parser.go`** — YAML parsing for playbooks, inventories, tasks, and roles. Custom `Task.UnmarshalYAML` scans map keys against `KnownModules` to extract the module name and args (since Ansible embeds the module name as a YAML key, not a fixed field). Free-form syntax (`shell: echo hello`) is stored as `Args["_raw_params"]`. Iterator variants (`ParsePlaybookIter`, `ParseTasksIter`, etc.) return `iter.Seq` values.
- **`executor.go`** — Orchestration engine: host resolution from inventory, play execution order (gather facts → pre_tasks → roles → tasks → post_tasks → notified handlers), `when:` condition evaluation, `{{ }}` Jinja2-style templating with filter support, loop execution, block/rescue/always, handler notification.
- **`modules.go`** — 50 module handler implementations dispatched via a `switch` on the normalised module name. Each handler extracts args via `getStringArg`/`getBoolArg`, constructs shell commands, runs them via SSH, and returns a `TaskResult`.
- **`modules.go`** — 41 module handler implementations dispatched via a `switch` on the normalised module name. Each handler extracts args via `getStringArg`/`getBoolArg`, constructs shell commands, runs them via SSH, and returns a `TaskResult`.
- **`ssh.go`** — SSH client with lazy connection, auth chain (key file → default keys → password), `known_hosts` verification, become/sudo wrapping, file transfer via `cat >` piped through stdin.
- **`cmd/ansible/`** — CLI command registration via `core/cli`. Provides `ansible <playbook>` and `ansible test <host>` subcommands with flags for inventory, limit, tags, extra-vars, verbosity, and check mode.

View file

@ -1,59 +0,0 @@
# Convention Drift Report
Date: 2026-03-23
Branch: `agent/convention-drift-check--stdlib-core----u`
`CODEX.md` is not present in this repository. Conventions were taken from `CLAUDE.md` and `docs/development.md`.
Commands used for the test-gap pass:
```bash
go test -coverprofile=/tmp/ansible.cover ./...
go tool cover -func=/tmp/ansible.cover
```
## `stdlib→core.*`
No direct `stdlib`-to-`core.*` wrapper drift was found in the Go implementation. The remaining drift is stale migration residue around the `core.*` move:
- `go.mod:15`, `go.sum:7`, `go.sum:8`
Legacy `forge.lthn.ai/core/go-log` references still remain in the dependency graph.
- `CLAUDE.md:37`, `docs/development.md:169`
Repository guidance still refers to `core/cli`, while the current command registration lives on the `dappco.re/go/core` API at `cmd/ansible/cmd.go:8`.
- `CLAUDE.md:66`, `docs/development.md:86`
Guidance still calls the logging package `go-log`, while production code imports `dappco.re/go/core/log` at `cmd/ansible/ansible.go:13`, `executor.go:15`, `modules.go:13`, `parser.go:12`, `ssh.go:16`.
## UK English
- `executor.go:248`
Comment uses US spelling: `Initialize host results`.
- `parser.go:321`
Comment uses US spelling: `NormalizeModule normalizes a module name to its canonical form.`
- `types.go:110`
Comment uses US spelling: `LoopControl controls loop behavior.`
## Missing Tests
- `cmd/ansible/ansible.go:17`, `cmd/ansible/ansible.go:29`, `cmd/ansible/ansible.go:163`, `cmd/ansible/cmd.go:8`
`go tool cover` reports `0.0%` coverage for the entire `cmd/ansible` package, so argument parsing, command registration, playbook execution wiring, and SSH test wiring have no tests.
- `executor.go:81`, `executor.go:97`, `executor.go:172`, `executor.go:210`, `executor.go:241`, `executor.go:307`, `executor.go:382`, `executor.go:420`, `executor.go:444`, `executor.go:499`, `executor.go:565`
The main execution path is still uncovered: top-level run flow, play execution, roles, host task scheduling, loops, block handling, includes, SSH client creation, and fact gathering are all `0.0%` in the coverage report.
- `parser.go:119`
`ParseRole` is `0.0%` covered.
- `ssh.go:77`, `ssh.go:187`, `ssh.go:200`, `ssh.go:276`, `ssh.go:283`, `ssh.go:377`, `ssh.go:396`, `ssh.go:410`
Only constructor/default behaviour is tested; the real SSH transport methods are all `0.0%` covered.
- `modules.go:17`, `modules.go:178`, `modules.go:206`, `modules.go:234`, `modules.go:253`, `modules.go:281`, `modules.go:324`, `modules.go:352`, `modules.go:420`, `modules.go:463`, `modules.go:480`, `modules.go:502`, `modules.go:526`, `modules.go:550`, `modules.go:584`, `modules.go:615`, `modules.go:652`, `modules.go:665`, `modules.go:690`, `modules.go:732`, `modules.go:743`, `modules.go:800`, `modules.go:835`, `modules.go:941`, `modules.go:989`, `modules.go:1013`, `modules.go:1042`, `modules.go:1120`, `modules.go:1139`, `modules.go:1172`, `modules.go:1209`, `modules.go:1283`, `modules.go:1288`, `modules.go:1306`, `modules.go:1357`, `modules.go:1408`
The real dispatcher and production module handlers are still `0.0%` covered.
- `mock_ssh_test.go:347`, `mock_ssh_test.go:433`
Existing module tests bypass `Executor.executeModule` and the production handlers by routing through `executeModuleWithMock` and duplicated shim implementations, so module assertions do not exercise the shipped code paths.
- `CLAUDE.md:60`, `docs/index.md:141`, `docs/index.md:142`, `modules.go:941`, `modules.go:989`, `modules.go:1120`, `modules.go:1139`, `modules.go:1283`, `modules.go:1288`
Documentation advertises support for `pause`, `wait_for`, `hostname`, `sysctl`, `setup`, and `reboot`, but there are no dedicated tests for those production handlers.
## Missing SPDX Headers
No tracked text file currently contains an SPDX header.
- Repo metadata: `.github/workflows/ci.yml:1`, `.gitignore:1`, `go.mod:1`, `go.sum:1`
- Documentation: `CLAUDE.md:1`, `CONSUMERS.md:1`, `docs/architecture.md:1`, `docs/development.md:1`, `docs/index.md:1`, `kb/Executor.md:1`, `kb/Home.md:1`
- Go source: `cmd/ansible/ansible.go:1`, `cmd/ansible/cmd.go:1`, `executor.go:1`, `modules.go:1`, `parser.go:1`, `ssh.go:1`, `types.go:1`
- Go tests: `executor_extra_test.go:1`, `executor_test.go:1`, `mock_ssh_test.go:1`, `modules_adv_test.go:1`, `modules_cmd_test.go:1`, `modules_file_test.go:1`, `modules_infra_test.go:1`, `modules_svc_test.go:1`, `parser_test.go:1`, `ssh_test.go:1`, `types_test.go:1`

View file

@ -1,10 +1,9 @@
package ansiblecmd
package anscmd
import (
"context"
"encoding/json"
"os"
"strconv"
"fmt"
"path/filepath"
"strings"
"time"
@ -12,38 +11,12 @@ import (
"dappco.re/go/core/ansible"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
type playbookCommandOptions struct {
playbookPath string
basePath string
limit string
tags []string
skipTags []string
extraVars map[string]any
verbose int
checkMode bool
diff bool
}
func splitCommaSeparatedOption(value string) []string {
if value == "" {
return nil
}
// args extracts all positional arguments from Options.
func args(opts core.Options) []string {
var out []string
for _, item := range split(value, ",") {
if trimmed := trimSpace(item); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// positionalArgs extracts all positional arguments from Options.
func positionalArgs(opts core.Options) []string {
var out []string
for _, o := range opts.Items() {
for _, o := range opts {
if o.Key == "_arg" {
if s, ok := o.Value.(string); ok {
out = append(out, s)
@ -53,342 +26,78 @@ func positionalArgs(opts core.Options) []string {
return out
}
// firstStringOption returns the first non-empty string for any of the provided keys.
func firstStringOption(opts core.Options, keys ...string) string {
for _, key := range keys {
if value := opts.String(key); value != "" {
return value
}
}
return ""
}
// firstBoolOption returns true when any of the provided keys is set to true.
func firstBoolOption(opts core.Options, keys ...string) bool {
for _, key := range keys {
if opts.Bool(key) {
return true
}
}
return false
}
// collectStringOptionValues returns every string value for any of the provided
// keys, preserving the original option order.
func collectStringOptionValues(opts core.Options, keys ...string) []string {
var out []string
for _, o := range opts.Items() {
matched := false
for _, key := range keys {
if o.Key == key {
matched = true
break
}
}
if !matched {
continue
}
switch v := o.Value.(type) {
case string:
out = append(out, v)
case []string:
out = append(out, v...)
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
}
}
return out
}
// joinedStringOption joins every non-empty string value for the provided keys.
func joinedStringOption(opts core.Options, keys ...string) string {
values := collectStringOptionValues(opts, keys...)
if len(values) == 0 {
return ""
}
var filtered []string
for _, value := range values {
if trimmed := trimSpace(value); trimmed != "" {
filtered = append(filtered, trimmed)
}
}
return strings.Join(filtered, ",")
}
// verbosityLevel resolves the effective verbosity from parsed options and the
// raw command line arguments. The core CLI parser does not preserve repeated
// `-v` tokens, so we count them from os.Args as a fallback.
func verbosityLevel(opts core.Options, rawArgs []string) int {
level := opts.Int("verbose")
if firstBoolOption(opts, "v") && level < 1 {
level = 1
}
for _, arg := range rawArgs {
switch {
case arg == "-v" || arg == "--verbose":
level++
case strings.HasPrefix(arg, "--verbose="):
if n, err := strconv.Atoi(strings.TrimPrefix(arg, "--verbose=")); err == nil && n > level {
level = n
}
case strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--"):
short := strings.TrimPrefix(arg, "-")
if short != "" && strings.Trim(short, "v") == "" {
if n := len([]rune(short)); n > level {
level = n
}
}
}
}
return level
}
// extraVars collects all repeated extra-vars values from Options.
func extraVars(opts core.Options) (map[string]any, error) {
vars := make(map[string]any)
for _, o := range opts.Items() {
if o.Key != "extra-vars" && o.Key != "e" {
continue
}
var values []string
switch v := o.Value.(type) {
case string:
values = append(values, v)
case []string:
values = append(values, v...)
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
values = append(values, s)
}
}
}
for _, value := range values {
parsed, err := parseExtraVarsValue(value)
if err != nil {
return nil, err
}
for key, parsedValue := range parsed {
vars[key] = parsedValue
}
}
}
return vars, nil
}
func parseExtraVarsValue(value string) (map[string]any, error) {
trimmed := trimSpace(value)
if trimmed == "" {
return nil, nil
}
if strings.HasPrefix(trimmed, "@") {
filePath := trimSpace(strings.TrimPrefix(trimmed, "@"))
if filePath == "" {
return nil, coreerr.E("parseExtraVarsValue", "extra vars file path required", nil)
}
data, err := coreio.Local.Read(filePath)
if err != nil {
return nil, coreerr.E("parseExtraVarsValue", "read extra vars file", err)
}
return parseExtraVarsValue(string(data))
}
if structured, ok := parseStructuredExtraVars(trimmed); ok {
return structured, nil
}
if strings.Contains(trimmed, "=") {
return parseKeyValueExtraVars(trimmed), nil
}
return nil, nil
}
func parseStructuredExtraVars(value string) (map[string]any, bool) {
var parsed map[string]any
if json.Valid([]byte(value)) {
if err := yaml.Unmarshal([]byte(value), &parsed); err == nil && len(parsed) > 0 {
return parsed, true
}
}
if err := yaml.Unmarshal([]byte(value), &parsed); err != nil {
return nil, false
}
if len(parsed) == 0 {
return nil, false
}
return parsed, true
}
func parseKeyValueExtraVars(value string) map[string]any {
vars := make(map[string]any)
for _, pair := range split(value, ",") {
pair = trimSpace(pair)
if pair == "" {
continue
}
parts := splitN(pair, "=", 2)
if len(parts) != 2 {
continue
}
key := trimSpace(parts[0])
if key == "" {
continue
}
vars[key] = parseExtraVarsScalar(trimSpace(parts[1]))
}
return vars
}
func parseExtraVarsScalar(value string) any {
if value == "" {
return ""
}
var parsed any
if err := yaml.Unmarshal([]byte(value), &parsed); err == nil {
switch parsed.(type) {
case map[string]any, []any:
return value
default:
return parsed
}
}
return value
}
// Example:
//
// core ansible test server.example.com -i ~/.ssh/id_ed25519
func resolveSSHTestKeyFile(opts core.Options) string {
if key := opts.String("key"); key != "" {
return key
}
return opts.String("i")
}
func buildPlaybookCommandSettings(opts core.Options, rawArgs []string) (playbookCommandOptions, error) {
positional := positionalArgs(opts)
func runAnsible(opts core.Options) core.Result {
positional := args(opts)
if len(positional) < 1 {
return playbookCommandOptions{}, coreerr.E("buildPlaybookCommandSettings", "usage: ansible <playbook>", nil)
return core.Result{Value: coreerr.E("runAnsible", "usage: ansible <playbook>", nil)}
}
playbookPath := positional[0]
if !pathIsAbs(playbookPath) {
playbookPath = absPath(playbookPath)
// Resolve playbook path
if !filepath.IsAbs(playbookPath) {
playbookPath, _ = filepath.Abs(playbookPath)
}
if !coreio.Local.Exists(playbookPath) {
return playbookCommandOptions{}, coreerr.E("buildPlaybookCommandSettings", sprintf("playbook not found: %s", playbookPath), nil)
return core.Result{Value: coreerr.E("runAnsible", fmt.Sprintf("playbook not found: %s", playbookPath), nil)}
}
vars, err := extraVars(opts)
if err != nil {
return playbookCommandOptions{}, coreerr.E("buildPlaybookCommandSettings", "parse extra vars", err)
}
return playbookCommandOptions{
playbookPath: playbookPath,
basePath: pathDir(playbookPath),
limit: joinedStringOption(opts, "limit", "l"),
tags: splitCommaSeparatedOption(joinedStringOption(opts, "tags", "t")),
skipTags: splitCommaSeparatedOption(joinedStringOption(opts, "skip-tags")),
extraVars: vars,
verbose: verbosityLevel(opts, rawArgs),
checkMode: opts.Bool("check"),
diff: opts.Bool("diff"),
}, nil
}
func diffOutputLines(diff map[string]any) []string {
lines := []string{"diff:"}
if path, ok := diff["path"].(string); ok && path != "" {
lines = append(lines, sprintf("path: %s", path))
}
if before, ok := diff["before"].(string); ok && before != "" {
lines = append(lines, sprintf("- %s", before))
}
if after, ok := diff["after"].(string); ok && after != "" {
lines = append(lines, sprintf("+ %s", after))
}
return lines
}
func runPlaybookCommand(opts core.Options) core.Result {
settings, err := buildPlaybookCommandSettings(opts, os.Args[1:])
if err != nil {
return core.Result{Value: err}
}
executor := ansible.NewExecutor(settings.basePath)
// Create executor
basePath := filepath.Dir(playbookPath)
executor := ansible.NewExecutor(basePath)
defer executor.Close()
executor.Limit = settings.limit
executor.CheckMode = settings.checkMode
executor.Diff = settings.diff
executor.Verbose = settings.verbose
executor.Tags = settings.tags
executor.SkipTags = settings.skipTags
// Set options
executor.Limit = opts.String("limit")
executor.CheckMode = opts.Bool("check")
executor.Verbose = opts.Int("verbose")
for key, value := range settings.extraVars {
executor.SetVar(key, value)
if tags := opts.String("tags"); tags != "" {
executor.Tags = strings.Split(tags, ",")
}
if skipTags := opts.String("skip-tags"); skipTags != "" {
executor.SkipTags = strings.Split(skipTags, ",")
}
// Parse extra vars
if extraVars := opts.String("extra-vars"); extraVars != "" {
for _, v := range strings.Split(extraVars, ",") {
parts := strings.SplitN(v, "=", 2)
if len(parts) == 2 {
executor.SetVar(parts[0], parts[1])
}
}
}
// Load inventory
if inventoryPath := firstStringOption(opts, "inventory", "i"); inventoryPath != "" {
if !pathIsAbs(inventoryPath) {
inventoryPath = absPath(inventoryPath)
if invPath := opts.String("inventory"); invPath != "" {
if !filepath.IsAbs(invPath) {
invPath, _ = filepath.Abs(invPath)
}
if !coreio.Local.Exists(inventoryPath) {
return core.Result{Value: coreerr.E("runPlaybookCommand", sprintf("inventory not found: %s", inventoryPath), nil)}
if !coreio.Local.Exists(invPath) {
return core.Result{Value: coreerr.E("runAnsible", fmt.Sprintf("inventory not found: %s", invPath), nil)}
}
if coreio.Local.IsDir(inventoryPath) {
if coreio.Local.IsDir(invPath) {
for _, name := range []string{"inventory.yml", "hosts.yml", "inventory.yaml", "hosts.yaml"} {
candidatePath := joinPath(inventoryPath, name)
if coreio.Local.Exists(candidatePath) {
inventoryPath = candidatePath
p := filepath.Join(invPath, name)
if coreio.Local.Exists(p) {
invPath = p
break
}
}
}
if err := executor.SetInventory(inventoryPath); err != nil {
return core.Result{Value: coreerr.E("runPlaybookCommand", "load inventory", err)}
if err := executor.SetInventory(invPath); err != nil {
return core.Result{Value: coreerr.E("runAnsible", "load inventory", err)}
}
}
// Set up callbacks
executor.OnPlayStart = func(play *ansible.Play) {
print("")
print("PLAY [%s]", play.Name)
print("%s", repeat("*", 70))
fmt.Printf("\nPLAY [%s]\n", play.Name)
fmt.Println(strings.Repeat("*", 70))
}
executor.OnTaskStart = func(host string, task *ansible.Task) {
@ -396,10 +105,9 @@ func runPlaybookCommand(opts core.Options) core.Result {
if taskName == "" {
taskName = task.Module
}
print("")
print("TASK [%s]", taskName)
fmt.Printf("\nTASK [%s]\n", taskName)
if executor.Verbose > 0 {
print("host: %s", host)
fmt.Printf("host: %s\n", host)
}
}
@ -413,75 +121,66 @@ func runPlaybookCommand(opts core.Options) core.Result {
status = "changed"
}
line := sprintf("%s: [%s]", status, host)
fmt.Printf("%s: [%s]", status, host)
if result.Msg != "" && executor.Verbose > 0 {
line = sprintf("%s => %s", line, result.Msg)
fmt.Printf(" => %s", result.Msg)
}
if result.Duration > 0 && executor.Verbose > 1 {
line = sprintf("%s (%s)", line, result.Duration.Round(time.Millisecond))
fmt.Printf(" (%s)", result.Duration.Round(time.Millisecond))
}
print("%s", line)
fmt.Println()
if result.Failed && result.Stderr != "" {
print("%s", result.Stderr)
fmt.Printf("%s\n", result.Stderr)
}
if executor.Verbose > 1 {
if result.Stdout != "" {
print("stdout: %s", trimSpace(result.Stdout))
}
}
if executor.Diff {
if diff, ok := result.Data["diff"].(map[string]any); ok {
for _, line := range diffOutputLines(diff) {
print("%s", line)
}
fmt.Printf("stdout: %s\n", strings.TrimSpace(result.Stdout))
}
}
}
executor.OnPlayEnd = func(play *ansible.Play) {
print("")
fmt.Println()
}
// Run playbook
ctx := context.Background()
start := time.Now()
print("Running playbook: %s", settings.playbookPath)
fmt.Printf("Running playbook: %s\n", playbookPath)
if err := executor.Run(ctx, settings.playbookPath); err != nil {
return core.Result{Value: coreerr.E("runPlaybookCommand", "playbook failed", err)}
if err := executor.Run(ctx, playbookPath); err != nil {
return core.Result{Value: coreerr.E("runAnsible", "playbook failed", err)}
}
print("")
print("Playbook completed in %s", time.Since(start).Round(time.Millisecond))
fmt.Printf("\nPlaybook completed in %s\n", time.Since(start).Round(time.Millisecond))
return core.Result{OK: true}
}
func runSSHTestCommand(opts core.Options) core.Result {
positional := positionalArgs(opts)
func runAnsibleTest(opts core.Options) core.Result {
positional := args(opts)
if len(positional) < 1 {
return core.Result{Value: coreerr.E("runSSHTestCommand", "usage: ansible test <host>", nil)}
return core.Result{Value: coreerr.E("runAnsibleTest", "usage: ansible test <host>", nil)}
}
host := positional[0]
print("Testing SSH connection to %s...", host)
fmt.Printf("Testing SSH connection to %s...\n", host)
config := ansible.SSHConfig{
cfg := ansible.SSHConfig{
Host: host,
Port: opts.Int("port"),
User: firstStringOption(opts, "user", "u"),
User: opts.String("user"),
Password: opts.String("password"),
KeyFile: resolveSSHTestKeyFile(opts),
KeyFile: opts.String("key"),
Timeout: 30 * time.Second,
}
client, err := ansible.NewSSHClient(config)
client, err := ansible.NewSSHClient(cfg)
if err != nil {
return core.Result{Value: coreerr.E("runSSHTestCommand", "create client", err)}
return core.Result{Value: coreerr.E("runAnsibleTest", "create client", err)}
}
defer func() { _ = client.Close() }()
@ -491,52 +190,50 @@ func runSSHTestCommand(opts core.Options) core.Result {
// Test connection
start := time.Now()
if err := client.Connect(ctx); err != nil {
return core.Result{Value: coreerr.E("runSSHTestCommand", "connect failed", err)}
return core.Result{Value: coreerr.E("runAnsibleTest", "connect failed", err)}
}
connectTime := time.Since(start)
print("Connected in %s", connectTime.Round(time.Millisecond))
fmt.Printf("Connected in %s\n", connectTime.Round(time.Millisecond))
// Gather facts
print("")
print("Gathering facts...")
fmt.Println("\nGathering facts...")
stdout, _, _, _ := client.Run(ctx, "hostname -f 2>/dev/null || hostname")
print(" Hostname: %s", trimSpace(stdout))
fmt.Printf(" Hostname: %s\n", strings.TrimSpace(stdout))
stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
if stdout != "" {
print(" OS: %s", trimSpace(stdout))
fmt.Printf(" OS: %s\n", strings.TrimSpace(stdout))
}
stdout, _, _, _ = client.Run(ctx, "uname -r")
print(" Kernel: %s", trimSpace(stdout))
fmt.Printf(" Kernel: %s\n", strings.TrimSpace(stdout))
stdout, _, _, _ = client.Run(ctx, "uname -m")
print(" Architecture: %s", trimSpace(stdout))
fmt.Printf(" Architecture: %s\n", strings.TrimSpace(stdout))
stdout, _, _, _ = client.Run(ctx, "free -h | grep Mem | awk '{print $2}'")
print(" Memory: %s", trimSpace(stdout))
fmt.Printf(" Memory: %s\n", strings.TrimSpace(stdout))
stdout, _, _, _ = client.Run(ctx, "df -h / | tail -1 | awk '{print $2 \" total, \" $4 \" available\"}'")
print(" Disk: %s", trimSpace(stdout))
fmt.Printf(" Disk: %s\n", strings.TrimSpace(stdout))
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null")
if err == nil {
print(" Docker: %s", trimSpace(stdout))
fmt.Printf(" Docker: %s\n", strings.TrimSpace(stdout))
} else {
print(" Docker: not installed")
fmt.Printf(" Docker: not installed\n")
}
stdout, _, _, _ = client.Run(ctx, "docker ps 2>/dev/null | grep -q coolify && echo 'running' || echo 'not running'")
if trimSpace(stdout) == "running" {
print(" Coolify: running")
if strings.TrimSpace(stdout) == "running" {
fmt.Printf(" Coolify: running\n")
} else {
print(" Coolify: not installed")
fmt.Printf(" Coolify: not installed\n")
}
print("")
print("SSH test passed")
fmt.Printf("\nSSH test passed\n")
return core.Result{OK: true}
}

View file

@ -1,371 +0,0 @@
package ansiblecmd
import (
"os"
"path/filepath"
"testing"
"dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtraVars_Good_RepeatableAndCommaSeparated(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "extra-vars", Value: "version=1.2.3,env=prod"},
core.Option{Key: "extra-vars", Value: "region=us-east-1"},
core.Option{Key: "extra-vars", Value: []string{"build=42"}},
)
vars, err := extraVars(opts)
require.NoError(t, err)
assert.Equal(t, map[string]any{
"version": "1.2.3",
"env": "prod",
"region": "us-east-1",
"build": 42,
}, vars)
}
func TestExtraVars_Good_UsesShortAlias(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "e", Value: "version=1.2.3,env=prod"},
)
vars, err := extraVars(opts)
require.NoError(t, err)
assert.Equal(t, map[string]any{
"version": "1.2.3",
"env": "prod",
}, vars)
}
func TestExtraVars_Good_TrimsWhitespaceAroundPairs(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "extra-vars", Value: " version = 1.2.3 , env = prod , empty = "},
)
vars, err := extraVars(opts)
require.NoError(t, err)
assert.Equal(t, map[string]any{
"version": "1.2.3",
"env": "prod",
"empty": "",
}, vars)
}
func TestExtraVars_Good_IgnoresMalformedPairs(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "extra-vars", Value: "missing_equals,keep=this"},
core.Option{Key: "extra-vars", Value: "also_bad="},
)
vars, err := extraVars(opts)
require.NoError(t, err)
assert.Equal(t, map[string]any{
"keep": "this",
"also_bad": "",
}, vars)
}
func TestExtraVars_Good_ParsesYAMLScalarsInKeyValuePairs(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "extra-vars", Value: "enabled=true,count=42,threshold=3.5"},
)
vars, err := extraVars(opts)
require.NoError(t, err)
assert.Equal(t, map[string]any{
"enabled": true,
"count": 42,
"threshold": 3.5,
}, vars)
}
func TestSplitCommaSeparatedOption_Good_TrimsWhitespace(t *testing.T) {
assert.Equal(t, []string{"deploy", "setup", "smoke"}, splitCommaSeparatedOption(" deploy, setup ,smoke "))
}
func TestExtraVars_Good_SupportsStructuredYAMLAndJSON(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "extra-vars", Value: "app:\n port: 8080\n debug: true"},
core.Option{Key: "extra-vars", Value: `{"image":"nginx:latest","replicas":3}`},
)
vars, err := extraVars(opts)
require.NoError(t, err)
assert.Equal(t, map[string]any{
"app": map[string]any{
"port": int(8080),
"debug": true,
},
"image": "nginx:latest",
"replicas": int(3),
}, vars)
}
func TestExtraVars_Good_LoadsFileReferences(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "vars.yml")
require.NoError(t, os.WriteFile(path, []byte("deploy_env: prod\nrelease: 42\n"), 0644))
opts := core.NewOptions(
core.Option{Key: "extra-vars", Value: "@" + path},
)
vars, err := extraVars(opts)
require.NoError(t, err)
assert.Equal(t, map[string]any{
"deploy_env": "prod",
"release": int(42),
}, vars)
}
func TestExtraVars_Bad_MissingFile(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "extra-vars", Value: "@/definitely/missing/vars.yml"},
)
_, err := extraVars(opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "read extra vars file")
}
func TestFirstString_Good_PrefersFirstNonEmptyKey(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "inventory", Value: ""},
core.Option{Key: "i", Value: "/tmp/inventory.yml"},
)
assert.Equal(t, "/tmp/inventory.yml", firstStringOption(opts, "inventory", "i"))
}
func TestFirstBool_Good_UsesAlias(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "v", Value: true},
)
assert.True(t, firstBoolOption(opts, "verbose", "v"))
}
func TestVerbosityLevel_Good_CountsStackedShortFlags(t *testing.T) {
opts := core.NewOptions()
assert.Equal(t, 3, verbosityLevel(opts, []string{"-vvv"}))
}
func TestVerbosityLevel_Good_CountsLongForm(t *testing.T) {
opts := core.NewOptions()
assert.Equal(t, 1, verbosityLevel(opts, []string{"--verbose"}))
}
func TestVerbosityLevel_Good_PreservesExplicitNumericLevel(t *testing.T) {
opts := core.NewOptions(core.Option{Key: "verbose", Value: 2})
assert.Equal(t, 2, verbosityLevel(opts, nil))
}
func TestBuildPlaybookCommandSettings_Good_AppliesFlags(t *testing.T) {
dir := t.TempDir()
playbookPath := filepath.Join(dir, "site.yml")
require.NoError(t, os.WriteFile(playbookPath, []byte("- hosts: all\n tasks: []\n"), 0644))
opts := core.NewOptions(
core.Option{Key: "_arg", Value: playbookPath},
core.Option{Key: "limit", Value: "web1"},
core.Option{Key: "tags", Value: "deploy,setup"},
core.Option{Key: "skip-tags", Value: "slow"},
core.Option{Key: "extra-vars", Value: "version=1.2.3"},
core.Option{Key: "check", Value: true},
core.Option{Key: "diff", Value: true},
)
settings, err := buildPlaybookCommandSettings(opts, []string{"-vvv"})
require.NoError(t, err)
assert.Equal(t, playbookPath, settings.playbookPath)
assert.Equal(t, dir, settings.basePath)
assert.Equal(t, "web1", settings.limit)
assert.Equal(t, []string{"deploy", "setup"}, settings.tags)
assert.Equal(t, []string{"slow"}, settings.skipTags)
assert.Equal(t, 3, settings.verbose)
assert.True(t, settings.checkMode)
assert.True(t, settings.diff)
assert.Equal(t, map[string]any{"version": "1.2.3"}, settings.extraVars)
}
func TestBuildPlaybookCommandSettings_Good_MergesRepeatedListFlags(t *testing.T) {
dir := t.TempDir()
playbookPath := filepath.Join(dir, "site.yml")
require.NoError(t, os.WriteFile(playbookPath, []byte("- hosts: all\n tasks: []\n"), 0644))
opts := core.NewOptions(
core.Option{Key: "_arg", Value: playbookPath},
core.Option{Key: "limit", Value: "web1"},
core.Option{Key: "limit", Value: []string{"web2"}},
core.Option{Key: "tags", Value: "deploy,setup"},
core.Option{Key: "tags", Value: []string{"smoke"}},
core.Option{Key: "skip-tags", Value: "slow"},
core.Option{Key: "skip-tags", Value: []string{"flaky,experimental"}},
)
settings, err := buildPlaybookCommandSettings(opts, nil)
require.NoError(t, err)
assert.Equal(t, "web1,web2", settings.limit)
assert.Equal(t, []string{"deploy", "setup", "smoke"}, settings.tags)
assert.Equal(t, []string{"slow", "flaky", "experimental"}, settings.skipTags)
}
func TestBuildPlaybookCommandSettings_Bad_MissingPlaybook(t *testing.T) {
_, err := buildPlaybookCommandSettings(core.NewOptions(), nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "usage: ansible <playbook>")
}
func TestDiffOutputLines_Good_IncludesPathAndBeforeAfter(t *testing.T) {
lines := diffOutputLines(map[string]any{
"path": "/etc/nginx/conf.d/app.conf",
"before": "server_name=old.example.com;",
"after": "server_name=web01.example.com;",
})
assert.Equal(t, []string{
"diff:",
"path: /etc/nginx/conf.d/app.conf",
"- server_name=old.example.com;",
"+ server_name=web01.example.com;",
}, lines)
}
func TestTestKeyFile_Good_PrefersExplicitKey(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "key", Value: "/tmp/id_ed25519"},
core.Option{Key: "i", Value: "/tmp/ignored"},
)
assert.Equal(t, "/tmp/id_ed25519", resolveSSHTestKeyFile(opts))
}
func TestTestKeyFile_Good_FallsBackToShortAlias(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "i", Value: "/tmp/id_ed25519"},
)
assert.Equal(t, "/tmp/id_ed25519", resolveSSHTestKeyFile(opts))
}
func TestFirstString_Good_ResolvesShortUserAlias(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "u", Value: "deploy"},
)
cfgUser := firstStringOption(opts, "user", "u")
assert.Equal(t, "deploy", cfgUser)
}
func TestRegister_Good_RegistersAnsibleCommands(t *testing.T) {
app := core.New()
Register(app)
ansible := app.Command("ansible")
require.True(t, ansible.OK)
ansibleCmd := ansible.Value.(*core.Command)
assert.Equal(t, "ansible", ansibleCmd.Path)
assert.Equal(t, "ansible", ansibleCmd.Name)
assert.Equal(t, "Run Ansible playbooks natively (no Python required)", ansibleCmd.Description)
require.NotNil(t, ansibleCmd.Action)
test := app.Command("ansible/test")
require.True(t, test.OK)
testCmd := test.Value.(*core.Command)
assert.Equal(t, "ansible/test", testCmd.Path)
assert.Equal(t, "test", testCmd.Name)
assert.Equal(t, "Test SSH connectivity to a host", testCmd.Description)
require.NotNil(t, testCmd.Action)
paths := app.Commands()
assert.Contains(t, paths, "ansible")
assert.Contains(t, paths, "ansible/test")
}
func TestRegister_Good_ExposesExpectedFlags(t *testing.T) {
app := core.New()
Register(app)
ansibleCmd := app.Command("ansible").Value.(*core.Command)
assert.True(t, ansibleCmd.Flags.Has("inventory"))
assert.True(t, ansibleCmd.Flags.Has("i"))
assert.True(t, ansibleCmd.Flags.Has("limit"))
assert.True(t, ansibleCmd.Flags.Has("l"))
assert.True(t, ansibleCmd.Flags.Has("tags"))
assert.True(t, ansibleCmd.Flags.Has("t"))
assert.True(t, ansibleCmd.Flags.Has("skip-tags"))
assert.True(t, ansibleCmd.Flags.Has("extra-vars"))
assert.True(t, ansibleCmd.Flags.Has("e"))
assert.True(t, ansibleCmd.Flags.Has("verbose"))
assert.True(t, ansibleCmd.Flags.Has("v"))
assert.True(t, ansibleCmd.Flags.Has("check"))
assert.True(t, ansibleCmd.Flags.Has("diff"))
assert.Equal(t, "", ansibleCmd.Flags.String("inventory"))
assert.Equal(t, "", ansibleCmd.Flags.String("i"))
assert.Equal(t, "", ansibleCmd.Flags.String("limit"))
assert.Equal(t, "", ansibleCmd.Flags.String("l"))
assert.Equal(t, "", ansibleCmd.Flags.String("tags"))
assert.Equal(t, "", ansibleCmd.Flags.String("t"))
assert.Equal(t, "", ansibleCmd.Flags.String("skip-tags"))
assert.Equal(t, "", ansibleCmd.Flags.String("extra-vars"))
assert.Equal(t, "", ansibleCmd.Flags.String("e"))
assert.Equal(t, 0, ansibleCmd.Flags.Int("verbose"))
assert.False(t, ansibleCmd.Flags.Bool("v"))
assert.False(t, ansibleCmd.Flags.Bool("check"))
assert.False(t, ansibleCmd.Flags.Bool("diff"))
testCmd := app.Command("ansible/test").Value.(*core.Command)
assert.True(t, testCmd.Flags.Has("user"))
assert.True(t, testCmd.Flags.Has("u"))
assert.True(t, testCmd.Flags.Has("password"))
assert.True(t, testCmd.Flags.Has("key"))
assert.True(t, testCmd.Flags.Has("i"))
assert.True(t, testCmd.Flags.Has("port"))
assert.Equal(t, "root", testCmd.Flags.String("user"))
assert.Equal(t, "root", testCmd.Flags.String("u"))
assert.Equal(t, "", testCmd.Flags.String("password"))
assert.Equal(t, "", testCmd.Flags.String("key"))
assert.Equal(t, "", testCmd.Flags.String("i"))
assert.Equal(t, 22, testCmd.Flags.Int("port"))
}
func TestRunAnsible_Bad_MissingPlaybook(t *testing.T) {
result := runPlaybookCommand(core.NewOptions())
require.False(t, result.OK)
err, ok := result.Value.(error)
require.True(t, ok)
assert.Contains(t, err.Error(), "usage: ansible <playbook>")
}
func TestRunAnsibleTest_Bad_MissingHost(t *testing.T) {
result := runSSHTestCommand(core.NewOptions())
require.False(t, result.OK)
err, ok := result.Value.(error)
require.True(t, ok)
assert.Contains(t, err.Error(), "usage: ansible test <host>")
}

View file

@ -1,46 +1,33 @@
package ansiblecmd
package anscmd
import (
"dappco.re/go/core"
)
// Register registers the `ansible` command and its `ansible/test` subcommand.
//
// Example:
//
// var app core.Core
// Register(&app)
// Register registers the 'ansible' command and all subcommands on the given Core instance.
func Register(c *core.Core) {
c.Command("ansible", core.Command{
Description: "Run Ansible playbooks natively (no Python required)",
Action: runPlaybookCommand,
Flags: core.NewOptions(
core.Option{Key: "inventory", Value: ""},
core.Option{Key: "i", Value: ""},
core.Option{Key: "limit", Value: ""},
core.Option{Key: "l", Value: ""},
core.Option{Key: "tags", Value: ""},
core.Option{Key: "t", Value: ""},
core.Option{Key: "skip-tags", Value: ""},
core.Option{Key: "extra-vars", Value: ""},
core.Option{Key: "e", Value: ""},
core.Option{Key: "verbose", Value: 0},
core.Option{Key: "v", Value: false},
core.Option{Key: "check", Value: false},
core.Option{Key: "diff", Value: false},
),
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},
},
})
c.Command("ansible/test", core.Command{
Description: "Test SSH connectivity to a host",
Action: runSSHTestCommand,
Flags: core.NewOptions(
core.Option{Key: "user", Value: "root"},
core.Option{Key: "u", Value: "root"},
core.Option{Key: "password", Value: ""},
core.Option{Key: "key", Value: ""},
core.Option{Key: "i", Value: ""},
core.Option{Key: "port", Value: 22},
),
Action: runAnsibleTest,
Flags: core.Options{
{Key: "user", Value: "root"},
{Key: "password", Value: ""},
{Key: "key", Value: ""},
{Key: "port", Value: 22},
},
})
}

View file

@ -1,160 +0,0 @@
package ansiblecmd
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
}

View file

@ -1,292 +0,0 @@
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 trimSpace(s string) string { return corexTrimSpace(s) }
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

@ -1,76 +0,0 @@
# API Contract
`CODEX.md` is not present in this repository. This extraction follows the repo conventions documented in `CLAUDE.md`.
Function and method coverage percentages below come from `go test -coverprofile=/tmp/ansible.cover ./...` run on 2026-03-23. Type rows list the tests that exercise or validate each type because Go coverage does not report percentages for type declarations. Exported variables are intentionally excluded because the task requested exported types, functions, and methods only.
## Package `ansible`: Types
| Name | Signature | Description | Test coverage |
| --- | --- | --- | --- |
| `Playbook` | `type Playbook struct` | Top-level playbook container for an inline list of plays. | No direct tests; parser tests work with `[]Play` rather than `Playbook`. |
| `Play` | `type Play struct` | Declares play metadata, host targeting, vars, lifecycle task lists, roles, handlers, tags, and execution settings. | Exercised by `parser_test.go` playbook cases and `executor_extra_test.go: TestParsePlaybookIter_Good`. |
| `RoleRef` | `type RoleRef struct` | Represents a role reference, including string and map forms plus role-scoped vars, tags, and `when`. | Directly validated by `types_test.go: TestRoleRef_UnmarshalYAML_*`; also parsed in `parser_test.go: TestParsePlaybook_Good_RoleRefs`. |
| `Task` | `type Task struct` | Represents a task, including module selection, args, conditions, loops, block/rescue/always sections, includes, notify, and privilege controls. | Directly validated by `types_test.go: TestTask_UnmarshalYAML_*`; also exercised throughout `parser_test.go` and module dispatch tests. |
| `LoopControl` | `type LoopControl struct` | Configures loop variable names, labels, pauses, and extended loop metadata. | No dedicated tests; only reachable through `Task.loop_control` parsing. |
| `TaskResult` | `type TaskResult struct` | Standard task execution result with change/failure state, output streams, loop subresults, module data, and duration. | Directly validated by `types_test.go: TestTaskResult_*`; also used across executor and module tests. |
| `Inventory` | `type Inventory struct` | Root Ansible inventory object keyed under `all`. | Directly validated by `types_test.go: TestInventory_UnmarshalYAML_Good_Complex`; also exercised by parser and host-resolution tests. |
| `InventoryGroup` | `type InventoryGroup struct` | Inventory group containing hosts, child groups, and inherited vars. | Exercised by `parser_test.go` inventory and host-matching cases plus `executor_extra_test.go: TestAllHostsIter_Good`. |
| `Host` | `type Host struct` | Host-level connection settings plus inline custom vars. | Exercised by inventory parsing and host-var inheritance tests in `types_test.go` and `parser_test.go`. |
| `Facts` | `type Facts struct` | Captures gathered host facts such as identity, OS, kernel, memory, CPUs, and primary IPv4. | Directly validated by `types_test.go: TestFacts_Struct`; also exercised by fact resolution tests in `modules_infra_test.go` and `executor_extra_test.go`. |
| `Parser` | `type Parser struct` | Stateful YAML parser for playbooks, inventories, task files, and roles. | Directly exercised by `parser_test.go: TestNewParser_Good` and all parser method tests. |
| `Executor` | `type Executor struct` | Playbook execution engine holding parser/inventory state, callbacks, vars/results/facts, SSH clients, and run options. | Directly exercised by `executor_test.go`, `executor_extra_test.go`, and module tests; the public `Run` entrypoint itself is untested. |
| `SSHClient` | `type SSHClient struct` | Lazy SSH client that tracks connection, auth, privilege-escalation state, and timeout. | Constructor and become state are covered in `ssh_test.go` and `modules_infra_test.go`; network and file-transfer methods are untested. |
| `SSHConfig` | `type SSHConfig struct` | Public configuration for SSH connection, auth, become, and timeout defaults. | Directly exercised by `ssh_test.go: TestNewSSHClient`, `TestSSHConfig_Defaults`, and become-state tests in `modules_infra_test.go`. |
## Package `ansible`: Parser and Inventory API
| Name | Signature | Description | Test coverage |
| --- | --- | --- | --- |
| `(*RoleRef).UnmarshalYAML` | `func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error` | Accepts either a scalar role name or a structured role reference and normalises `name` into `Role`. | `91.7%`; covered by `types_test.go: TestRoleRef_UnmarshalYAML_*`. |
| `(*Task).UnmarshalYAML` | `func (t *Task) UnmarshalYAML(node *yaml.Node) error` | Decodes known task fields, extracts the module key dynamically, and converts `with_items` into `Loop`. | `87.5%`; covered by `types_test.go: TestTask_UnmarshalYAML_*`. |
| `NewParser` | `func NewParser(basePath string) *Parser` | Constructs a parser rooted at `basePath` with an empty variable map. | `100.0%`; covered by `parser_test.go: TestNewParser_Good`. |
| `(*Parser).ParsePlaybook` | `func (p *Parser) ParsePlaybook(path string) ([]Play, error)` | Reads a playbook YAML file into plays and post-processes each play's tasks. | `90.0%`; covered by `parser_test.go: TestParsePlaybook_*`. |
| `(*Parser).ParsePlaybookIter` | `func (p *Parser) ParsePlaybookIter(path string) (iter.Seq[Play], error)` | Wraps `ParsePlaybook` with an iterator over plays. | `85.7%`; covered by `executor_extra_test.go: TestParsePlaybookIter_*`. |
| `(*Parser).ParseInventory` | `func (p *Parser) ParseInventory(path string) (*Inventory, error)` | Reads an inventory YAML file into the public inventory model. | `100.0%`; covered by `parser_test.go: TestParseInventory_*`. |
| `(*Parser).ParseTasks` | `func (p *Parser) ParseTasks(path string) ([]Task, error)` | Reads a task file and extracts module metadata for each task. | `90.0%`; covered by `parser_test.go: TestParseTasks_*`. |
| `(*Parser).ParseTasksIter` | `func (p *Parser) ParseTasksIter(path string) (iter.Seq[Task], error)` | Wraps `ParseTasks` with an iterator over tasks. | `85.7%`; covered by `executor_extra_test.go: TestParseTasksIter_*`. |
| `(*Parser).ParseRole` | `func (p *Parser) ParseRole(name string, tasksFrom string) ([]Task, error)` | Resolves a role across several search paths, loads role defaults and vars, then parses the requested task file. | `0.0%`; no automated tests found. |
| `NormalizeModule` | `func NormalizeModule(name string) string` | Canonicalises short module names to `ansible.builtin.*` while leaving dotted names unchanged. | `100.0%`; covered by `parser_test.go: TestNormalizeModule_*`. |
| `GetHosts` | `func GetHosts(inv *Inventory, pattern string) []string` | Resolves `all`, `localhost`, group names, or explicit host names from inventory. | `100.0%`; covered by `parser_test.go: TestGetHosts_*`. |
| `GetHostsIter` | `func GetHostsIter(inv *Inventory, pattern string) iter.Seq[string]` | Iterator wrapper over `GetHosts`. | `80.0%`; covered by `executor_extra_test.go: TestGetHostsIter_Good`. |
| `AllHostsIter` | `func AllHostsIter(group *InventoryGroup) iter.Seq[string]` | Iterates every host in a group tree with deterministic key ordering. | `84.6%`; covered by `executor_extra_test.go: TestAllHostsIter_*`. |
| `GetHostVars` | `func GetHostVars(inv *Inventory, hostname string) map[string]any` | Collects effective variables for a host by merging group ancestry with host-specific connection vars and inline vars. | `100.0%`; covered by `parser_test.go: TestGetHostVars_*`. |
## Package `ansible`: Executor API
| Name | Signature | Description | Test coverage |
| --- | --- | --- | --- |
| `NewExecutor` | `func NewExecutor(basePath string) *Executor` | Constructs an executor with parser state, variable stores, handler tracking, and SSH client cache. | `100.0%`; covered by `executor_test.go: TestNewExecutor_Good`. |
| `(*Executor).SetInventory` | `func (e *Executor) SetInventory(path string) error` | Loads inventory from disk via the embedded parser and stores it on the executor. | `100.0%`; covered by `executor_extra_test.go: TestSetInventory_*`. |
| `(*Executor).SetInventoryDirect` | `func (e *Executor) SetInventoryDirect(inv *Inventory)` | Replaces the executor inventory with a caller-supplied value. | `100.0%`; covered by `executor_test.go: TestSetInventoryDirect_Good`. |
| `(*Executor).SetVar` | `func (e *Executor) SetVar(key string, value any)` | Stores a variable in the executor-scoped variable map under lock. | `100.0%`; covered by `executor_test.go: TestSetVar_Good`. |
| `(*Executor).Run` | `func (e *Executor) Run(ctx context.Context, playbookPath string) error` | Parses a playbook and executes each play in order. | `0.0%`; no automated tests found for the public run path. |
| `(*Executor).Close` | `func (e *Executor) Close()` | Closes all cached SSH clients and resets the client cache. | `80.0%`; covered by `executor_test.go: TestClose_Good_EmptyClients`. |
| `(*Executor).TemplateFile` | `func (e *Executor) TemplateFile(src, host string, task *Task) (string, error)` | Reads a template, performs a basic Jinja2-to-Go-template conversion, and falls back to string substitution if parsing or execution fails. | `75.0%`; exercised indirectly by `modules_file_test.go: TestModuleTemplate_*`. |
## Package `ansible`: SSH API
| Name | Signature | Description | Test coverage |
| --- | --- | --- | --- |
| `NewSSHClient` | `func NewSSHClient(cfg SSHConfig) (*SSHClient, error)` | Applies SSH defaults and constructs a client from the supplied config. | `100.0%`; covered by `ssh_test.go: TestNewSSHClient`, `TestSSHConfig_Defaults`, plus become-state tests in `modules_infra_test.go`. |
| `(*SSHClient).Connect` | `func (c *SSHClient) Connect(ctx context.Context) error` | Lazily establishes an SSH connection using key, password, and `known_hosts` handling. | `0.0%`; no automated tests found. |
| `(*SSHClient).Close` | `func (c *SSHClient) Close() error` | Closes the active SSH connection if one exists. | `0.0%`; no automated tests found. |
| `(*SSHClient).Run` | `func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error)` | Executes a remote command, optionally wrapped with `sudo`, and returns streams plus exit code. | `0.0%`; no automated tests found for the concrete SSH implementation. |
| `(*SSHClient).RunScript` | `func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error)` | Executes a remote shell script through a heredoc wrapper. | `0.0%`; no automated tests found. |
| `(*SSHClient).Upload` | `func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error` | Uploads content to a remote file, creating parent directories and handling `sudo` writes when needed. | `0.0%`; no automated tests found. |
| `(*SSHClient).Download` | `func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error)` | Downloads a remote file by reading it with `cat`. | `0.0%`; no automated tests found. |
| `(*SSHClient).FileExists` | `func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error)` | Checks remote path existence with `test -e`. | `0.0%`; no automated tests found. |
| `(*SSHClient).Stat` | `func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error)` | Returns a minimal remote stat map describing existence and directory state. | `0.0%`; no automated tests found. |
| `(*SSHClient).SetBecome` | `func (c *SSHClient) SetBecome(become bool, user, password string)` | Updates the privilege-escalation settings stored on the client. | `100.0%`; covered by `modules_infra_test.go: TestBecome_Infra_Good_*`. |
## Package `ansiblecmd`: CLI API
| Name | Signature | Description | Test coverage |
| --- | --- | --- | --- |
| `Register` | `func Register(c *core.Core)` | Registers the `ansible` and `ansible/test` CLI commands and their flags on a `core.Core` instance. | `0.0%`; `cmd/ansible` has no test files. |

View file

@ -222,7 +222,6 @@ The `evaluateWhen` method processes `when:` clauses. It supports:
- Boolean literals: `true`, `false`, `True`, `False`
- Negation: `not <condition>`
- Inline boolean expressions with `and`, `or`, and parentheses
- Registered variable checks: `result is defined`, `result is success`, `result is failed`, `result is changed`, `result is skipped`
- Variable truthiness: checks `vars` map for the condition as a key, evaluating booleans, non-empty strings, and non-zero integers
- Default filter handling: `var | default(value)` always evaluates to true (permissive)

View file

@ -161,7 +161,7 @@ go-ansible/
types.go Core data types and KnownModules registry
parser.go YAML parsing (playbooks, inventories, roles)
executor.go Execution engine (orchestration, templating, conditions)
modules.go 49 module handler implementations
modules.go 41 module handler implementations
ssh.go SSH client (auth, commands, file transfer, become)
*_test.go Test files (see table above)
cmd/

View file

@ -110,7 +110,7 @@ go-ansible/
types.go Core data types: Playbook, Play, Task, Inventory, Host, Facts
parser.go YAML parser for playbooks, inventories, tasks, roles
executor.go Execution engine: module dispatch, templating, conditions, loops
modules.go 50 module implementations (shell, apt, replace, docker-compose, setup, etc.)
modules.go 41 module implementations (shell, apt, docker-compose, etc.)
ssh.go SSH client with key/password auth, become/sudo, file transfer
types_test.go Tests for data types and YAML unmarshalling
parser_test.go Tests for the YAML parser
@ -126,20 +126,20 @@ go-ansible/
## Supported Modules
50 module handlers are implemented, covering the most commonly used Ansible modules:
41 module handlers are implemented, covering the most commonly used Ansible modules:
| Category | Modules |
|----------|---------|
| **Command execution** | `shell`, `command`, `raw`, `script` |
| **File operations** | `copy`, `template`, `file`, `lineinfile`, `replace`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` |
| **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip`, `rpm` |
| **File operations** | `copy`, `template`, `file`, `lineinfile`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` |
| **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip` |
| **Service management** | `service`, `systemd` |
| **User and group** | `user`, `group` |
| **HTTP** | `uri` |
| **Source control** | `git` |
| **Archive** | `unarchive` |
| **System** | `hostname`, `sysctl`, `cron`, `reboot`, `setup` |
| **Flow control** | `debug`, `fail`, `assert`, `set_fact`, `add_host`, `pause`, `wait_for`, `meta`, `include_vars` |
| **Flow control** | `debug`, `fail`, `assert`, `set_fact`, `pause`, `wait_for`, `meta`, `include_vars` |
| **Community** | `community.general.ufw`, `ansible.posix.authorized_key`, `community.docker.docker_compose` |
Both fully-qualified collection names (e.g. `ansible.builtin.shell`) and short-form names (e.g. `shell`) are accepted.

File diff suppressed because it is too large Load diff

View file

@ -1,137 +0,0 @@
package ansible
import (
"context"
"io"
"io/fs"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type becomeRecordingClient struct {
mu sync.Mutex
become bool
becomeUser string
becomePass string
runBecomeSeen []bool
runBecomePass []string
}
func (c *becomeRecordingClient) Run(_ context.Context, _ string) (string, string, int, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.runBecomeSeen = append(c.runBecomeSeen, c.become)
c.runBecomePass = append(c.runBecomePass, c.becomePass)
return "", "", 0, nil
}
func (c *becomeRecordingClient) RunScript(_ context.Context, _ string) (string, string, int, error) {
return c.Run(context.Background(), "")
}
func (c *becomeRecordingClient) Upload(_ context.Context, _ io.Reader, _ string, _ fs.FileMode) error {
return nil
}
func (c *becomeRecordingClient) Download(_ context.Context, _ string) ([]byte, error) {
return nil, nil
}
func (c *becomeRecordingClient) FileExists(_ context.Context, _ string) (bool, error) {
return false, nil
}
func (c *becomeRecordingClient) Stat(_ context.Context, _ string) (map[string]any, error) {
return map[string]any{}, nil
}
func (c *becomeRecordingClient) BecomeState() (bool, string, string) {
c.mu.Lock()
defer c.mu.Unlock()
return c.become, c.becomeUser, c.becomePass
}
func (c *becomeRecordingClient) SetBecome(become bool, user, password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.become = become
if !become {
c.becomeUser = ""
c.becomePass = ""
return
}
if user != "" {
c.becomeUser = user
}
if password != "" {
c.becomePass = password
}
}
func (c *becomeRecordingClient) Close() error {
return nil
}
func TestExecutor_RunTaskOnHost_Good_TaskBecomeFalseOverridesPlayBecome(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {AnsibleHost: "127.0.0.1"},
},
},
})
client := &becomeRecordingClient{}
client.SetBecome(true, "root", "secret")
e.clients["host1"] = client
play := &Play{Become: true, BecomeUser: "admin"}
task := &Task{
Name: "Disable become for this task",
Module: "command",
Args: map[string]any{"cmd": "echo ok"},
Become: func() *bool { v := false; return &v }(),
}
require.NoError(t, e.runTaskOnHost(context.Background(), "host1", []string{"host1"}, task, play))
require.Len(t, client.runBecomeSeen, 1)
assert.False(t, client.runBecomeSeen[0])
assert.True(t, client.become)
assert.Equal(t, "admin", client.becomeUser)
assert.Equal(t, "secret", client.becomePass)
}
func TestExecutor_RunTaskOnHost_Good_TaskBecomeUsesInventoryPassword(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {
AnsibleHost: "127.0.0.1",
AnsibleBecomePassword: "secret",
},
},
},
})
client := &becomeRecordingClient{}
e.clients["host1"] = client
play := &Play{}
task := &Task{
Name: "Enable become for this task",
Module: "command",
Args: map[string]any{"cmd": "echo ok"},
Become: func() *bool { v := true; return &v }(),
}
require.NoError(t, e.runTaskOnHost(context.Background(), "host1", []string{"host1"}, task, play))
require.Len(t, client.runBecomeSeen, 1)
assert.True(t, client.runBecomeSeen[0])
require.Len(t, client.runBecomePass, 1)
assert.Equal(t, "secret", client.runBecomePass[0])
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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.8.0-alpha.1
dappco.re/go/core v0.5.0
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.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 v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
dappco.re/go/core v0.5.0/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

@ -36,7 +36,6 @@ Jinja2-like `{{ var }}` syntax is supported:
`when` supports:
- Boolean literals: `true`, `false`
- Inline boolean expressions with `and`, `or`, and parentheses
- Registered variable checks: `result is success`, `result is failed`, `result is changed`, `result is defined`
- Negation: `not condition`
- Variable truthiness checks

View file

@ -1,171 +0,0 @@
package ansible
import (
"bytes"
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
// localClient executes commands and file operations on the controller host.
// It satisfies sshExecutorClient so the executor can reuse the same module
// handlers for `connection: local` playbooks.
//
// Example:
//
// client := newLocalClient()
type localClient struct {
mu sync.Mutex
become bool
becomeUser string
becomePass string
}
// newLocalClient creates a controller-side client for `connection: local`.
//
// Example:
//
// client := newLocalClient()
func newLocalClient() *localClient {
return &localClient{}
}
func (c *localClient) BecomeState() (bool, string, string) {
c.mu.Lock()
defer c.mu.Unlock()
return c.become, c.becomeUser, c.becomePass
}
func (c *localClient) SetBecome(become bool, user, password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.become = become
if !become {
c.becomeUser = ""
c.becomePass = ""
return
}
if user != "" {
c.becomeUser = user
}
if password != "" {
c.becomePass = password
}
}
func (c *localClient) Close() error {
return nil
}
func (c *localClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
c.mu.Lock()
become, becomeUser, becomePass := c.becomeStateLocked()
c.mu.Unlock()
command := cmd
if become {
command = wrapLocalBecomeCommand(command, becomeUser, becomePass)
}
if become {
return runLocalShell(ctx, command, becomePass)
}
return runLocalShell(ctx, command, "")
}
func (c *localClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
return c.Run(ctx, "bash <<'ANSIBLE_SCRIPT_EOF'\n"+script+"\nANSIBLE_SCRIPT_EOF")
}
func (c *localClient) Upload(_ context.Context, localReader io.Reader, remote string, mode os.FileMode) error {
content, err := io.ReadAll(localReader)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(remote), 0o755); err != nil {
return err
}
return os.WriteFile(remote, content, mode)
}
func (c *localClient) Download(_ context.Context, remote string) ([]byte, error) {
return os.ReadFile(remote)
}
func (c *localClient) FileExists(_ context.Context, path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func (c *localClient) Stat(_ context.Context, path string) (map[string]any, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]any{"exists": false}, nil
}
return nil, err
}
return map[string]any{
"exists": true,
"isdir": info.IsDir(),
}, nil
}
func (c *localClient) becomeStateLocked() (bool, string, string) {
return c.become, c.becomeUser, c.becomePass
}
func runLocalShell(ctx context.Context, command, password string) (stdout, stderr string, exitCode int, err error) {
cmd := exec.CommandContext(ctx, "bash", "-lc", command)
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
if password != "" {
stdin, stdinErr := cmd.StdinPipe()
if stdinErr != nil {
return "", "", -1, stdinErr
}
go func() {
defer func() { _ = stdin.Close() }()
_, _ = io.WriteString(stdin, password+"\n")
}()
}
err = cmd.Run()
stdout = stdoutBuf.String()
stderr = stderrBuf.String()
if err == nil {
return stdout, stderr, 0, nil
}
if exitErr, ok := err.(*exec.ExitError); ok {
return stdout, stderr, exitErr.ExitCode(), nil
}
return stdout, stderr, -1, err
}
func wrapLocalBecomeCommand(command, user, password string) string {
if user == "" {
user = "root"
}
escaped := strings.ReplaceAll(command, "'", "'\\''")
if password != "" {
return "sudo -S -u " + user + " bash -lc '" + escaped + "'"
}
return "sudo -n -u " + user + " bash -lc '" + escaped + "'"
}

View file

@ -1,96 +0,0 @@
package ansible
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLocalClient_Good_RunAndRunScript(t *testing.T) {
client := newLocalClient()
stdout, stderr, rc, err := client.Run(context.Background(), "printf 'hello\\n'")
require.NoError(t, err)
assert.Equal(t, "hello\n", stdout)
assert.Equal(t, "", stderr)
assert.Equal(t, 0, rc)
stdout, stderr, rc, err = client.RunScript(context.Background(), "printf 'script\\n'")
require.NoError(t, err)
assert.Equal(t, "script\n", stdout)
assert.Equal(t, "", stderr)
assert.Equal(t, 0, rc)
}
func TestLocalClient_Good_FileOperations(t *testing.T) {
client := newLocalClient()
dir := t.TempDir()
path := filepath.Join(dir, "nested", "file.txt")
require.NoError(t, client.Upload(context.Background(), newReader("content"), path, 0o644))
exists, err := client.FileExists(context.Background(), path)
require.NoError(t, err)
assert.True(t, exists)
info, err := client.Stat(context.Background(), path)
require.NoError(t, err)
assert.Equal(t, true, info["exists"])
assert.Equal(t, false, info["isdir"])
content, err := client.Download(context.Background(), path)
require.NoError(t, err)
assert.Equal(t, []byte("content"), content)
}
func TestExecutor_RunTaskOnHost_Good_LocalConnection(t *testing.T) {
e := NewExecutor("/tmp")
task := &Task{
Name: "Local shell",
Module: "shell",
Args: map[string]any{"_raw_params": "printf 'local ok\\n'"},
Register: "local_result",
}
play := &Play{Connection: "local"}
require.NoError(t, e.runTaskOnHosts(context.Background(), []string{"host1"}, task, play))
result := e.results["host1"]["local_result"]
require.NotNil(t, result)
assert.Equal(t, "local ok\n", result.Stdout)
assert.False(t, result.Failed)
_, ok := e.clients["host1"].(*localClient)
assert.True(t, ok)
}
func TestExecutor_GatherFacts_Good_LocalConnection(t *testing.T) {
e := NewExecutor("/tmp")
require.NoError(t, e.gatherFacts(context.Background(), "host1", &Play{Connection: "local"}))
facts := e.facts["host1"]
require.NotNil(t, facts)
assert.NotEmpty(t, facts.Hostname)
assert.NotEmpty(t, facts.Kernel)
}
func TestLocalClient_Good_SetBecomeResetsStateWhenDisabled(t *testing.T) {
client := newLocalClient()
client.SetBecome(true, "admin", "secret")
become, user, password := client.BecomeState()
assert.True(t, become)
assert.Equal(t, "admin", user)
assert.Equal(t, "secret", password)
client.SetBecome(false, "", "")
become, user, password = client.BecomeState()
assert.False(t, become)
assert.Empty(t, user)
assert.Empty(t, password)
}

File diff suppressed because it is too large Load diff

3903
modules.go

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
package ansible
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@ -14,7 +15,7 @@ import (
// --- MockSSHClient basic tests ---
func TestModulesCmd_MockSSHClient_Good_RunRecordsExecution(t *testing.T) {
func TestMockSSHClient_Good_RunRecordsExecution(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommand("echo hello", "hello\n", "", 0)
@ -29,7 +30,7 @@ func TestModulesCmd_MockSSHClient_Good_RunRecordsExecution(t *testing.T) {
assert.Equal(t, "echo hello", mock.lastCommand().Cmd)
}
func TestModulesCmd_MockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
func TestMockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommand("set -e", "ok", "", 0)
@ -42,7 +43,7 @@ func TestModulesCmd_MockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
assert.Equal(t, "RunScript", mock.lastCommand().Method)
}
func TestModulesCmd_MockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
func TestMockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
mock := NewMockSSHClient()
// No expectations registered — should return empty success
@ -54,7 +55,7 @@ func TestModulesCmd_MockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
assert.Equal(t, 0, rc)
}
func TestModulesCmd_MockSSHClient_Good_LastMatchWins(t *testing.T) {
func TestMockSSHClient_Good_LastMatchWins(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommand("echo", "first", "", 0)
mock.expectCommand("echo", "second", "", 0)
@ -64,7 +65,7 @@ func TestModulesCmd_MockSSHClient_Good_LastMatchWins(t *testing.T) {
assert.Equal(t, "second", stdout)
}
func TestModulesCmd_MockSSHClient_Good_FileOperations(t *testing.T) {
func TestMockSSHClient_Good_FileOperations(t *testing.T) {
mock := NewMockSSHClient()
// File does not exist initially
@ -90,7 +91,7 @@ func TestModulesCmd_MockSSHClient_Good_FileOperations(t *testing.T) {
assert.Error(t, err)
}
func TestModulesCmd_MockSSHClient_Good_StatWithExplicit(t *testing.T) {
func TestMockSSHClient_Good_StatWithExplicit(t *testing.T) {
mock := NewMockSSHClient()
mock.addStat("/var/log", map[string]any{"exists": true, "isdir": true})
@ -100,7 +101,7 @@ func TestModulesCmd_MockSSHClient_Good_StatWithExplicit(t *testing.T) {
assert.Equal(t, true, info["isdir"])
}
func TestModulesCmd_MockSSHClient_Good_StatFallback(t *testing.T) {
func TestMockSSHClient_Good_StatFallback(t *testing.T) {
mock := NewMockSSHClient()
mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost"))
@ -114,7 +115,7 @@ func TestModulesCmd_MockSSHClient_Good_StatFallback(t *testing.T) {
assert.Equal(t, false, info["exists"])
}
func TestModulesCmd_MockSSHClient_Good_BecomeTracking(t *testing.T) {
func TestMockSSHClient_Good_BecomeTracking(t *testing.T) {
mock := NewMockSSHClient()
assert.False(t, mock.become)
@ -127,26 +128,7 @@ func TestModulesCmd_MockSSHClient_Good_BecomeTracking(t *testing.T) {
assert.Equal(t, "secret", mock.becomePass)
}
func TestModulesCmd_ModuleScript_Good_RelativePathResolvedAgainstBasePath(t *testing.T) {
dir := t.TempDir()
scriptPath := joinPath(dir, "scripts", "deploy.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("echo deploy"), 0755))
e := NewExecutor(dir)
mock := NewMockSSHClient()
mock.expectCommand("echo deploy", "deploy\n", "", 0)
result, err := e.moduleScript(context.Background(), mock, map[string]any{
"_raw_params": "scripts/deploy.sh",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, "deploy\n", result.Stdout)
assert.True(t, mock.hasExecuted("echo deploy"))
}
func TestModulesCmd_MockSSHClient_Good_HasExecuted(t *testing.T) {
func TestMockSSHClient_Good_HasExecuted(t *testing.T) {
mock := NewMockSSHClient()
_, _, _, _ = mock.Run(nil, "systemctl restart nginx")
_, _, _, _ = mock.Run(nil, "apt-get update")
@ -156,7 +138,7 @@ func TestModulesCmd_MockSSHClient_Good_HasExecuted(t *testing.T) {
assert.False(t, mock.hasExecuted("yum"))
}
func TestModulesCmd_MockSSHClient_Good_HasExecutedMethod(t *testing.T) {
func TestMockSSHClient_Good_HasExecutedMethod(t *testing.T) {
mock := NewMockSSHClient()
_, _, _, _ = mock.Run(nil, "echo run")
_, _, _, _ = mock.RunScript(nil, "echo script")
@ -167,7 +149,7 @@ func TestModulesCmd_MockSSHClient_Good_HasExecutedMethod(t *testing.T) {
assert.False(t, mock.hasExecutedMethod("RunScript", "echo run"))
}
func TestModulesCmd_MockSSHClient_Good_Reset(t *testing.T) {
func TestMockSSHClient_Good_Reset(t *testing.T) {
mock := NewMockSSHClient()
_, _, _, _ = mock.Run(nil, "echo hello")
assert.Equal(t, 1, mock.commandCount())
@ -176,7 +158,7 @@ func TestModulesCmd_MockSSHClient_Good_Reset(t *testing.T) {
assert.Equal(t, 0, mock.commandCount())
}
func TestModulesCmd_MockSSHClient_Good_ErrorExpectation(t *testing.T) {
func TestMockSSHClient_Good_ErrorExpectation(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommandError("bad cmd", assert.AnError)
@ -186,7 +168,7 @@ func TestModulesCmd_MockSSHClient_Good_ErrorExpectation(t *testing.T) {
// --- command module ---
func TestModulesCmd_ModuleCommand_Good_BasicCommand(t *testing.T) {
func TestModuleCommand_Good_BasicCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("ls -la /tmp", "total 0\n", "", 0)
@ -205,7 +187,7 @@ func TestModulesCmd_ModuleCommand_Good_BasicCommand(t *testing.T) {
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
}
func TestModulesCmd_ModuleCommand_Good_CmdArg(t *testing.T) {
func TestModuleCommand_Good_CmdArg(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("whoami", "root\n", "", 0)
@ -219,26 +201,7 @@ func TestModulesCmd_ModuleCommand_Good_CmdArg(t *testing.T) {
assert.True(t, mock.hasExecutedMethod("Run", "whoami"))
}
func TestModulesCmd_ModuleCommand_Good_Argv(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`"echo".*"hello world"`, "hello world\n", "", 0)
task := &Task{
Module: "command",
Args: map[string]any{
"argv": []any{"echo", "hello world"},
},
}
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, "hello world\n", result.Stdout)
assert.True(t, mock.hasExecuted(`hello world`))
}
func TestModulesCmd_ModuleCommand_Good_WithChdir(t *testing.T) {
func TestModuleCommand_Good_WithChdir(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`cd "/var/log" && ls`, "syslog\n", "", 0)
@ -256,26 +219,7 @@ func TestModulesCmd_ModuleCommand_Good_WithChdir(t *testing.T) {
assert.Contains(t, last.Cmd, "ls")
}
func TestModulesCmd_ModuleCommand_Good_WithStdin(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("cat", "input\n", "", 0)
result, err := moduleCommandWithClient(e, mock, map[string]any{
"_raw_params": "cat",
"stdin": "payload",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, "input\n", result.Stdout)
last := mock.lastCommand()
assert.Equal(t, "Run", last.Method)
assert.Contains(t, last.Cmd, "printf %s")
assert.Contains(t, last.Cmd, "| cat")
assert.Contains(t, last.Cmd, "payload\n")
}
func TestModulesCmd_ModuleCommand_Bad_NoCommand(t *testing.T) {
func TestModuleCommand_Bad_NoCommand(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -285,7 +229,7 @@ func TestModulesCmd_ModuleCommand_Bad_NoCommand(t *testing.T) {
assert.Contains(t, err.Error(), "no command specified")
}
func TestModulesCmd_ModuleCommand_Good_NonZeroRC(t *testing.T) {
func TestModuleCommand_Good_NonZeroRC(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("false", "", "error occurred", 1)
@ -299,7 +243,7 @@ func TestModulesCmd_ModuleCommand_Good_NonZeroRC(t *testing.T) {
assert.Equal(t, "error occurred", result.Stderr)
}
func TestModulesCmd_ModuleCommand_Good_SSHError(t *testing.T) {
func TestModuleCommand_Good_SSHError(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
@ -313,7 +257,7 @@ func TestModulesCmd_ModuleCommand_Good_SSHError(t *testing.T) {
assert.Contains(t, result.Msg, assert.AnError.Error())
}
func TestModulesCmd_ModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
func TestModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("from_raw", "raw\n", "", 0)
@ -327,47 +271,9 @@ func TestModulesCmd_ModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
assert.True(t, mock.hasExecuted("from_raw"))
}
func TestModulesCmd_ModuleCommand_Good_SkipsWhenCreatesExists(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.addFile("/tmp/output.txt", []byte("done"))
task := &Task{
Module: "ansible.builtin.command",
Args: map[string]any{
"_raw_params": "echo should-not-run",
"creates": "/tmp/output.txt",
},
}
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
require.NoError(t, err)
assert.False(t, result.Changed)
assert.Equal(t, 0, mock.commandCount())
}
func TestModulesCmd_ModuleCommand_Good_SkipsWhenCreatesExistsUnderChdir(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.addFile("/app/build/output.txt", []byte("done"))
task := &Task{
Module: "ansible.builtin.command",
Args: map[string]any{
"_raw_params": "echo should-not-run",
"creates": "build/output.txt",
"chdir": "/app",
},
}
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
require.NoError(t, err)
assert.False(t, result.Changed)
assert.Equal(t, 0, mock.commandCount())
}
// --- shell module ---
func TestModulesCmd_ModuleShell_Good_BasicShell(t *testing.T) {
func TestModuleShell_Good_BasicShell(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo hello", "hello\n", "", 0)
@ -385,7 +291,7 @@ func TestModulesCmd_ModuleShell_Good_BasicShell(t *testing.T) {
assert.False(t, mock.hasExecutedMethod("Run", ".*"))
}
func TestModulesCmd_ModuleShell_Good_CmdArg(t *testing.T) {
func TestModuleShell_Good_CmdArg(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("date", "Thu Feb 20\n", "", 0)
@ -398,7 +304,7 @@ func TestModulesCmd_ModuleShell_Good_CmdArg(t *testing.T) {
assert.True(t, mock.hasExecutedMethod("RunScript", "date"))
}
func TestModulesCmd_ModuleShell_Good_WithChdir(t *testing.T) {
func TestModuleShell_Good_WithChdir(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`cd "/app" && npm install`, "done\n", "", 0)
@ -415,27 +321,7 @@ func TestModulesCmd_ModuleShell_Good_WithChdir(t *testing.T) {
assert.Contains(t, last.Cmd, "npm install")
}
func TestModulesCmd_ModuleShell_Good_ExecutableUsesRun(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`/bin/dash.*echo test`, "test\n", "", 0)
result, err := e.moduleShell(context.Background(), mock, map[string]any{
"_raw_params": "echo test",
"executable": "/bin/dash",
})
require.NoError(t, err)
assert.True(t, result.Changed)
last := mock.lastCommand()
require.NotNil(t, last)
assert.Equal(t, "Run", last.Method)
assert.Contains(t, last.Cmd, "/bin/dash")
assert.Contains(t, last.Cmd, "-c")
assert.Contains(t, last.Cmd, "echo test")
}
func TestModulesCmd_ModuleShell_Bad_NoCommand(t *testing.T) {
func TestModuleShell_Bad_NoCommand(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -445,7 +331,7 @@ func TestModulesCmd_ModuleShell_Bad_NoCommand(t *testing.T) {
assert.Contains(t, err.Error(), "no command specified")
}
func TestModulesCmd_ModuleShell_Good_NonZeroRC(t *testing.T) {
func TestModuleShell_Good_NonZeroRC(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("exit 2", "", "failed", 2)
@ -458,24 +344,7 @@ func TestModulesCmd_ModuleShell_Good_NonZeroRC(t *testing.T) {
assert.Equal(t, 2, result.RC)
}
func TestModulesCmd_ModuleShell_Good_SkipsWhenRemovesMissing(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
task := &Task{
Module: "ansible.builtin.shell",
Args: map[string]any{
"_raw_params": "echo should-not-run",
"removes": "/tmp/missing.txt",
},
}
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
require.NoError(t, err)
assert.False(t, result.Changed)
assert.Equal(t, 0, mock.commandCount())
}
func TestModulesCmd_ModuleShell_Good_SSHError(t *testing.T) {
func TestModuleShell_Good_SSHError(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
@ -488,7 +357,7 @@ func TestModulesCmd_ModuleShell_Good_SSHError(t *testing.T) {
assert.True(t, result.Failed)
}
func TestModulesCmd_ModuleShell_Good_PipelineCommand(t *testing.T) {
func TestModuleShell_Good_PipelineCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`cat /etc/passwd \| grep root`, "root:x:0:0\n", "", 0)
@ -504,7 +373,7 @@ func TestModulesCmd_ModuleShell_Good_PipelineCommand(t *testing.T) {
// --- raw module ---
func TestModulesCmd_ModuleRaw_Good_BasicRaw(t *testing.T) {
func TestModuleRaw_Good_BasicRaw(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("uname -a", "Linux host1 5.15\n", "", 0)
@ -521,7 +390,7 @@ func TestModulesCmd_ModuleRaw_Good_BasicRaw(t *testing.T) {
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
}
func TestModulesCmd_ModuleRaw_Bad_NoCommand(t *testing.T) {
func TestModuleRaw_Bad_NoCommand(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -531,7 +400,7 @@ func TestModulesCmd_ModuleRaw_Bad_NoCommand(t *testing.T) {
assert.Contains(t, err.Error(), "no command specified")
}
func TestModulesCmd_ModuleRaw_Good_NoChdir(t *testing.T) {
func TestModuleRaw_Good_NoChdir(t *testing.T) {
// Raw module does NOT support chdir — it should ignore it
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
@ -549,7 +418,7 @@ func TestModulesCmd_ModuleRaw_Good_NoChdir(t *testing.T) {
assert.NotContains(t, last.Cmd, "cd")
}
func TestModulesCmd_ModuleRaw_Good_NonZeroRC(t *testing.T) {
func TestModuleRaw_Good_NonZeroRC(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("invalid", "", "not found", 127)
@ -563,7 +432,7 @@ func TestModulesCmd_ModuleRaw_Good_NonZeroRC(t *testing.T) {
assert.Equal(t, "not found", result.Stderr)
}
func TestModulesCmd_ModuleRaw_Good_SSHError(t *testing.T) {
func TestModuleRaw_Good_SSHError(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
@ -576,7 +445,7 @@ func TestModulesCmd_ModuleRaw_Good_SSHError(t *testing.T) {
assert.True(t, result.Failed)
}
func TestModulesCmd_ModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
func TestModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
// Raw should pass the command exactly as given — no wrapping
e, mock := newTestExecutorWithMock("host1")
complexCmd := `/usr/bin/python3 -c 'import sys; print(sys.version)'`
@ -594,12 +463,12 @@ func TestModulesCmd_ModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
// --- script module ---
func TestModulesCmd_ModuleScript_Good_BasicScript(t *testing.T) {
func TestModuleScript_Good_BasicScript(t *testing.T) {
// Create a temporary script file
tmpDir := t.TempDir()
scriptPath := joinPath(tmpDir, "setup.sh")
scriptPath := filepath.Join(tmpDir, "setup.sh")
scriptContent := "#!/bin/bash\necho 'setup complete'\nexit 0"
require.NoError(t, writeTestFile(scriptPath, []byte(scriptContent), 0755))
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("setup complete", "setup complete\n", "", 0)
@ -621,73 +490,7 @@ func TestModulesCmd_ModuleScript_Good_BasicScript(t *testing.T) {
assert.Equal(t, scriptContent, last.Cmd)
}
func TestModulesCmd_ModuleScript_Good_CreatesSkipsExecution(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := joinPath(tmpDir, "setup.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("echo should-not-run"), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.addFile("/tmp/already-there", []byte("present"))
result, err := e.moduleScript(context.Background(), mock, map[string]any{
"_raw_params": scriptPath,
"creates": "/tmp/already-there",
})
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.Changed)
assert.Equal(t, 0, mock.commandCount())
}
func TestModulesCmd_ModuleScript_Good_ChdirPrefixesScript(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := joinPath(tmpDir, "work.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("pwd"), 0755))
e, mock := newTestExecutorWithMock("host1")
result, err := e.moduleScript(context.Background(), mock, map[string]any{
"_raw_params": scriptPath,
"chdir": "/opt/app",
})
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.Changed)
last := mock.lastCommand()
require.NotNil(t, last)
assert.Equal(t, "RunScript", last.Method)
assert.Equal(t, `cd "/opt/app" && pwd`, last.Cmd)
}
func TestModulesCmd_ModuleScript_Good_ExecutableUsesRun(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := joinPath(tmpDir, "dash.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("echo script works"), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`/bin/dash.*echo script works`, "script works\n", "", 0)
result, err := e.moduleScript(context.Background(), mock, map[string]any{
"_raw_params": scriptPath,
"executable": "/bin/dash",
})
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.Changed)
last := mock.lastCommand()
require.NotNil(t, last)
assert.Equal(t, "Run", last.Method)
assert.Contains(t, last.Cmd, "/bin/dash")
assert.Contains(t, last.Cmd, "-c")
assert.Contains(t, last.Cmd, "echo script works")
}
func TestModulesCmd_ModuleScript_Bad_NoScript(t *testing.T) {
func TestModuleScript_Bad_NoScript(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -697,7 +500,7 @@ func TestModulesCmd_ModuleScript_Bad_NoScript(t *testing.T) {
assert.Contains(t, err.Error(), "no script specified")
}
func TestModulesCmd_ModuleScript_Bad_FileNotFound(t *testing.T) {
func TestModuleScript_Bad_FileNotFound(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -709,10 +512,10 @@ func TestModulesCmd_ModuleScript_Bad_FileNotFound(t *testing.T) {
assert.Contains(t, err.Error(), "read script")
}
func TestModulesCmd_ModuleScript_Good_NonZeroRC(t *testing.T) {
func TestModuleScript_Good_NonZeroRC(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := joinPath(tmpDir, "fail.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("exit 1"), 0755))
scriptPath := filepath.Join(tmpDir, "fail.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("exit 1"), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("exit 1", "", "script failed", 1)
@ -726,11 +529,11 @@ func TestModulesCmd_ModuleScript_Good_NonZeroRC(t *testing.T) {
assert.Equal(t, 1, result.RC)
}
func TestModulesCmd_ModuleScript_Good_MultiLineScript(t *testing.T) {
func TestModuleScript_Good_MultiLineScript(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := joinPath(tmpDir, "multi.sh")
scriptPath := filepath.Join(tmpDir, "multi.sh")
scriptContent := "#!/bin/bash\nset -e\napt-get update\napt-get install -y nginx\nsystemctl start nginx"
require.NoError(t, writeTestFile(scriptPath, []byte(scriptContent), 0755))
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("apt-get", "done\n", "", 0)
@ -748,10 +551,10 @@ func TestModulesCmd_ModuleScript_Good_MultiLineScript(t *testing.T) {
assert.Equal(t, scriptContent, last.Cmd)
}
func TestModulesCmd_ModuleScript_Good_SSHError(t *testing.T) {
func TestModuleScript_Good_SSHError(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := joinPath(tmpDir, "ok.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("echo ok"), 0755))
scriptPath := filepath.Join(tmpDir, "ok.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("echo ok"), 0755))
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -767,7 +570,7 @@ func TestModulesCmd_ModuleScript_Good_SSHError(t *testing.T) {
// --- Cross-module differentiation tests ---
func TestModulesCmd_ModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
func TestModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
@ -778,7 +581,7 @@ func TestModulesCmd_ModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
assert.Equal(t, "Run", cmds[0].Method, "command module must use Run()")
}
func TestModulesCmd_ModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
func TestModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
@ -789,23 +592,7 @@ func TestModulesCmd_ModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T)
assert.Equal(t, "RunScript", cmds[0].Method, "shell module must use RunScript()")
}
func TestModulesCmd_ModuleDifferentiation_Good_ShellWithStdinStillUsesRunScript(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
_, _ = moduleShellWithClient(e, mock, map[string]any{
"_raw_params": "echo test",
"stdin": "payload",
})
cmds := mock.executedCommands()
require.Len(t, cmds, 1)
assert.Equal(t, "RunScript", cmds[0].Method, "shell module must still use RunScript()")
assert.Contains(t, cmds[0].Cmd, "printf %s")
assert.Contains(t, cmds[0].Cmd, "| echo test")
}
func TestModulesCmd_ModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
func TestModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
@ -816,10 +603,10 @@ func TestModulesCmd_ModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
assert.Equal(t, "Run", cmds[0].Method, "raw module must use Run()")
}
func TestModulesCmd_ModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
func TestModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := joinPath(tmpDir, "test.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("echo test"), 0755))
scriptPath := filepath.Join(tmpDir, "test.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("echo test"), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
@ -833,7 +620,7 @@ func TestModulesCmd_ModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T)
// --- executeModuleWithMock dispatch tests ---
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("uptime", "up 5 days\n", "", 0)
@ -849,7 +636,7 @@ func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
assert.Equal(t, "up 5 days\n", result.Stdout)
}
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("ps aux", "root.*bash\n", "", 0)
@ -864,7 +651,7 @@ func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
assert.True(t, result.Changed)
}
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("cat /etc/hostname", "web01\n", "", 0)
@ -880,10 +667,10 @@ func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
assert.Equal(t, "web01\n", result.Stdout)
}
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := joinPath(tmpDir, "deploy.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("echo deploying"), 0755))
scriptPath := filepath.Join(tmpDir, "deploy.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("echo deploying"), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("deploying", "deploying\n", "", 0)
@ -899,7 +686,7 @@ func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
assert.True(t, result.Changed)
}
func TestModulesCmd_ExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
task := &Task{
@ -911,12 +698,11 @@ func TestModulesCmd_ExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported module")
assert.Contains(t, err.Error(), "ansible.builtin.hostname")
}
// --- Template integration tests ---
func TestModulesCmd_ModuleCommand_Good_TemplatedArgs(t *testing.T) {
func TestModuleCommand_Good_TemplatedArgs(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
e.SetVar("service_name", "nginx")
mock.expectCommand("systemctl status nginx", "active\n", "", 0)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@ import (
// --- service module ---
func TestModulesSvc_ModuleService_Good_Start(t *testing.T) {
func TestModuleService_Good_Start(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start nginx`, "Started", "", 0)
@ -29,7 +29,7 @@ func TestModulesSvc_ModuleService_Good_Start(t *testing.T) {
assert.Equal(t, 1, mock.commandCount())
}
func TestModulesSvc_ModuleService_Good_Stop(t *testing.T) {
func TestModuleService_Good_Stop(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl stop nginx`, "", "", 0)
@ -44,7 +44,7 @@ func TestModulesSvc_ModuleService_Good_Stop(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl stop nginx`))
}
func TestModulesSvc_ModuleService_Good_Restart(t *testing.T) {
func TestModuleService_Good_Restart(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl restart docker`, "", "", 0)
@ -59,7 +59,7 @@ func TestModulesSvc_ModuleService_Good_Restart(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl restart docker`))
}
func TestModulesSvc_ModuleService_Good_Reload(t *testing.T) {
func TestModuleService_Good_Reload(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl reload nginx`, "", "", 0)
@ -74,7 +74,7 @@ func TestModulesSvc_ModuleService_Good_Reload(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl reload nginx`))
}
func TestModulesSvc_ModuleService_Good_Enable(t *testing.T) {
func TestModuleService_Good_Enable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
@ -89,7 +89,7 @@ func TestModulesSvc_ModuleService_Good_Enable(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
}
func TestModulesSvc_ModuleService_Good_Disable(t *testing.T) {
func TestModuleService_Good_Disable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl disable nginx`, "", "", 0)
@ -104,7 +104,7 @@ func TestModulesSvc_ModuleService_Good_Disable(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl disable nginx`))
}
func TestModulesSvc_ModuleService_Good_StartAndEnable(t *testing.T) {
func TestModuleService_Good_StartAndEnable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start nginx`, "", "", 0)
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
@ -123,7 +123,7 @@ func TestModulesSvc_ModuleService_Good_StartAndEnable(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
}
func TestModulesSvc_ModuleService_Good_RestartAndDisable(t *testing.T) {
func TestModuleService_Good_RestartAndDisable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl restart sshd`, "", "", 0)
mock.expectCommand(`systemctl disable sshd`, "", "", 0)
@ -142,7 +142,7 @@ func TestModulesSvc_ModuleService_Good_RestartAndDisable(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl disable sshd`))
}
func TestModulesSvc_ModuleService_Bad_MissingName(t *testing.T) {
func TestModuleService_Bad_MissingName(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -154,7 +154,7 @@ func TestModulesSvc_ModuleService_Bad_MissingName(t *testing.T) {
assert.Contains(t, err.Error(), "name required")
}
func TestModulesSvc_ModuleService_Good_NoStateNoEnabled(t *testing.T) {
func TestModuleService_Good_NoStateNoEnabled(t *testing.T) {
// When neither state nor enabled is provided, no commands run
e, mock := newTestExecutorWithMock("host1")
@ -168,7 +168,7 @@ func TestModulesSvc_ModuleService_Good_NoStateNoEnabled(t *testing.T) {
assert.Equal(t, 0, mock.commandCount())
}
func TestModulesSvc_ModuleService_Good_CommandFailure(t *testing.T) {
func TestModuleService_Good_CommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start.*`, "", "Failed to start nginx.service", 1)
@ -183,7 +183,7 @@ func TestModulesSvc_ModuleService_Good_CommandFailure(t *testing.T) {
assert.Equal(t, 1, result.RC)
}
func TestModulesSvc_ModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
func TestModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
// When state command fails, enable should not run
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start`, "", "unit not found", 5)
@ -203,7 +203,7 @@ func TestModulesSvc_ModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T)
// --- systemd module ---
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
func TestModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
mock.expectCommand(`systemctl start nginx`, "", "", 0)
@ -225,7 +225,7 @@ func TestModulesSvc_ModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
assert.Contains(t, cmds[1].Cmd, "systemctl start nginx")
}
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
func TestModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
@ -243,7 +243,7 @@ func TestModulesSvc_ModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
}
func TestModulesSvc_ModuleSystemd_Good_DelegationToService(t *testing.T) {
func TestModuleSystemd_Good_DelegationToService(t *testing.T) {
// Without daemon_reload, systemd delegates entirely to service
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl restart docker`, "", "", 0)
@ -261,7 +261,7 @@ func TestModulesSvc_ModuleSystemd_Good_DelegationToService(t *testing.T) {
assert.False(t, mock.hasExecuted(`daemon-reload`))
}
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
func TestModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
mock.expectCommand(`systemctl enable myapp`, "", "", 0)
@ -281,7 +281,7 @@ func TestModulesSvc_ModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
// --- apt module ---
func TestModulesSvc_ModuleApt_Good_InstallPresent(t *testing.T) {
func TestModuleApt_Good_InstallPresent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq nginx`, "installed", "", 0)
@ -296,7 +296,7 @@ func TestModulesSvc_ModuleApt_Good_InstallPresent(t *testing.T) {
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx`))
}
func TestModulesSvc_ModuleApt_Good_InstallInstalled(t *testing.T) {
func TestModuleApt_Good_InstallInstalled(t *testing.T) {
// state=installed is an alias for present
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq curl`, "", "", 0)
@ -312,7 +312,7 @@ func TestModulesSvc_ModuleApt_Good_InstallInstalled(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-get install -y -qq curl`))
}
func TestModulesSvc_ModuleApt_Good_RemoveAbsent(t *testing.T) {
func TestModuleApt_Good_RemoveAbsent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
@ -327,7 +327,7 @@ func TestModulesSvc_ModuleApt_Good_RemoveAbsent(t *testing.T) {
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq nginx`))
}
func TestModulesSvc_ModuleApt_Good_RemoveRemoved(t *testing.T) {
func TestModuleApt_Good_RemoveRemoved(t *testing.T) {
// state=removed is an alias for absent
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
@ -342,7 +342,7 @@ func TestModulesSvc_ModuleApt_Good_RemoveRemoved(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nginx`))
}
func TestModulesSvc_ModuleApt_Good_UpgradeLatest(t *testing.T) {
func TestModuleApt_Good_UpgradeLatest(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq --only-upgrade nginx`, "", "", 0)
@ -357,7 +357,7 @@ func TestModulesSvc_ModuleApt_Good_UpgradeLatest(t *testing.T) {
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade nginx`))
}
func TestModulesSvc_ModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
func TestModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get update`, "", "", 0)
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
@ -379,7 +379,7 @@ func TestModulesSvc_ModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
assert.Contains(t, cmds[1].Cmd, "apt-get install")
}
func TestModulesSvc_ModuleApt_Good_UpdateCacheOnly(t *testing.T) {
func TestModuleApt_Good_UpdateCacheOnly(t *testing.T) {
// update_cache with no name means update only, no install
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get update`, "", "", 0)
@ -395,7 +395,7 @@ func TestModulesSvc_ModuleApt_Good_UpdateCacheOnly(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-get update`))
}
func TestModulesSvc_ModuleApt_Good_CommandFailure(t *testing.T) {
func TestModuleApt_Good_CommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install`, "", "E: Unable to locate package badpkg", 100)
@ -410,7 +410,7 @@ func TestModulesSvc_ModuleApt_Good_CommandFailure(t *testing.T) {
assert.Equal(t, 100, result.RC)
}
func TestModulesSvc_ModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
func TestModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
// If no state is given, default is "present" (install)
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq vim`, "", "", 0)
@ -424,22 +424,9 @@ func TestModulesSvc_ModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
}
func TestModulesSvc_ModuleApt_Good_InstallMultiplePackages(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq nginx curl`, "", "", 0)
result, err := moduleAptWithClient(e, mock, map[string]any{
"name": []any{"nginx", "curl"},
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`apt-get install -y -qq nginx curl`))
}
// --- apt_key module ---
func TestModulesSvc_ModuleAptKey_Good_AddWithKeyring(t *testing.T) {
func TestModuleAptKey_Good_AddWithKeyring(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`curl -fsSL.*gpg --dearmor`, "", "", 0)
@ -456,7 +443,7 @@ func TestModulesSvc_ModuleAptKey_Good_AddWithKeyring(t *testing.T) {
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/example.gpg"))
}
func TestModulesSvc_ModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
func TestModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`curl -fsSL.*apt-key add -`, "", "", 0)
@ -470,7 +457,7 @@ func TestModulesSvc_ModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-key add -`))
}
func TestModulesSvc_ModuleAptKey_Good_RemoveKey(t *testing.T) {
func TestModuleAptKey_Good_RemoveKey(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
@ -485,7 +472,7 @@ func TestModulesSvc_ModuleAptKey_Good_RemoveKey(t *testing.T) {
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/old.gpg"))
}
func TestModulesSvc_ModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
func TestModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
// Absent with no keyring — still succeeds, just no rm command
e, mock := newTestExecutorWithMock("host1")
@ -498,7 +485,7 @@ func TestModulesSvc_ModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
assert.Equal(t, 0, mock.commandCount())
}
func TestModulesSvc_ModuleAptKey_Bad_MissingURL(t *testing.T) {
func TestModuleAptKey_Bad_MissingURL(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -510,7 +497,7 @@ func TestModulesSvc_ModuleAptKey_Bad_MissingURL(t *testing.T) {
assert.Contains(t, err.Error(), "url required")
}
func TestModulesSvc_ModuleAptKey_Good_CommandFailure(t *testing.T) {
func TestModuleAptKey_Good_CommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`curl`, "", "curl: (22) 404 Not Found", 22)
@ -526,7 +513,7 @@ func TestModulesSvc_ModuleAptKey_Good_CommandFailure(t *testing.T) {
// --- apt_repository module ---
func TestModulesSvc_ModuleAptRepository_Good_AddRepository(t *testing.T) {
func TestModuleAptRepository_Good_AddRepository(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo.*sources\.list\.d`, "", "", 0)
mock.expectCommand(`apt-get update`, "", "", 0)
@ -542,7 +529,7 @@ func TestModulesSvc_ModuleAptRepository_Good_AddRepository(t *testing.T) {
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/example.list"))
}
func TestModulesSvc_ModuleAptRepository_Good_RemoveRepository(t *testing.T) {
func TestModuleAptRepository_Good_RemoveRepository(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
@ -558,7 +545,7 @@ func TestModulesSvc_ModuleAptRepository_Good_RemoveRepository(t *testing.T) {
assert.True(t, mock.containsSubstring("example.list"))
}
func TestModulesSvc_ModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
func TestModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
mock.expectCommand(`apt-get update`, "", "", 0)
@ -577,7 +564,7 @@ func TestModulesSvc_ModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-get update`))
}
func TestModulesSvc_ModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
func TestModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
@ -595,7 +582,7 @@ func TestModulesSvc_ModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T)
assert.False(t, mock.hasExecuted(`apt-get update`))
}
func TestModulesSvc_ModuleAptRepository_Good_CustomFilename(t *testing.T) {
func TestModuleAptRepository_Good_CustomFilename(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
mock.expectCommand(`apt-get update`, "", "", 0)
@ -610,7 +597,7 @@ func TestModulesSvc_ModuleAptRepository_Good_CustomFilename(t *testing.T) {
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/custom-ppa.list"))
}
func TestModulesSvc_ModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T) {
func TestModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T) {
// When no filename is given, it auto-generates from the repo string
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
@ -626,7 +613,7 @@ func TestModulesSvc_ModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T)
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/"))
}
func TestModulesSvc_ModuleAptRepository_Bad_MissingRepo(t *testing.T) {
func TestModuleAptRepository_Bad_MissingRepo(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -638,7 +625,7 @@ func TestModulesSvc_ModuleAptRepository_Bad_MissingRepo(t *testing.T) {
assert.Contains(t, err.Error(), "repo required")
}
func TestModulesSvc_ModuleAptRepository_Good_WriteFailure(t *testing.T) {
func TestModuleAptRepository_Good_WriteFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "permission denied", 1)
@ -654,7 +641,7 @@ func TestModulesSvc_ModuleAptRepository_Good_WriteFailure(t *testing.T) {
// --- package module ---
func TestModulesSvc_ModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
func TestModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
// First command: which apt-get returns the path
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
@ -673,7 +660,7 @@ func TestModulesSvc_ModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-get install -y -qq htop`))
}
func TestModulesSvc_ModulePackage_Good_FallbackToApt(t *testing.T) {
func TestModulePackage_Good_FallbackToApt(t *testing.T) {
// When which returns nothing (no package manager found), still falls back to apt
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`which apt-get`, "", "", 1)
@ -690,7 +677,7 @@ func TestModulesSvc_ModulePackage_Good_FallbackToApt(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
}
func TestModulesSvc_ModulePackage_Good_RemovePackage(t *testing.T) {
func TestModulePackage_Good_RemovePackage(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
mock.expectCommand(`apt-get remove -y -qq nano`, "", "", 0)
@ -705,113 +692,9 @@ func TestModulesSvc_ModulePackage_Good_RemovePackage(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nano`))
}
func TestModulesSvc_ModulePackage_Good_DetectYumAndDelegate(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`which apt-get yum dnf`, "/usr/bin/yum", "", 0)
mock.expectCommand(`yum install -y -q htop`, "", "", 0)
result, err := modulePackageWithClient(e, mock, map[string]any{
"name": "htop",
"state": "present",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`yum install -y -q htop`))
}
func TestModulesSvc_ModulePackage_Good_DetectDnfAndDelegate(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`which apt-get yum dnf`, "/usr/bin/dnf", "", 0)
mock.expectCommand(`dnf upgrade -y -q vim`, "", "", 0)
result, err := modulePackageWithClient(e, mock, map[string]any{
"name": "vim",
"state": "latest",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`dnf upgrade -y -q vim`))
}
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchYum(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`yum install -y -q htop`, "", "", 0)
task := &Task{
Module: "yum",
Args: map[string]any{
"name": "htop",
"state": "present",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`yum install -y -q htop`))
}
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchDnf(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`dnf remove -y -q nano`, "", "", 0)
task := &Task{
Module: "dnf",
Args: map[string]any{
"name": "nano",
"state": "absent",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`dnf remove -y -q nano`))
}
func TestModulesSvc_ModuleRpm_Good_InstallPackage(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommand(`rpm -ivh /tmp/nginx.rpm`, "", "", 0)
result, err := moduleRPMWithClient(mock, map[string]any{
"name": "/tmp/nginx.rpm",
"state": "present",
}, "rpm")
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`rpm -ivh /tmp/nginx.rpm`))
}
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchRpm(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`rpm -e nginx`, "", "", 0)
task := &Task{
Module: "rpm",
Args: map[string]any{
"name": "nginx",
"state": "absent",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`rpm -e nginx`))
}
// --- pip module ---
func TestModulesSvc_ModulePip_Good_InstallPresent(t *testing.T) {
func TestModulePip_Good_InstallPresent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install flask`, "Successfully installed", "", 0)
@ -826,7 +709,7 @@ func TestModulesSvc_ModulePip_Good_InstallPresent(t *testing.T) {
assert.True(t, mock.hasExecuted(`pip3 install flask`))
}
func TestModulesSvc_ModulePip_Good_UninstallAbsent(t *testing.T) {
func TestModulePip_Good_UninstallAbsent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 uninstall -y flask`, "Successfully uninstalled", "", 0)
@ -841,7 +724,7 @@ func TestModulesSvc_ModulePip_Good_UninstallAbsent(t *testing.T) {
assert.True(t, mock.hasExecuted(`pip3 uninstall -y flask`))
}
func TestModulesSvc_ModulePip_Good_UpgradeLatest(t *testing.T) {
func TestModulePip_Good_UpgradeLatest(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install --upgrade flask`, "Successfully installed", "", 0)
@ -856,7 +739,7 @@ func TestModulesSvc_ModulePip_Good_UpgradeLatest(t *testing.T) {
assert.True(t, mock.hasExecuted(`pip3 install --upgrade flask`))
}
func TestModulesSvc_ModulePip_Good_CustomExecutable(t *testing.T) {
func TestModulePip_Good_CustomExecutable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`/opt/venv/bin/pip install requests`, "", "", 0)
@ -872,37 +755,7 @@ func TestModulesSvc_ModulePip_Good_CustomExecutable(t *testing.T) {
assert.True(t, mock.hasExecuted(`/opt/venv/bin/pip install requests`))
}
func TestModulesSvc_ModulePip_Good_RequirementsFile(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install -r "/tmp/requirements.txt"`, "", "", 0)
result, err := modulePipWithClient(e, mock, map[string]any{
"requirements": "/tmp/requirements.txt",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`pip3 install -r "/tmp/requirements.txt"`))
}
func TestModulesSvc_ModulePip_Good_VirtualenvUsesVenvPip(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`/opt/venv/bin/pip install requests`, "", "", 0)
result, err := modulePipWithClient(e, mock, map[string]any{
"name": "requests",
"state": "present",
"virtualenv": "/opt/venv",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`/opt/venv/bin/pip install requests`))
}
func TestModulesSvc_ModulePip_Good_DefaultStateIsPresent(t *testing.T) {
func TestModulePip_Good_DefaultStateIsPresent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install django`, "", "", 0)
@ -915,20 +768,7 @@ func TestModulesSvc_ModulePip_Good_DefaultStateIsPresent(t *testing.T) {
assert.True(t, mock.hasExecuted(`pip3 install django`))
}
func TestModulesSvc_ModulePip_Good_InstallMultiplePackages(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install requests flask`, "", "", 0)
result, err := modulePipWithClient(e, mock, map[string]any{
"name": []any{"requests", "flask"},
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`pip3 install requests flask`))
}
func TestModulesSvc_ModulePip_Good_CommandFailure(t *testing.T) {
func TestModulePip_Good_CommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install`, "", "ERROR: No matching distribution found", 1)
@ -942,7 +782,7 @@ func TestModulesSvc_ModulePip_Good_CommandFailure(t *testing.T) {
assert.Contains(t, result.Msg, "No matching distribution found")
}
func TestModulesSvc_ModulePip_Good_InstalledAlias(t *testing.T) {
func TestModulePip_Good_InstalledAlias(t *testing.T) {
// state=installed is an alias for present
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install boto3`, "", "", 0)
@ -957,7 +797,7 @@ func TestModulesSvc_ModulePip_Good_InstalledAlias(t *testing.T) {
assert.True(t, mock.hasExecuted(`pip3 install boto3`))
}
func TestModulesSvc_ModulePip_Good_RemovedAlias(t *testing.T) {
func TestModulePip_Good_RemovedAlias(t *testing.T) {
// state=removed is an alias for absent
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 uninstall -y boto3`, "", "", 0)
@ -974,7 +814,7 @@ func TestModulesSvc_ModulePip_Good_RemovedAlias(t *testing.T) {
// --- Cross-module dispatch tests ---
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl restart nginx`, "", "", 0)
@ -993,7 +833,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl restart nginx`))
}
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
mock.expectCommand(`systemctl start myapp`, "", "", 0)
@ -1015,7 +855,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
assert.True(t, mock.hasExecuted(`systemctl start myapp`))
}
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
@ -1034,7 +874,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
assert.True(t, mock.hasExecuted(`apt-get install`))
}
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`curl.*gpg`, "", "", 0)
@ -1052,7 +892,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
assert.True(t, result.Changed)
}
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
mock.expectCommand(`apt-get update`, "", "", 0)
@ -1071,7 +911,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.
assert.True(t, result.Changed)
}
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
mock.expectCommand(`apt-get install -y -qq git`, "", "", 0)
@ -1090,7 +930,7 @@ func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
assert.True(t, result.Changed)
}
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
func TestExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install ansible`, "", "", 0)

964
parser.go

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ package ansible
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@ -10,9 +11,9 @@ import (
// --- ParsePlaybook ---
func TestParser_ParsePlaybook_Good_SimplePlay(t *testing.T) {
func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Configure webserver
@ -24,7 +25,7 @@ func TestParser_ParsePlaybook_Good_SimplePlay(t *testing.T) {
name: nginx
state: present
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -41,9 +42,9 @@ func TestParser_ParsePlaybook_Good_SimplePlay(t *testing.T) {
assert.Equal(t, "present", plays[0].Tasks[0].Args["state"])
}
func TestParser_ParsePlaybook_Good_MultiplePlays(t *testing.T) {
func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Play one
@ -61,7 +62,7 @@ func TestParser_ParsePlaybook_Good_MultiplePlays(t *testing.T) {
debug:
msg: "Goodbye"
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -75,156 +76,9 @@ func TestParser_ParsePlaybook_Good_MultiplePlays(t *testing.T) {
assert.Equal(t, "local", plays[1].Connection)
}
func TestParser_ParsePlaybook_Good_ImportPlaybook(t *testing.T) {
func TestParsePlaybook_Good_WithVars(t *testing.T) {
dir := t.TempDir()
mainPath := joinPath(dir, "site.yml")
importDir := joinPath(dir, "plays")
importPath := joinPath(importDir, "web.yml")
yamlMain := `---
- name: Before import
hosts: all
tasks:
- name: Say before
debug:
msg: "before"
- import_playbook: plays/web.yml
- name: After import
hosts: all
tasks:
- name: Say after
debug:
msg: "after"
`
yamlImported := `---
- name: Imported play
hosts: webservers
tasks:
- name: Say imported
debug:
msg: "imported"
`
require.NoError(t, os.MkdirAll(importDir, 0755))
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook("site.yml")
require.NoError(t, err)
require.Len(t, plays, 3)
assert.Equal(t, "Before import", plays[0].Name)
assert.Equal(t, "Imported play", plays[1].Name)
assert.Equal(t, "After import", plays[2].Name)
assert.Equal(t, "webservers", plays[1].Hosts)
assert.Len(t, plays[1].Tasks, 1)
assert.Equal(t, "Say imported", plays[1].Tasks[0].Name)
}
func TestParser_ParsePlaybook_Good_TemplatedImportPlaybook(t *testing.T) {
dir := t.TempDir()
mainPath := joinPath(dir, "site.yml")
importDir := joinPath(dir, "plays")
importPath := joinPath(importDir, "web.yml")
yamlMain := `---
- import_playbook: "{{ playbook_dir }}/plays/web.yml"
`
yamlImported := `---
- name: Imported play
hosts: all
tasks:
- name: Say imported
debug:
msg: "imported"
`
require.NoError(t, os.MkdirAll(importDir, 0755))
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook("site.yml")
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Equal(t, "Imported play", plays[0].Name)
assert.Equal(t, "all", plays[0].Hosts)
require.NotNil(t, plays[0].Vars)
assert.Equal(t, dir, plays[0].Vars["playbook_dir"])
}
func TestParser_ParsePlaybook_Good_FQCNImportPlaybook(t *testing.T) {
dir := t.TempDir()
mainPath := joinPath(dir, "site.yml")
importDir := joinPath(dir, "plays")
importPath := joinPath(importDir, "web.yml")
yamlMain := `---
- ansible.builtin.import_playbook: plays/web.yml
`
yamlImported := `---
- name: Imported play
hosts: all
tasks:
- name: Say imported
debug:
msg: "imported"
`
require.NoError(t, os.MkdirAll(importDir, 0755))
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(mainPath)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Equal(t, "Imported play", plays[0].Name)
assert.Equal(t, "all", plays[0].Hosts)
}
func TestParser_ParsePlaybook_Good_NestedImportPlaybookDirScope(t *testing.T) {
dir := t.TempDir()
mainPath := joinPath(dir, "site.yml")
outerDir := joinPath(dir, "plays")
outerPath := joinPath(outerDir, "outer.yml")
innerDir := joinPath(outerDir, "nested")
innerPath := joinPath(innerDir, "inner.yml")
yamlMain := `---
- import_playbook: plays/outer.yml
`
yamlOuter := `---
- import_playbook: "{{ playbook_dir }}/nested/inner.yml"
`
yamlInner := `---
- name: Inner play
hosts: all
tasks:
- name: Say inner
debug:
msg: "inner"
`
require.NoError(t, os.MkdirAll(innerDir, 0755))
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
require.NoError(t, writeTestFile(outerPath, []byte(yamlOuter), 0644))
require.NoError(t, writeTestFile(innerPath, []byte(yamlInner), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook("site.yml")
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Equal(t, "Inner play", plays[0].Name)
require.NotNil(t, plays[0].Vars)
assert.Equal(t, dir, plays[0].Vars["playbook_dir"])
}
func TestParser_ParsePlaybook_Good_WithVars(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With vars
@ -237,7 +91,7 @@ func TestParser_ParsePlaybook_Good_WithVars(t *testing.T) {
debug:
msg: "Port is {{ http_port }}"
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -248,9 +102,9 @@ func TestParser_ParsePlaybook_Good_WithVars(t *testing.T) {
assert.Equal(t, "myapp", plays[0].Vars["app_name"])
}
func TestParser_ParsePlaybook_Good_PrePostTasks(t *testing.T) {
func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Full lifecycle
@ -268,7 +122,7 @@ func TestParser_ParsePlaybook_Good_PrePostTasks(t *testing.T) {
debug:
msg: "post"
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -283,9 +137,9 @@ func TestParser_ParsePlaybook_Good_PrePostTasks(t *testing.T) {
assert.Equal(t, "Post task", plays[0].PostTasks[0].Name)
}
func TestParser_ParsePlaybook_Good_Handlers(t *testing.T) {
func TestParsePlaybook_Good_Handlers(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With handlers
@ -301,7 +155,7 @@ func TestParser_ParsePlaybook_Good_Handlers(t *testing.T) {
name: nginx
state: restarted
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -313,9 +167,9 @@ func TestParser_ParsePlaybook_Good_Handlers(t *testing.T) {
assert.Equal(t, "service", plays[0].Handlers[0].Module)
}
func TestParser_ParsePlaybook_Good_ShellFreeForm(t *testing.T) {
func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Shell tasks
@ -326,7 +180,7 @@ func TestParser_ParsePlaybook_Good_ShellFreeForm(t *testing.T) {
- name: Run raw command
command: ls -la /tmp
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -339,9 +193,9 @@ func TestParser_ParsePlaybook_Good_ShellFreeForm(t *testing.T) {
assert.Equal(t, "ls -la /tmp", plays[0].Tasks[1].Args["_raw_params"])
}
func TestParser_ParsePlaybook_Good_WithTags(t *testing.T) {
func TestParsePlaybook_Good_WithTags(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Tagged play
@ -356,7 +210,7 @@ func TestParser_ParsePlaybook_Good_WithTags(t *testing.T) {
- debug
- always
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -366,9 +220,9 @@ func TestParser_ParsePlaybook_Good_WithTags(t *testing.T) {
assert.Equal(t, []string{"debug", "always"}, plays[0].Tasks[0].Tags)
}
func TestParser_ParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With blocks
@ -387,7 +241,7 @@ func TestParser_ParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
debug:
msg: "always"
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -402,9 +256,9 @@ func TestParser_ParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
assert.Equal(t, "Always runs", task.Always[0].Name)
}
func TestParser_ParsePlaybook_Good_WithLoop(t *testing.T) {
func TestParsePlaybook_Good_WithLoop(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Loop test
@ -419,7 +273,7 @@ func TestParser_ParsePlaybook_Good_WithLoop(t *testing.T) {
- curl
- git
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -432,9 +286,9 @@ func TestParser_ParsePlaybook_Good_WithLoop(t *testing.T) {
assert.Len(t, items, 3)
}
func TestParser_ParsePlaybook_Good_RoleRefs(t *testing.T) {
func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With roles
@ -447,7 +301,7 @@ func TestParser_ParsePlaybook_Good_RoleRefs(t *testing.T) {
tags:
- web
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -460,9 +314,9 @@ func TestParser_ParsePlaybook_Good_RoleRefs(t *testing.T) {
assert.Equal(t, []string{"web"}, plays[0].Roles[1].Tags)
}
func TestParser_ParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: FQCN modules
@ -475,7 +329,7 @@ func TestParser_ParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
- name: Run shell
ansible.builtin.shell: echo hello
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -487,9 +341,9 @@ func TestParser_ParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
assert.Equal(t, "echo hello", plays[0].Tasks[1].Args["_raw_params"])
}
func TestParser_ParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Conditional play
@ -504,7 +358,7 @@ func TestParser_ParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
msg: "File exists"
when: nginx_conf.stat.exists
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -514,11 +368,11 @@ func TestParser_ParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
assert.NotNil(t, plays[0].Tasks[1].When)
}
func TestParser_ParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
func TestParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
require.NoError(t, writeTestFile(path, []byte("---\n[]"), 0644))
require.NoError(t, os.WriteFile(path, []byte("---\n[]"), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -527,11 +381,11 @@ func TestParser_ParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
assert.Empty(t, plays)
}
func TestParser_ParsePlaybook_Bad_InvalidYAML(t *testing.T) {
func TestParsePlaybook_Bad_InvalidYAML(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "bad.yml")
path := filepath.Join(dir, "bad.yml")
require.NoError(t, writeTestFile(path, []byte("{{invalid yaml}}"), 0644))
require.NoError(t, os.WriteFile(path, []byte("{{invalid yaml}}"), 0644))
p := NewParser(dir)
_, err := p.ParsePlaybook(path)
@ -540,7 +394,7 @@ func TestParser_ParsePlaybook_Bad_InvalidYAML(t *testing.T) {
assert.Contains(t, err.Error(), "parse playbook")
}
func TestParser_ParsePlaybook_Bad_FileNotFound(t *testing.T) {
func TestParsePlaybook_Bad_FileNotFound(t *testing.T) {
p := NewParser(t.TempDir())
_, err := p.ParsePlaybook("/nonexistent/playbook.yml")
@ -548,9 +402,9 @@ func TestParser_ParsePlaybook_Bad_FileNotFound(t *testing.T) {
assert.Contains(t, err.Error(), "read playbook")
}
func TestParser_ParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: No facts
@ -558,7 +412,7 @@ func TestParser_ParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
gather_facts: false
tasks: []
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -568,33 +422,11 @@ func TestParser_ParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
assert.False(t, *plays[0].GatherFacts)
}
func TestParser_ParsePlaybook_Good_ForceHandlers(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: Handler control
hosts: all
force_handlers: true
any_errors_fatal: true
tasks: []
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.True(t, plays[0].ForceHandlers)
assert.True(t, plays[0].AnyErrorsFatal)
}
// --- ParseInventory ---
func TestParser_ParseInventory_Good_SimpleInventory(t *testing.T) {
func TestParseInventory_Good_SimpleInventory(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "inventory.yml")
path := filepath.Join(dir, "inventory.yml")
yaml := `---
all:
@ -604,7 +436,7 @@ all:
web2:
ansible_host: 192.168.1.11
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
@ -616,32 +448,9 @@ all:
assert.Equal(t, "192.168.1.11", inv.All.Hosts["web2"].AnsibleHost)
}
func TestParser_ParseInventory_Good_DirectoryInventory(t *testing.T) {
func TestParseInventory_Good_WithGroups(t *testing.T) {
dir := t.TempDir()
inventoryDir := joinPath(dir, "inventory")
require.NoError(t, os.MkdirAll(inventoryDir, 0755))
path := joinPath(inventoryDir, "hosts.yml")
yaml := `---
all:
hosts:
web1:
ansible_host: 192.168.1.10
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(inventoryDir)
require.NoError(t, err)
require.NotNil(t, inv.All)
require.Contains(t, inv.All.Hosts, "web1")
assert.Equal(t, "192.168.1.10", inv.All.Hosts["web1"].AnsibleHost)
}
func TestParser_ParseInventory_Good_WithGroups(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "inventory.yml")
path := filepath.Join(dir, "inventory.yml")
yaml := `---
all:
@ -657,7 +466,7 @@ all:
db1:
ansible_host: 10.0.1.1
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
@ -669,44 +478,9 @@ all:
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
}
func TestParser_ParseInventory_Good_TopLevelGroups(t *testing.T) {
func TestParseInventory_Good_WithVars(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "inventory.yml")
yaml := `---
webservers:
vars:
tier: web
hosts:
web1:
ansible_host: 10.0.0.1
web2:
ansible_host: 10.0.0.2
databases:
hosts:
db1:
ansible_host: 10.0.1.1
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
require.NoError(t, err)
require.NotNil(t, inv.All)
require.NotNil(t, inv.All.Children["webservers"])
require.NotNil(t, inv.All.Children["databases"])
assert.Len(t, inv.All.Children["webservers"].Hosts, 2)
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
assert.Equal(t, "web", inv.All.Children["webservers"].Vars["tier"])
assert.ElementsMatch(t, []string{"web1", "web2", "db1"}, GetHosts(inv, "all"))
assert.Equal(t, []string{"web1", "web2"}, GetHosts(inv, "webservers"))
assert.Equal(t, "web", GetHostVars(inv, "web1")["tier"])
}
func TestParser_ParseInventory_Good_WithVars(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "inventory.yml")
path := filepath.Join(dir, "inventory.yml")
yaml := `---
all:
@ -721,7 +495,7 @@ all:
ansible_host: 10.0.0.1
ansible_port: 2222
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
@ -732,11 +506,11 @@ all:
assert.Equal(t, 2222, inv.All.Children["production"].Hosts["prod1"].AnsiblePort)
}
func TestParser_ParseInventory_Bad_InvalidYAML(t *testing.T) {
func TestParseInventory_Bad_InvalidYAML(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "bad.yml")
path := filepath.Join(dir, "bad.yml")
require.NoError(t, writeTestFile(path, []byte("{{{bad"), 0644))
require.NoError(t, os.WriteFile(path, []byte("{{{bad"), 0644))
p := NewParser(dir)
_, err := p.ParseInventory(path)
@ -745,7 +519,7 @@ func TestParser_ParseInventory_Bad_InvalidYAML(t *testing.T) {
assert.Contains(t, err.Error(), "parse inventory")
}
func TestParser_ParseInventory_Bad_FileNotFound(t *testing.T) {
func TestParseInventory_Bad_FileNotFound(t *testing.T) {
p := NewParser(t.TempDir())
_, err := p.ParseInventory("/nonexistent/inventory.yml")
@ -755,9 +529,9 @@ func TestParser_ParseInventory_Bad_FileNotFound(t *testing.T) {
// --- ParseTasks ---
func TestParser_ParseTasks_Good_TaskFile(t *testing.T) {
func TestParseTasks_Good_TaskFile(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "tasks.yml")
path := filepath.Join(dir, "tasks.yml")
yaml := `---
- name: First task
@ -767,7 +541,7 @@ func TestParser_ParseTasks_Good_TaskFile(t *testing.T) {
src: /tmp/a
dest: /tmp/b
`
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
tasks, err := p.ParseTasks(path)
@ -780,11 +554,11 @@ func TestParser_ParseTasks_Good_TaskFile(t *testing.T) {
assert.Equal(t, "/tmp/a", tasks[1].Args["src"])
}
func TestParser_ParseTasks_Bad_InvalidYAML(t *testing.T) {
func TestParseTasks_Bad_InvalidYAML(t *testing.T) {
dir := t.TempDir()
path := joinPath(dir, "bad.yml")
path := filepath.Join(dir, "bad.yml")
require.NoError(t, writeTestFile(path, []byte("not: [valid: tasks"), 0644))
require.NoError(t, os.WriteFile(path, []byte("not: [valid: tasks"), 0644))
p := NewParser(dir)
_, err := p.ParseTasks(path)
@ -792,40 +566,9 @@ func TestParser_ParseTasks_Bad_InvalidYAML(t *testing.T) {
assert.Error(t, err)
}
func TestParser_ParseRole_Good_LoadsRoleVarsIntoParserContext(t *testing.T) {
dir := t.TempDir()
require.NoError(t, writeTestFile(joinPath(dir, "roles", "web", "tasks", "main.yml"), []byte(`---
- name: Role task
debug:
msg: "{{ role_default }} {{ role_value }} {{ shared_value }}"
`), 0644))
require.NoError(t, writeTestFile(joinPath(dir, "roles", "web", "defaults", "main.yml"), []byte(`---
role_default: default-value
shared_value: default-shared
`), 0644))
require.NoError(t, writeTestFile(joinPath(dir, "roles", "web", "vars", "main.yml"), []byte(`---
role_value: vars-value
shared_value: role-shared
`), 0644))
p := NewParser(dir)
p.vars["existing_value"] = "keep-me"
tasks, err := p.ParseRole("web", "main.yml")
require.NoError(t, err)
require.Len(t, tasks, 1)
assert.Equal(t, "debug", tasks[0].Module)
assert.Equal(t, "keep-me", p.vars["existing_value"])
assert.Equal(t, "default-value", p.vars["role_default"])
assert.Equal(t, "vars-value", p.vars["role_value"])
assert.Equal(t, "role-shared", p.vars["shared_value"])
}
// --- GetHosts ---
func TestParser_GetHosts_Good_AllPattern(t *testing.T) {
func TestGetHosts_Good_AllPattern(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
@ -841,13 +584,13 @@ func TestParser_GetHosts_Good_AllPattern(t *testing.T) {
assert.Contains(t, hosts, "host2")
}
func TestParser_GetHosts_Good_LocalhostPattern(t *testing.T) {
func TestGetHosts_Good_LocalhostPattern(t *testing.T) {
inv := &Inventory{All: &InventoryGroup{}}
hosts := GetHosts(inv, "localhost")
assert.Equal(t, []string{"localhost"}, hosts)
}
func TestParser_GetHosts_Good_GroupPattern(t *testing.T) {
func TestGetHosts_Good_GroupPattern(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Children: map[string]*InventoryGroup{
@ -872,7 +615,7 @@ func TestParser_GetHosts_Good_GroupPattern(t *testing.T) {
assert.Contains(t, hosts, "web2")
}
func TestParser_GetHosts_Good_SpecificHost(t *testing.T) {
func TestGetHosts_Good_SpecificHost(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Children: map[string]*InventoryGroup{
@ -889,39 +632,7 @@ func TestParser_GetHosts_Good_SpecificHost(t *testing.T) {
assert.Equal(t, []string{"myhost"}, hosts)
}
func TestParser_GetHosts_Good_ColonUnionIntersectionExclusion(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Children: map[string]*InventoryGroup{
"web": {
Hosts: map[string]*Host{
"web1": {},
"web2": {},
},
},
"db": {
Hosts: map[string]*Host{
"db1": {},
"web2": {},
},
},
"canary": {
Hosts: map[string]*Host{
"web2": {},
"db1": {},
},
},
},
},
}
assert.Equal(t, []string{"web1", "web2", "db1"}, GetHosts(inv, "web:db"))
assert.Equal(t, []string{"web2"}, GetHosts(inv, "web:&db"))
assert.Equal(t, []string{"web1"}, GetHosts(inv, "web:!canary"))
assert.Equal(t, []string{"web1"}, GetHosts(inv, "web:db:!canary"))
}
func TestParser_GetHosts_Good_AllIncludesChildren(t *testing.T) {
func TestGetHosts_Good_AllIncludesChildren(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"top": {}},
@ -939,7 +650,7 @@ func TestParser_GetHosts_Good_AllIncludesChildren(t *testing.T) {
assert.Contains(t, hosts, "child1")
}
func TestParser_GetHosts_Bad_NoMatch(t *testing.T) {
func TestGetHosts_Bad_NoMatch(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"host1": {}},
@ -950,7 +661,7 @@ func TestParser_GetHosts_Bad_NoMatch(t *testing.T) {
assert.Empty(t, hosts)
}
func TestParser_GetHosts_Bad_NilGroup(t *testing.T) {
func TestGetHosts_Bad_NilGroup(t *testing.T) {
inv := &Inventory{All: nil}
hosts := GetHosts(inv, "all")
assert.Empty(t, hosts)
@ -958,16 +669,15 @@ func TestParser_GetHosts_Bad_NilGroup(t *testing.T) {
// --- GetHostVars ---
func TestParser_GetHostVars_Good_DirectHost(t *testing.T) {
func TestGetHostVars_Good_DirectHost(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Vars: map[string]any{"global_var": "global"},
Hosts: map[string]*Host{
"myhost": {
AnsibleHost: "10.0.0.1",
AnsiblePort: 2222,
AnsibleUser: "deploy",
AnsibleBecomePassword: "secret",
AnsibleHost: "10.0.0.1",
AnsiblePort: 2222,
AnsibleUser: "deploy",
},
},
},
@ -977,11 +687,10 @@ func TestParser_GetHostVars_Good_DirectHost(t *testing.T) {
assert.Equal(t, "10.0.0.1", vars["ansible_host"])
assert.Equal(t, 2222, vars["ansible_port"])
assert.Equal(t, "deploy", vars["ansible_user"])
assert.Equal(t, "secret", vars["ansible_become_password"])
assert.Equal(t, "global", vars["global_var"])
}
func TestParser_GetHostVars_Good_InheritedGroupVars(t *testing.T) {
func TestGetHostVars_Good_InheritedGroupVars(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Vars: map[string]any{"level": "all"},
@ -1003,7 +712,7 @@ func TestParser_GetHostVars_Good_InheritedGroupVars(t *testing.T) {
assert.Equal(t, "prod", vars["env"])
}
func TestParser_GetHostVars_Good_HostNotFound(t *testing.T) {
func TestGetHostVars_Good_HostNotFound(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"other": {}},
@ -1016,7 +725,7 @@ func TestParser_GetHostVars_Good_HostNotFound(t *testing.T) {
// --- isModule ---
func TestParser_IsModule_Good_KnownModules(t *testing.T) {
func TestIsModule_Good_KnownModules(t *testing.T) {
assert.True(t, isModule("shell"))
assert.True(t, isModule("command"))
assert.True(t, isModule("copy"))
@ -1024,71 +733,43 @@ func TestParser_IsModule_Good_KnownModules(t *testing.T) {
assert.True(t, isModule("apt"))
assert.True(t, isModule("service"))
assert.True(t, isModule("systemd"))
assert.True(t, isModule("rpm"))
assert.True(t, isModule("debug"))
assert.True(t, isModule("set_fact"))
assert.True(t, isModule("ping"))
}
func TestParser_IsModule_Good_FQCN(t *testing.T) {
func TestIsModule_Good_FQCN(t *testing.T) {
assert.True(t, isModule("ansible.builtin.shell"))
assert.True(t, isModule("ansible.builtin.copy"))
assert.True(t, isModule("ansible.builtin.apt"))
assert.True(t, isModule("ansible.builtin.rpm"))
}
func TestParser_IsModule_Good_DottedUnknown(t *testing.T) {
func TestIsModule_Good_DottedUnknown(t *testing.T) {
// Any key with dots is considered a module
assert.True(t, isModule("community.general.ufw"))
assert.True(t, isModule("ansible.posix.authorized_key"))
}
func TestParser_IsModule_Bad_NotAModule(t *testing.T) {
func TestIsModule_Bad_NotAModule(t *testing.T) {
assert.False(t, isModule("some_random_key"))
assert.False(t, isModule("foobar"))
}
// --- NormalizeModule ---
func TestParser_NormalizeModule_Good(t *testing.T) {
func TestNormalizeModule_Good(t *testing.T) {
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("shell"))
assert.Equal(t, "ansible.builtin.copy", NormalizeModule("copy"))
assert.Equal(t, "ansible.builtin.apt", NormalizeModule("apt"))
assert.Equal(t, "ansible.builtin.rpm", NormalizeModule("rpm"))
assert.Equal(t, "ansible.builtin.ping", NormalizeModule("ping"))
}
func TestParser_NormalizeModule_Good_CommunityAliases(t *testing.T) {
assert.Equal(t, "ansible.posix.authorized_key", NormalizeModule("authorized_key"))
assert.Equal(t, "ansible.posix.authorized_key", NormalizeModule("ansible.builtin.authorized_key"))
assert.Equal(t, "community.general.ufw", NormalizeModule("ufw"))
assert.Equal(t, "community.general.ufw", NormalizeModule("ansible.builtin.ufw"))
assert.Equal(t, "community.docker.docker_compose", NormalizeModule("docker_compose"))
assert.Equal(t, "community.docker.docker_compose_v2", NormalizeModule("docker_compose_v2"))
assert.Equal(t, "community.docker.docker_compose", NormalizeModule("ansible.builtin.docker_compose"))
assert.Equal(t, "community.docker.docker_compose_v2", NormalizeModule("ansible.builtin.docker_compose_v2"))
}
func TestParser_NormalizeModule_Good_AlreadyFQCN(t *testing.T) {
func TestNormalizeModule_Good_AlreadyFQCN(t *testing.T) {
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("ansible.builtin.shell"))
assert.Equal(t, "community.general.ufw", NormalizeModule("community.general.ufw"))
}
func TestParser_IsModule_Good_AdditionalFQCN(t *testing.T) {
assert.True(t, isModule("ansible.builtin.hostname"))
assert.True(t, isModule("ansible.builtin.sysctl"))
assert.True(t, isModule("ansible.builtin.reboot"))
}
func TestParser_NormalizeModule_Good_LegacyNamespace(t *testing.T) {
assert.Equal(t, "ansible.builtin.command", NormalizeModule("ansible.legacy.command"))
assert.Equal(t, "ansible.posix.authorized_key", NormalizeModule("ansible.legacy.authorized_key"))
assert.Equal(t, "community.general.ufw", NormalizeModule("ansible.legacy.ufw"))
}
// --- NewParser ---
func TestParser_NewParser_Good(t *testing.T) {
func TestNewParser_Good(t *testing.T) {
p := NewParser("/some/path")
assert.NotNil(t, p)
assert.Equal(t, "/some/path", p.basePath)

180
ssh.go
View file

@ -3,9 +3,12 @@ package ansible
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
@ -16,10 +19,6 @@ import (
)
// SSHClient handles SSH connections to remote hosts.
//
// Example:
//
// client, _ := NewSSHClient(SSHConfig{Host: "web1"})
type SSHClient struct {
host string
port int
@ -35,10 +34,6 @@ type SSHClient struct {
}
// SSHConfig holds SSH connection configuration.
//
// Example:
//
// config := SSHConfig{Host: "web1", User: "deploy", Port: 22}
type SSHConfig struct {
Host string
Port int
@ -52,41 +47,33 @@ type SSHConfig struct {
}
// NewSSHClient creates a new SSH client.
//
// Example:
//
// client, err := NewSSHClient(SSHConfig{Host: "web1", User: "deploy"})
func NewSSHClient(config SSHConfig) (*SSHClient, error) {
if config.Port == 0 {
config.Port = 22
func NewSSHClient(cfg SSHConfig) (*SSHClient, error) {
if cfg.Port == 0 {
cfg.Port = 22
}
if config.User == "" {
config.User = "root"
if cfg.User == "" {
cfg.User = "root"
}
if config.Timeout == 0 {
config.Timeout = 30 * time.Second
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
client := &SSHClient{
host: config.Host,
port: config.Port,
user: config.User,
password: config.Password,
keyFile: config.KeyFile,
become: config.Become,
becomeUser: config.BecomeUser,
becomePass: config.BecomePass,
timeout: config.Timeout,
host: cfg.Host,
port: cfg.Port,
user: cfg.User,
password: cfg.Password,
keyFile: cfg.KeyFile,
become: cfg.Become,
becomeUser: cfg.BecomeUser,
becomePass: cfg.BecomePass,
timeout: cfg.Timeout,
}
return client, nil
}
// Connect establishes the SSH connection.
//
// Example:
//
// _ = client.Connect(context.Background())
func (c *SSHClient) Connect(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
@ -100,8 +87,9 @@ func (c *SSHClient) Connect(ctx context.Context) error {
// Try key-based auth first
if c.keyFile != "" {
keyPath := c.keyFile
if corexHasPrefix(keyPath, "~") {
keyPath = joinPath(env("DIR_HOME"), keyPath[1:])
if strings.HasPrefix(keyPath, "~") {
home, _ := os.UserHomeDir()
keyPath = filepath.Join(home, keyPath[1:])
}
if key, err := coreio.Local.Read(keyPath); err == nil {
@ -113,10 +101,10 @@ func (c *SSHClient) Connect(ctx context.Context) error {
// Try default SSH keys
if len(authMethods) == 0 {
home := env("DIR_HOME")
home, _ := os.UserHomeDir()
defaultKeys := []string{
joinPath(home, ".ssh", "id_ed25519"),
joinPath(home, ".ssh", "id_rsa"),
filepath.Join(home, ".ssh", "id_ed25519"),
filepath.Join(home, ".ssh", "id_rsa"),
}
for _, keyPath := range defaultKeys {
if key, err := coreio.Local.Read(keyPath); err == nil {
@ -147,15 +135,15 @@ func (c *SSHClient) Connect(ctx context.Context) error {
// Host key verification
var hostKeyCallback ssh.HostKeyCallback
home := env("DIR_HOME")
if home == "" {
return coreerr.E("ssh.Connect", "failed to get user home dir", nil)
home, err := os.UserHomeDir()
if err != nil {
return coreerr.E("ssh.Connect", "failed to get user home dir", err)
}
knownHostsPath := joinPath(home, ".ssh", "known_hosts")
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
// Ensure known_hosts file exists
if !coreio.Local.Exists(knownHostsPath) {
if err := coreio.Local.EnsureDir(pathDir(knownHostsPath)); err != nil {
if err := coreio.Local.EnsureDir(filepath.Dir(knownHostsPath)); err != nil {
return coreerr.E("ssh.Connect", "failed to create .ssh dir", err)
}
if err := coreio.Local.Write(knownHostsPath, ""); err != nil {
@ -176,19 +164,19 @@ func (c *SSHClient) Connect(ctx context.Context) error {
Timeout: c.timeout,
}
addr := sprintf("%s:%d", c.host, c.port)
addr := fmt.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", sprintf("dial %s", addr), err)
return coreerr.E("ssh.Connect", fmt.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", sprintf("ssh connect %s", addr), err)
return coreerr.E("ssh.Connect", fmt.Sprintf("ssh connect %s", addr), err)
}
c.client = ssh.NewClient(sshConn, chans, reqs)
@ -196,10 +184,6 @@ func (c *SSHClient) Connect(ctx context.Context) error {
}
// Close closes the SSH connection.
//
// Example:
//
// _ = client.Close()
func (c *SSHClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
@ -212,22 +196,7 @@ func (c *SSHClient) Close() error {
return nil
}
// BecomeState returns the current privilege escalation settings.
//
// Example:
//
// become, user, password := client.BecomeState()
func (c *SSHClient) BecomeState() (bool, string, string) {
c.mu.Lock()
defer c.mu.Unlock()
return c.become, c.becomeUser, c.becomePass
}
// Run executes a command on the remote host.
//
// Example:
//
// stdout, stderr, rc, err := client.Run(context.Background(), "hostname")
func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
if err := c.Connect(ctx); err != nil {
return "", "", -1, err
@ -250,33 +219,33 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
becomeUser = "root"
}
// Escape single quotes in the command
escapedCmd := replaceAll(cmd, "'", "'\\''")
escapedCmd := strings.ReplaceAll(cmd, "'", "'\\''")
if c.becomePass != "" {
// Use sudo with password via stdin (-S flag)
// We launch a goroutine to write the password to stdin
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
cmd = fmt.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() }()
writeString(stdin, c.becomePass+"\n")
_, _ = io.WriteString(stdin, c.becomePass+"\n")
}()
} else if c.password != "" {
// Try using connection password for sudo
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
cmd = fmt.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() }()
writeString(stdin, c.password+"\n")
_, _ = io.WriteString(stdin, c.password+"\n")
}()
} else {
// Try passwordless sudo
cmd = sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
cmd = fmt.Sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
}
}
@ -304,44 +273,36 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
}
// RunScript runs a script on the remote host.
//
// Example:
//
// stdout, stderr, rc, err := client.RunScript(context.Background(), "echo hello")
func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
// Escape the script for heredoc
cmd := sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
cmd := fmt.Sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
return c.Run(ctx, cmd)
}
// Upload copies a file to the remote host.
//
// Example:
//
// err := client.Upload(context.Background(), newReader("hello"), "/tmp/hello.txt", 0644)
func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode fs.FileMode) error {
func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error {
if err := c.Connect(ctx); err != nil {
return err
}
// Read content
content, err := readAllString(local)
content, err := io.ReadAll(local)
if err != nil {
return coreerr.E("ssh.Upload", "read content", err)
}
// Create parent directory
dir := pathDir(remote)
dirCmd := sprintf("mkdir -p %q", dir)
dir := filepath.Dir(remote)
dirCmd := fmt.Sprintf("mkdir -p %q", dir)
if c.become {
dirCmd = sprintf("sudo mkdir -p %q", dir)
dirCmd = fmt.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 := sprintf("cat > %q && chmod %o %q", remote, mode, remote)
writeCmd := fmt.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.
@ -374,11 +335,11 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
if pass != "" {
// Use sudo -S with password from stdin
writeCmd = sprintf("sudo -S -u %s bash -c 'cat > %q && chmod %o %q'",
writeCmd = fmt.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 = sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
writeCmd = fmt.Sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
becomeUser, remote, mode, remote)
}
@ -389,9 +350,9 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
go func() {
defer func() { _ = stdin.Close() }()
if pass != "" {
writeString(stdin, pass+"\n")
_, _ = io.WriteString(stdin, pass+"\n")
}
_, _ = stdin.Write([]byte(content))
_, _ = stdin.Write(content)
}()
} else {
// Normal write
@ -401,47 +362,39 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
go func() {
defer func() { _ = stdin.Close() }()
_, _ = stdin.Write([]byte(content))
_, _ = stdin.Write(content)
}()
}
if err := session2.Wait(); err != nil {
return coreerr.E("ssh.Upload", sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
return coreerr.E("ssh.Upload", fmt.Sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
}
return nil
}
// Download copies a file from the remote host.
//
// Example:
//
// data, err := client.Download(context.Background(), "/etc/hostname")
func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error) {
if err := c.Connect(ctx); err != nil {
return nil, err
}
cmd := sprintf("cat %q", remote)
cmd := fmt.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", sprintf("cat failed: %s", stderr), nil)
return nil, coreerr.E("ssh.Download", fmt.Sprintf("cat failed: %s", stderr), nil)
}
return []byte(stdout), nil
}
// FileExists checks if a file exists on the remote host.
//
// Example:
//
// ok, err := client.FileExists(context.Background(), "/etc/hosts")
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
cmd := sprintf("test -e %q && echo yes || echo no", path)
cmd := fmt.Sprintf("test -e %q && echo yes || echo no", path)
stdout, _, exitCode, err := c.Run(ctx, cmd)
if err != nil {
return false, err
@ -450,17 +403,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 corexTrimSpace(stdout) == "yes", nil
return strings.TrimSpace(stdout) == "yes", nil
}
// Stat returns file info from the remote host.
//
// Example:
//
// info, err := client.Stat(context.Background(), "/etc/hosts")
func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error) {
// Simple approach - get basic file info
cmd := sprintf(`
cmd := fmt.Sprintf(`
if [ -e %q ]; then
if [ -d %q ]; then
echo "exists=true isdir=true"
@ -478,9 +427,9 @@ fi
}
result := make(map[string]any)
parts := fields(corexTrimSpace(stdout))
parts := strings.Fields(strings.TrimSpace(stdout))
for _, part := range parts {
kv := splitN(part, "=", 2)
kv := strings.SplitN(part, "=", 2)
if len(kv) == 2 {
result[kv[0]] = kv[1] == "true"
}
@ -490,19 +439,10 @@ fi
}
// SetBecome enables privilege escalation.
//
// Example:
//
// client.SetBecome(true, "root", "")
func (c *SSHClient) SetBecome(become bool, user, password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.become = become
if !become {
c.becomeUser = ""
c.becomePass = ""
return
}
if user != "" {
c.becomeUser = user
}

View file

@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestSSH_NewSSHClient_Good_CustomConfig(t *testing.T) {
func TestNewSSHClient(t *testing.T) {
cfg := SSHConfig{
Host: "localhost",
Port: 2222,
@ -23,7 +23,7 @@ func TestSSH_NewSSHClient_Good_CustomConfig(t *testing.T) {
assert.Equal(t, 30*time.Second, client.timeout)
}
func TestSSH_NewSSHClient_Good_Defaults(t *testing.T) {
func TestSSHConfig_Defaults(t *testing.T) {
cfg := SSHConfig{
Host: "localhost",
}
@ -34,19 +34,3 @@ func TestSSH_NewSSHClient_Good_Defaults(t *testing.T) {
assert.Equal(t, "root", client.user)
assert.Equal(t, 30*time.Second, client.timeout)
}
func TestSSH_SetBecome_Good_DisablesAndClearsState(t *testing.T) {
client := &SSHClient{}
client.SetBecome(true, "admin", "secret")
become, user, password := client.BecomeState()
assert.True(t, become)
assert.Equal(t, "admin", user)
assert.Equal(t, "secret", password)
client.SetBecome(false, "", "")
become, user, password = client.BecomeState()
assert.False(t, become)
assert.Empty(t, user)
assert.Empty(t, password)
}

View file

@ -1,23 +0,0 @@
package ansible
import (
"io/fs"
coreio "dappco.re/go/core/io"
)
func readTestFile(path string) ([]byte, error) {
content, err := coreio.Local.Read(path)
if err != nil {
return nil, err
}
return []byte(content), nil
}
func writeTestFile(path string, content []byte, mode fs.FileMode) error {
return coreio.Local.WriteMode(path, string(content), mode)
}
func joinStrings(parts []string, sep string) string {
return join(sep, parts)
}

403
types.go
View file

@ -2,95 +2,44 @@ package ansible
import (
"time"
coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// Playbook represents an Ansible playbook.
//
// Example:
//
// playbook := Playbook{Plays: []Play{{Name: "Bootstrap", Hosts: "all"}}}
type Playbook struct {
Plays []Play `yaml:",inline"`
}
// Play represents a single play in a playbook.
//
// Example:
//
// play := Play{Name: "Configure web", Hosts: "webservers", Become: true}
type Play struct {
Name string `yaml:"name"`
Hosts string `yaml:"hosts"`
ImportPlaybook string `yaml:"import_playbook,omitempty"`
Connection string `yaml:"connection,omitempty"`
Become bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
GatherFacts *bool `yaml:"gather_facts,omitempty"`
ForceHandlers bool `yaml:"force_handlers,omitempty"`
AnyErrorsFatal bool `yaml:"any_errors_fatal,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
VarsFiles any `yaml:"vars_files,omitempty"` // string or []string
ModuleDefaults map[string]map[string]any `yaml:"module_defaults,omitempty"`
PreTasks []Task `yaml:"pre_tasks,omitempty"`
Tasks []Task `yaml:"tasks,omitempty"`
PostTasks []Task `yaml:"post_tasks,omitempty"`
Roles []RoleRef `yaml:"roles,omitempty"`
Handlers []Task `yaml:"handlers,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
Serial any `yaml:"serial,omitempty"` // int or string
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
}
// UnmarshalYAML handles play-level aliases such as ansible.builtin.import_playbook.
func (p *Play) UnmarshalYAML(node *yaml.Node) error {
type rawPlay Play
var raw rawPlay
if err := node.Decode(&raw); err != nil {
return err
}
*p = Play(raw)
var fields map[string]any
if err := node.Decode(&fields); err != nil {
return err
}
if value, ok := directiveValue(fields, "import_playbook"); ok && p.ImportPlaybook == "" {
p.ImportPlaybook = sprintf("%v", value)
}
return nil
Name string `yaml:"name"`
Hosts string `yaml:"hosts"`
Connection string `yaml:"connection,omitempty"`
Become bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
GatherFacts *bool `yaml:"gather_facts,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
PreTasks []Task `yaml:"pre_tasks,omitempty"`
Tasks []Task `yaml:"tasks,omitempty"`
PostTasks []Task `yaml:"post_tasks,omitempty"`
Roles []RoleRef `yaml:"roles,omitempty"`
Handlers []Task `yaml:"handlers,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
Serial any `yaml:"serial,omitempty"` // int or string
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
}
// RoleRef represents a role reference in a play.
//
// Example:
//
// role := RoleRef{Role: "nginx", TasksFrom: "install.yml"}
type RoleRef struct {
Role string `yaml:"role,omitempty"`
Name string `yaml:"name,omitempty"` // Alternative to role
TasksFrom string `yaml:"tasks_from,omitempty"`
DefaultsFrom string `yaml:"defaults_from,omitempty"`
VarsFrom string `yaml:"vars_from,omitempty"`
HandlersFrom string `yaml:"handlers_from,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
Apply *TaskApply `yaml:"apply,omitempty"`
Public bool `yaml:"public,omitempty"`
When any `yaml:"when,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Role string `yaml:"role,omitempty"`
Name string `yaml:"name,omitempty"` // Alternative to role
TasksFrom string `yaml:"tasks_from,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
When any `yaml:"when,omitempty"`
Tags []string `yaml:"tags,omitempty"`
}
// UnmarshalYAML handles both string and struct role refs.
//
// Example:
//
// var ref RoleRef
// _ = yaml.Unmarshal([]byte("common"), &ref)
func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
// Try string first
var s string
@ -112,77 +61,53 @@ func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
return nil
}
func directiveValue(fields map[string]any, name string) (any, bool) {
if fields == nil {
return nil, false
}
for _, key := range []string{name, "ansible.builtin." + name, "ansible.legacy." + name} {
if value, ok := fields[key]; ok {
return value, true
}
}
return nil, false
}
// Task represents an Ansible task.
//
// Example:
//
// task := Task{Name: "Install nginx", Module: "apt", Args: map[string]any{"name": "nginx"}}
type Task struct {
Name string `yaml:"name,omitempty"`
Module string `yaml:"-"` // Derived from the module key
Args map[string]any `yaml:"-"` // Module arguments
Register string `yaml:"register,omitempty"`
When any `yaml:"when,omitempty"` // string or []string
CheckMode *bool `yaml:"check_mode,omitempty"`
Diff *bool `yaml:"diff,omitempty"`
Loop any `yaml:"loop,omitempty"` // string or []any
LoopControl *LoopControl `yaml:"loop_control,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
ChangedWhen any `yaml:"changed_when,omitempty"`
FailedWhen any `yaml:"failed_when,omitempty"`
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
NoLog bool `yaml:"no_log,omitempty"`
Become *bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
Delegate string `yaml:"delegate_to,omitempty"`
DelegateFacts bool `yaml:"delegate_facts,omitempty"`
RunOnce bool `yaml:"run_once,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Block []Task `yaml:"block,omitempty"`
Rescue []Task `yaml:"rescue,omitempty"`
Always []Task `yaml:"always,omitempty"`
Notify any `yaml:"notify,omitempty"` // string or []string
Listen any `yaml:"listen,omitempty"` // string or []string
Retries int `yaml:"retries,omitempty"`
Delay int `yaml:"delay,omitempty"`
Until string `yaml:"until,omitempty"`
Name string `yaml:"name,omitempty"`
Module string `yaml:"-"` // Derived from the module key
Args map[string]any `yaml:"-"` // Module arguments
Register string `yaml:"register,omitempty"`
When any `yaml:"when,omitempty"` // string or []string
Loop any `yaml:"loop,omitempty"` // string or []any
LoopControl *LoopControl `yaml:"loop_control,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
ChangedWhen any `yaml:"changed_when,omitempty"`
FailedWhen any `yaml:"failed_when,omitempty"`
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
NoLog bool `yaml:"no_log,omitempty"`
Become *bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
Delegate string `yaml:"delegate_to,omitempty"`
RunOnce bool `yaml:"run_once,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Block []Task `yaml:"block,omitempty"`
Rescue []Task `yaml:"rescue,omitempty"`
Always []Task `yaml:"always,omitempty"`
Notify any `yaml:"notify,omitempty"` // string or []string
Retries int `yaml:"retries,omitempty"`
Delay int `yaml:"delay,omitempty"`
Until string `yaml:"until,omitempty"`
// Include/import directives
IncludeTasks string `yaml:"include_tasks,omitempty"`
ImportTasks string `yaml:"import_tasks,omitempty"`
Apply *TaskApply `yaml:"apply,omitempty"`
WithFile any `yaml:"with_file,omitempty"`
WithFileGlob any `yaml:"with_fileglob,omitempty"`
WithSequence any `yaml:"with_sequence,omitempty"`
WithTogether any `yaml:"with_together,omitempty"`
WithSubelements any `yaml:"with_subelements,omitempty"`
IncludeRole *RoleRef `yaml:"include_role,omitempty"`
ImportRole *RoleRef `yaml:"import_role,omitempty"`
IncludeTasks string `yaml:"include_tasks,omitempty"`
ImportTasks string `yaml:"import_tasks,omitempty"`
IncludeRole *struct {
Name string `yaml:"name"`
TasksFrom string `yaml:"tasks_from,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
} `yaml:"include_role,omitempty"`
ImportRole *struct {
Name string `yaml:"name"`
TasksFrom string `yaml:"tasks_from,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
} `yaml:"import_role,omitempty"`
// Raw YAML for module extraction
raw map[string]any
}
// LoopControl controls loop behaviour.
//
// Example:
//
// loop := LoopControl{LoopVar: "item", IndexVar: "idx"}
// LoopControl controls loop behavior.
type LoopControl struct {
LoopVar string `yaml:"loop_var,omitempty"`
IndexVar string `yaml:"index_var,omitempty"`
@ -191,30 +116,7 @@ type LoopControl struct {
Extended bool `yaml:"extended,omitempty"`
}
// TaskApply captures role-level task defaults from include_role/import_role.
//
// Example:
//
// apply := TaskApply{Tags: []string{"deploy"}}
type TaskApply struct {
Tags []string `yaml:"tags,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
When any `yaml:"when,omitempty"`
Become *bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
Delegate string `yaml:"delegate_to,omitempty"`
DelegateFacts bool `yaml:"delegate_facts,omitempty"`
RunOnce bool `yaml:"run_once,omitempty"`
NoLog bool `yaml:"no_log,omitempty"`
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
}
// TaskResult holds the result of executing a task.
//
// Example:
//
// result := TaskResult{Changed: true, Stdout: "ok"}
type TaskResult struct {
Changed bool `json:"changed"`
Failed bool `json:"failed"`
@ -229,116 +131,11 @@ type TaskResult struct {
}
// Inventory represents Ansible inventory.
//
// Example:
//
// inventory := Inventory{All: &InventoryGroup{Hosts: map[string]*Host{"web1": {AnsibleHost: "10.0.0.1"}}}}
type Inventory struct {
All *InventoryGroup `yaml:"all"`
}
// UnmarshalYAML supports both the explicit `all:` root and inventories that
// declare top-level groups directly.
func (i *Inventory) UnmarshalYAML(unmarshal func(any) error) error {
var raw map[string]any
if err := unmarshal(&raw); err != nil {
return err
}
root := &InventoryGroup{}
rootInput := make(map[string]any)
if all, ok := raw["all"]; ok {
group, err := decodeInventoryGroupValue(all)
if err != nil {
return coreerr.E("Inventory.UnmarshalYAML", "decode all group", err)
}
root = group
}
for name, value := range raw {
if name == "all" {
continue
}
switch name {
case "hosts", "children", "vars":
rootInput[name] = value
continue
}
group, err := decodeInventoryGroupValue(value)
if err != nil {
return coreerr.E("Inventory.UnmarshalYAML", "decode group "+name, err)
}
if root.Children == nil {
root.Children = make(map[string]*InventoryGroup)
}
root.Children[name] = group
}
if len(rootInput) > 0 {
extra, err := decodeInventoryGroupValue(rootInput)
if err != nil {
return coreerr.E("Inventory.UnmarshalYAML", "decode root group", err)
}
mergeInventoryGroups(root, extra)
}
i.All = root
return nil
}
func decodeInventoryGroupValue(value any) (*InventoryGroup, error) {
if value == nil {
return &InventoryGroup{}, nil
}
data, err := yaml.Marshal(value)
if err != nil {
return nil, err
}
var group InventoryGroup
if err := yaml.Unmarshal(data, &group); err != nil {
return nil, err
}
return &group, nil
}
func mergeInventoryGroups(dst, src *InventoryGroup) {
if dst == nil || src == nil {
return
}
if dst.Hosts == nil && len(src.Hosts) > 0 {
dst.Hosts = make(map[string]*Host, len(src.Hosts))
}
for name, host := range src.Hosts {
dst.Hosts[name] = host
}
if dst.Children == nil && len(src.Children) > 0 {
dst.Children = make(map[string]*InventoryGroup, len(src.Children))
}
for name, child := range src.Children {
dst.Children[name] = child
}
if dst.Vars == nil && len(src.Vars) > 0 {
dst.Vars = make(map[string]any, len(src.Vars))
}
for key, value := range src.Vars {
dst.Vars[key] = value
}
}
// InventoryGroup represents a group in inventory.
//
// Example:
//
// group := InventoryGroup{Hosts: map[string]*Host{"db1": {AnsibleHost: "10.0.1.10"}}}
type InventoryGroup struct {
Hosts map[string]*Host `yaml:"hosts,omitempty"`
Children map[string]*InventoryGroup `yaml:"children,omitempty"`
@ -346,10 +143,6 @@ type InventoryGroup struct {
}
// Host represents a host in inventory.
//
// Example:
//
// host := Host{AnsibleHost: "192.168.1.10", AnsibleUser: "deploy"}
type Host struct {
AnsibleHost string `yaml:"ansible_host,omitempty"`
AnsiblePort int `yaml:"ansible_port,omitempty"`
@ -364,32 +157,20 @@ type Host struct {
}
// Facts holds gathered facts about a host.
//
// Example:
//
// facts := Facts{Hostname: "web1", Distribution: "Ubuntu", Kernel: "Linux"}
type Facts struct {
Hostname string `json:"ansible_hostname"`
FQDN string `json:"ansible_fqdn"`
OS string `json:"ansible_os_family"`
Distribution string `json:"ansible_distribution"`
Version string `json:"ansible_distribution_version"`
Architecture string `json:"ansible_architecture"`
Kernel string `json:"ansible_kernel"`
VirtualizationRole string `json:"ansible_virtualization_role"`
VirtualizationType string `json:"ansible_virtualization_type"`
Memory int64 `json:"ansible_memtotal_mb"`
CPUs int `json:"ansible_processor_vcpus"`
IPv4 string `json:"ansible_default_ipv4_address"`
Hostname string `json:"ansible_hostname"`
FQDN string `json:"ansible_fqdn"`
OS string `json:"ansible_os_family"`
Distribution string `json:"ansible_distribution"`
Version string `json:"ansible_distribution_version"`
Architecture string `json:"ansible_architecture"`
Kernel string `json:"ansible_kernel"`
Memory int64 `json:"ansible_memtotal_mb"`
CPUs int `json:"ansible_processor_vcpus"`
IPv4 string `json:"ansible_default_ipv4_address"`
}
// KnownModules lists the Ansible module names recognized by the parser.
//
// Example:
//
// if slices.Contains(KnownModules, "ansible.builtin.command") {
// // parser accepts command tasks
// }
// Known Ansible modules
var KnownModules = []string{
// Builtin
"ansible.builtin.shell",
@ -400,7 +181,6 @@ var KnownModules = []string{
"ansible.builtin.template",
"ansible.builtin.file",
"ansible.builtin.lineinfile",
"ansible.builtin.replace",
"ansible.builtin.blockinfile",
"ansible.builtin.stat",
"ansible.builtin.slurp",
@ -412,7 +192,6 @@ var KnownModules = []string{
"ansible.builtin.apt_repository",
"ansible.builtin.yum",
"ansible.builtin.dnf",
"ansible.builtin.rpm",
"ansible.builtin.package",
"ansible.builtin.pip",
"ansible.builtin.service",
@ -426,25 +205,14 @@ var KnownModules = []string{
"ansible.builtin.debug",
"ansible.builtin.fail",
"ansible.builtin.assert",
"ansible.builtin.ping",
"ansible.builtin.pause",
"ansible.builtin.wait_for",
"ansible.builtin.wait_for_connection",
"ansible.builtin.set_fact",
"ansible.builtin.include_vars",
"ansible.builtin.add_host",
"ansible.builtin.group_by",
"ansible.builtin.meta",
"ansible.builtin.setup",
"community.general.ufw",
"ansible.posix.authorized_key",
"ansible.builtin.docker_compose",
"ansible.builtin.docker_compose_v2",
"ansible.builtin.hostname",
"ansible.builtin.sysctl",
"ansible.builtin.reboot",
"community.docker.docker_compose",
"community.docker.docker_compose_v2",
// Short forms (legacy)
"shell",
@ -455,7 +223,6 @@ var KnownModules = []string{
"template",
"file",
"lineinfile",
"replace",
"blockinfile",
"stat",
"slurp",
@ -467,7 +234,6 @@ var KnownModules = []string{
"apt_repository",
"yum",
"dnf",
"rpm",
"package",
"pip",
"service",
@ -481,39 +247,12 @@ var KnownModules = []string{
"debug",
"fail",
"assert",
"ping",
"pause",
"wait_for",
"wait_for_connection",
"set_fact",
"include_vars",
"add_host",
"group_by",
"meta",
"setup",
"hostname",
"sysctl",
"reboot",
"authorized_key",
"ufw",
"docker_compose",
"docker_compose_v2",
}
// ModuleAliases maps accepted short-form module names to their canonical
// fully-qualified collection names.
//
// Example:
//
// module := ModuleAliases["ansible.builtin.authorized_key"]
var ModuleAliases = map[string]string{
"authorized_key": "ansible.posix.authorized_key",
"ansible.builtin.authorized_key": "ansible.posix.authorized_key",
"ufw": "community.general.ufw",
"ansible.builtin.ufw": "community.general.ufw",
"docker_compose": "community.docker.docker_compose",
"docker_compose_v2": "community.docker.docker_compose_v2",
"ansible.builtin.docker_compose": "community.docker.docker_compose",
"ansible.builtin.docker_compose_v2": "community.docker.docker_compose_v2",
"rpm": "ansible.builtin.rpm",
}

View file

@ -10,7 +10,7 @@ import (
// --- RoleRef UnmarshalYAML ---
func TestTypes_RoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
func TestRoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
input := `common`
var ref RoleRef
err := yaml.Unmarshal([]byte(input), &ref)
@ -19,7 +19,7 @@ func TestTypes_RoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
assert.Equal(t, "common", ref.Role)
}
func TestTypes_RoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
func TestRoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
input := `
role: webserver
vars:
@ -36,7 +36,7 @@ tags:
assert.Equal(t, []string{"web"}, ref.Tags)
}
func TestTypes_RoleRef_UnmarshalYAML_Good_NameField(t *testing.T) {
func TestRoleRef_UnmarshalYAML_Good_NameField(t *testing.T) {
// Some playbooks use "name:" instead of "role:"
input := `
name: myapp
@ -50,7 +50,7 @@ tasks_from: install.yml
assert.Equal(t, "install.yml", ref.TasksFrom)
}
func TestTypes_RoleRef_UnmarshalYAML_Good_WithWhen(t *testing.T) {
func TestRoleRef_UnmarshalYAML_Good_WithWhen(t *testing.T) {
input := `
role: conditional_role
when: ansible_os_family == "Debian"
@ -63,28 +63,9 @@ when: ansible_os_family == "Debian"
assert.NotNil(t, ref.When)
}
func TestTypes_RoleRef_UnmarshalYAML_Good_CustomRoleFiles(t *testing.T) {
input := `
name: web
tasks_from: setup.yml
defaults_from: custom-defaults.yml
vars_from: custom-vars.yml
public: true
`
var ref RoleRef
err := yaml.Unmarshal([]byte(input), &ref)
require.NoError(t, err)
assert.Equal(t, "web", ref.Role)
assert.Equal(t, "setup.yml", ref.TasksFrom)
assert.Equal(t, "custom-defaults.yml", ref.DefaultsFrom)
assert.Equal(t, "custom-vars.yml", ref.VarsFrom)
assert.True(t, ref.Public)
}
// --- Task UnmarshalYAML ---
func TestTypes_Task_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
func TestTask_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
input := `
name: Install nginx
apt:
@ -101,7 +82,7 @@ apt:
assert.Equal(t, "present", task.Args["state"])
}
func TestTypes_Task_UnmarshalYAML_Good_FreeFormModule(t *testing.T) {
func TestTask_UnmarshalYAML_Good_FreeFormModule(t *testing.T) {
input := `
name: Run command
shell: echo hello world
@ -114,7 +95,7 @@ shell: echo hello world
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
}
func TestTypes_Task_UnmarshalYAML_Good_ModuleNoArgs(t *testing.T) {
func TestTask_UnmarshalYAML_Good_ModuleNoArgs(t *testing.T) {
input := `
name: Gather facts
setup:
@ -127,7 +108,7 @@ setup:
assert.NotNil(t, task.Args)
}
func TestTypes_Task_UnmarshalYAML_Good_WithRegister(t *testing.T) {
func TestTask_UnmarshalYAML_Good_WithRegister(t *testing.T) {
input := `
name: Check file
stat:
@ -142,7 +123,7 @@ register: stat_result
assert.Equal(t, "stat", task.Module)
}
func TestTypes_Task_UnmarshalYAML_Good_WithWhen(t *testing.T) {
func TestTask_UnmarshalYAML_Good_WithWhen(t *testing.T) {
input := `
name: Conditional task
debug:
@ -156,24 +137,7 @@ when: some_var is defined
assert.NotNil(t, task.When)
}
func TestTypes_Task_UnmarshalYAML_Good_WithCheckModeAndDiff(t *testing.T) {
input := `
name: Force a dry run
shell: echo hello
check_mode: false
diff: true
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.CheckMode)
require.NotNil(t, task.Diff)
assert.False(t, *task.CheckMode)
assert.True(t, *task.Diff)
}
func TestTypes_Task_UnmarshalYAML_Good_WithLoop(t *testing.T) {
func TestTask_UnmarshalYAML_Good_WithLoop(t *testing.T) {
input := `
name: Install packages
apt:
@ -192,7 +156,7 @@ loop:
assert.Len(t, items, 3)
}
func TestTypes_Task_UnmarshalYAML_Good_WithItems(t *testing.T) {
func TestTask_UnmarshalYAML_Good_WithItems(t *testing.T) {
// with_items should be converted to loop
input := `
name: Old-style loop
@ -212,324 +176,7 @@ with_items:
assert.Len(t, items, 2)
}
func TestTypes_Task_UnmarshalYAML_Good_WithDict(t *testing.T) {
input := `
name: Old-style dict loop
debug:
msg: "{{ item.key }}={{ item.value }}"
with_dict:
alpha: one
beta: two
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
items, ok := task.Loop.([]any)
require.True(t, ok)
require.Len(t, items, 2)
first, ok := items[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, "alpha", first["key"])
assert.Equal(t, "one", first["value"])
second, ok := items[1].(map[string]any)
require.True(t, ok)
assert.Equal(t, "beta", second["key"])
assert.Equal(t, "two", second["value"])
}
func TestTypes_Task_UnmarshalYAML_Good_WithIndexedItems(t *testing.T) {
input := `
name: Indexed loop
debug:
msg: "{{ item.0 }}={{ item.1 }}"
with_indexed_items:
- apple
- banana
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
items, ok := task.Loop.([]any)
require.True(t, ok)
require.Len(t, items, 2)
first, ok := items[0].([]any)
require.True(t, ok)
assert.Equal(t, 0, first[0])
assert.Equal(t, "apple", first[1])
second, ok := items[1].([]any)
require.True(t, ok)
assert.Equal(t, 1, second[0])
assert.Equal(t, "banana", second[1])
}
func TestTypes_Task_UnmarshalYAML_Good_WithFile(t *testing.T) {
input := `
name: Read files
debug:
msg: "{{ item }}"
with_file:
- templates/a.txt
- templates/b.txt
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.WithFile)
files, ok := task.WithFile.([]any)
require.True(t, ok)
assert.Equal(t, []any{"templates/a.txt", "templates/b.txt"}, files)
}
func TestTypes_Task_UnmarshalYAML_Good_WithFileGlob(t *testing.T) {
input := `
name: Read globbed files
debug:
msg: "{{ item }}"
with_fileglob:
- templates/*.txt
- files/*.yml
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.WithFileGlob)
files, ok := task.WithFileGlob.([]any)
require.True(t, ok)
assert.Equal(t, []any{"templates/*.txt", "files/*.yml"}, files)
}
func TestTypes_Task_UnmarshalYAML_Good_WithSequence(t *testing.T) {
input := `
name: Read sequence values
debug:
msg: "{{ item }}"
with_sequence: "start=1 end=3 format=%02d"
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.WithSequence)
sequence, ok := task.WithSequence.(string)
require.True(t, ok)
assert.Equal(t, "start=1 end=3 format=%02d", sequence)
}
func TestTypes_Task_UnmarshalYAML_Good_ActionAlias(t *testing.T) {
input := `
name: Legacy action
action: command echo hello world
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "command", task.Module)
require.NotNil(t, task.Args)
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
}
func TestTypes_Task_UnmarshalYAML_Good_ActionAliasFQCN(t *testing.T) {
input := `
name: Legacy action
ansible.builtin.action: command echo hello world
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "command", task.Module)
require.NotNil(t, task.Args)
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
}
func TestTypes_Task_UnmarshalYAML_Good_ActionAliasKeyValue(t *testing.T) {
input := `
name: Legacy action with args
action: module=copy src=/tmp/source dest=/tmp/dest mode=0644
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "copy", task.Module)
require.NotNil(t, task.Args)
assert.Equal(t, "/tmp/source", task.Args["src"])
assert.Equal(t, "/tmp/dest", task.Args["dest"])
assert.Equal(t, "0644", task.Args["mode"])
}
func TestTypes_Task_UnmarshalYAML_Good_ActionAliasMixedArgs(t *testing.T) {
input := `
name: Legacy action with mixed args
action: command chdir=/tmp echo hello world
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "command", task.Module)
require.NotNil(t, task.Args)
assert.Equal(t, "/tmp", task.Args["chdir"])
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
}
func TestTypes_Task_UnmarshalYAML_Good_LocalAction(t *testing.T) {
input := `
name: Legacy local action
local_action: shell echo local
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "shell", task.Module)
assert.Equal(t, "localhost", task.Delegate)
require.NotNil(t, task.Args)
assert.Equal(t, "echo local", task.Args["_raw_params"])
}
func TestTypes_Task_UnmarshalYAML_Good_LocalActionFQCN(t *testing.T) {
input := `
name: Legacy local action
ansible.legacy.local_action: shell echo local
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "shell", task.Module)
assert.Equal(t, "localhost", task.Delegate)
require.NotNil(t, task.Args)
assert.Equal(t, "echo local", task.Args["_raw_params"])
}
func TestTypes_Task_UnmarshalYAML_Good_LocalActionKeyValue(t *testing.T) {
input := `
name: Legacy local action with args
local_action: module=command chdir=/tmp
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "command", task.Module)
assert.Equal(t, "localhost", task.Delegate)
require.NotNil(t, task.Args)
assert.Equal(t, "/tmp", task.Args["chdir"])
}
func TestTypes_Task_UnmarshalYAML_Good_LocalActionMixedArgs(t *testing.T) {
input := `
name: Legacy local action with mixed args
local_action: command chdir=/var/tmp echo local
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "command", task.Module)
assert.Equal(t, "localhost", task.Delegate)
require.NotNil(t, task.Args)
assert.Equal(t, "/var/tmp", task.Args["chdir"])
assert.Equal(t, "echo local", task.Args["_raw_params"])
}
func TestTypes_Task_UnmarshalYAML_Good_WithNested(t *testing.T) {
input := `
name: Nested loop values
debug:
msg: "{{ item.0 }} {{ item.1 }}"
with_nested:
- - red
- blue
- - small
- large
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
items, ok := task.Loop.([]any)
require.True(t, ok)
require.Len(t, items, 4)
first, ok := items[0].([]any)
require.True(t, ok)
assert.Equal(t, []any{"red", "small"}, first)
second, ok := items[1].([]any)
require.True(t, ok)
assert.Equal(t, []any{"red", "large"}, second)
third, ok := items[2].([]any)
require.True(t, ok)
assert.Equal(t, []any{"blue", "small"}, third)
fourth, ok := items[3].([]any)
require.True(t, ok)
assert.Equal(t, []any{"blue", "large"}, fourth)
}
func TestTypes_Task_UnmarshalYAML_Good_WithTogether(t *testing.T) {
input := `
name: Together loop values
debug:
msg: "{{ item.0 }} {{ item.1 }}"
with_together:
- - red
- blue
- - small
- large
- medium
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.WithTogether)
items, ok := task.Loop.([]any)
require.True(t, ok)
require.Len(t, items, 2)
first, ok := items[0].([]any)
require.True(t, ok)
assert.Equal(t, []any{"red", "small"}, first)
second, ok := items[1].([]any)
require.True(t, ok)
assert.Equal(t, []any{"blue", "large"}, second)
}
func TestTypes_Task_UnmarshalYAML_Good_WithSubelements(t *testing.T) {
input := `
name: Subelement loop values
debug:
msg: "{{ item.0.name }} {{ item.1 }}"
with_subelements:
- users
- authorized
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.WithSubelements)
values, ok := task.WithSubelements.([]any)
require.True(t, ok)
assert.Equal(t, []any{"users", "authorized"}, values)
}
func TestTypes_Task_UnmarshalYAML_Good_WithNotify(t *testing.T) {
func TestTask_UnmarshalYAML_Good_WithNotify(t *testing.T) {
input := `
name: Install package
apt:
@ -543,116 +190,7 @@ notify: restart nginx
assert.Equal(t, "restart nginx", task.Notify)
}
func TestTypes_Task_UnmarshalYAML_Good_WithListen(t *testing.T) {
input := `
name: Restart service
debug:
msg: "handler"
listen: reload nginx
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "reload nginx", task.Listen)
}
func TestTypes_Task_UnmarshalYAML_Good_ShortFormSystemModules(t *testing.T) {
cases := []struct {
name string
input string
wantModule string
}{
{
name: "hostname",
input: `
name: Set hostname
hostname:
name: web01
`,
wantModule: "hostname",
},
{
name: "sysctl",
input: `
name: Tune kernel
sysctl:
name: net.ipv4.ip_forward
value: "1"
`,
wantModule: "sysctl",
},
{
name: "reboot",
input: `
name: Reboot host
reboot:
`,
wantModule: "reboot",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var task Task
err := yaml.Unmarshal([]byte(tc.input), &task)
require.NoError(t, err)
assert.Equal(t, tc.wantModule, task.Module)
assert.NotNil(t, task.Args)
})
}
}
func TestTypes_Task_UnmarshalYAML_Good_ShortFormCommunityModules(t *testing.T) {
cases := []struct {
name string
input string
wantModule string
}{
{
name: "authorized_key",
input: `
name: Install SSH key
authorized_key:
user: deploy
key: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD
`,
wantModule: "authorized_key",
},
{
name: "ufw",
input: `
name: Allow SSH
ufw:
rule: allow
port: "22"
`,
wantModule: "ufw",
},
{
name: "docker_compose",
input: `
name: Start stack
docker_compose:
project_src: /opt/app
`,
wantModule: "docker_compose",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var task Task
err := yaml.Unmarshal([]byte(tc.input), &task)
require.NoError(t, err)
assert.Equal(t, tc.wantModule, task.Module)
})
}
}
func TestTypes_Task_UnmarshalYAML_Good_WithNotifyList(t *testing.T) {
func TestTask_UnmarshalYAML_Good_WithNotifyList(t *testing.T) {
input := `
name: Install package
apt:
@ -670,7 +208,7 @@ notify:
assert.Len(t, notifyList, 2)
}
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
func TestTask_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
input := `
name: Include tasks
include_tasks: other-tasks.yml
@ -682,76 +220,12 @@ include_tasks: other-tasks.yml
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
}
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasksFQCN(t *testing.T) {
input := `
name: Include tasks
ansible.builtin.include_tasks: other-tasks.yml
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
}
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasksApply(t *testing.T) {
input := `
name: Include tasks
include_tasks: other-tasks.yml
apply:
tags:
- deploy
become: true
become_user: root
delegate_facts: true
environment:
APP_ENV: production
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.Apply)
assert.Equal(t, []string{"deploy"}, task.Apply.Tags)
require.NotNil(t, task.Apply.Become)
assert.True(t, *task.Apply.Become)
assert.Equal(t, "root", task.Apply.BecomeUser)
assert.True(t, task.Apply.DelegateFacts)
assert.Equal(t, "production", task.Apply.Environment["APP_ENV"])
}
func TestTypes_Task_UnmarshalYAML_Good_DelegateFacts(t *testing.T) {
input := `
name: Gather delegated facts
delegate_to: delegate1
delegate_facts: true
setup:
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.True(t, task.DelegateFacts)
}
func TestTypes_Task_UnmarshalYAML_Good_IncludeRole(t *testing.T) {
func TestTask_UnmarshalYAML_Good_IncludeRole(t *testing.T) {
input := `
name: Include role
include_role:
name: common
tasks_from: setup.yml
defaults_from: defaults.yml
vars_from: vars.yml
handlers_from: handlers.yml
public: true
apply:
tags:
- deploy
when: apply_enabled
become: true
become_user: root
environment:
APP_ENV: production
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
@ -760,75 +234,9 @@ include_role:
require.NotNil(t, task.IncludeRole)
assert.Equal(t, "common", task.IncludeRole.Name)
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
assert.Equal(t, "defaults.yml", task.IncludeRole.DefaultsFrom)
assert.Equal(t, "vars.yml", task.IncludeRole.VarsFrom)
assert.Equal(t, "handlers.yml", task.IncludeRole.HandlersFrom)
assert.True(t, task.IncludeRole.Public)
require.NotNil(t, task.IncludeRole.Apply)
assert.Equal(t, []string{"deploy"}, task.IncludeRole.Apply.Tags)
assert.Equal(t, "apply_enabled", task.IncludeRole.Apply.When)
require.NotNil(t, task.IncludeRole.Apply.Become)
assert.True(t, *task.IncludeRole.Apply.Become)
assert.Equal(t, "root", task.IncludeRole.Apply.BecomeUser)
assert.Equal(t, "production", task.IncludeRole.Apply.Environment["APP_ENV"])
}
func TestTypes_Task_UnmarshalYAML_Good_IncludeRoleStringForm(t *testing.T) {
input := `
name: Include role
include_role: common
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.IncludeRole)
assert.Equal(t, "common", task.IncludeRole.Role)
}
func TestTypes_Task_UnmarshalYAML_Good_IncludeRoleFQCN(t *testing.T) {
input := `
name: Include role
ansible.builtin.include_role:
name: common
tasks_from: setup.yml
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.IncludeRole)
assert.Equal(t, "common", task.IncludeRole.Role)
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
}
func TestTypes_Task_UnmarshalYAML_Good_ImportRoleStringForm(t *testing.T) {
input := `
name: Import role
import_role: common
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.ImportRole)
assert.Equal(t, "common", task.ImportRole.Role)
}
func TestTypes_Task_UnmarshalYAML_Good_ImportRoleFQCN(t *testing.T) {
input := `
name: Import role
ansible.builtin.import_role: common
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.ImportRole)
assert.Equal(t, "common", task.ImportRole.Role)
}
func TestTypes_Task_UnmarshalYAML_Good_BecomeFields(t *testing.T) {
func TestTask_UnmarshalYAML_Good_BecomeFields(t *testing.T) {
input := `
name: Privileged task
shell: systemctl restart nginx
@ -844,7 +252,7 @@ become_user: root
assert.Equal(t, "root", task.BecomeUser)
}
func TestTypes_Task_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
func TestTask_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
input := `
name: Might fail
shell: some risky command
@ -859,7 +267,7 @@ ignore_errors: true
// --- Inventory data structure ---
func TestTypes_Inventory_UnmarshalYAML_Good_Complex(t *testing.T) {
func TestInventory_UnmarshalYAML_Good_Complex(t *testing.T) {
input := `
all:
vars:
@ -909,35 +317,31 @@ all:
// --- Facts ---
func TestTypes_Facts_Good_Struct(t *testing.T) {
func TestFacts_Struct(t *testing.T) {
facts := Facts{
Hostname: "web1",
FQDN: "web1.example.com",
OS: "Debian",
Distribution: "ubuntu",
Version: "24.04",
Architecture: "x86_64",
Kernel: "6.8.0",
VirtualizationRole: "guest",
VirtualizationType: "docker",
Memory: 16384,
CPUs: 4,
IPv4: "10.0.0.1",
Hostname: "web1",
FQDN: "web1.example.com",
OS: "Debian",
Distribution: "ubuntu",
Version: "24.04",
Architecture: "x86_64",
Kernel: "6.8.0",
Memory: 16384,
CPUs: 4,
IPv4: "10.0.0.1",
}
assert.Equal(t, "web1", facts.Hostname)
assert.Equal(t, "web1.example.com", facts.FQDN)
assert.Equal(t, "ubuntu", facts.Distribution)
assert.Equal(t, "x86_64", facts.Architecture)
assert.Equal(t, "guest", facts.VirtualizationRole)
assert.Equal(t, "docker", facts.VirtualizationType)
assert.Equal(t, int64(16384), facts.Memory)
assert.Equal(t, 4, facts.CPUs)
}
// --- TaskResult ---
func TestTypes_TaskResult_Good_Struct(t *testing.T) {
func TestTaskResult_Struct(t *testing.T) {
result := TaskResult{
Changed: true,
Failed: false,
@ -954,7 +358,7 @@ func TestTypes_TaskResult_Good_Struct(t *testing.T) {
assert.Equal(t, 0, result.RC)
}
func TestTypes_TaskResult_Good_WithLoopResults(t *testing.T) {
func TestTaskResult_WithLoopResults(t *testing.T) {
result := TaskResult{
Changed: true,
Results: []TaskResult{
@ -971,7 +375,7 @@ func TestTypes_TaskResult_Good_WithLoopResults(t *testing.T) {
// --- KnownModules ---
func TestTypes_KnownModules_Good_ContainsExpected(t *testing.T) {
func TestKnownModules_ContainsExpected(t *testing.T) {
// Verify both FQCN and short forms are present
fqcnModules := []string{
"ansible.builtin.shell",
@ -981,19 +385,8 @@ func TestTypes_KnownModules_Good_ContainsExpected(t *testing.T) {
"ansible.builtin.apt",
"ansible.builtin.service",
"ansible.builtin.systemd",
"ansible.builtin.rpm",
"ansible.builtin.debug",
"ansible.builtin.set_fact",
"ansible.builtin.ping",
"community.general.ufw",
"ansible.posix.authorized_key",
"ansible.builtin.docker_compose",
"ansible.builtin.docker_compose_v2",
"ansible.builtin.hostname",
"ansible.builtin.sysctl",
"ansible.builtin.reboot",
"community.docker.docker_compose",
"community.docker.docker_compose_v2",
}
for _, mod := range fqcnModules {
assert.Contains(t, KnownModules, mod, "expected FQCN module %s", mod)
@ -1001,7 +394,7 @@ func TestTypes_KnownModules_Good_ContainsExpected(t *testing.T) {
shortModules := []string{
"shell", "command", "copy", "file", "apt", "service",
"systemd", "rpm", "debug", "set_fact", "ping", "template", "user", "group",
"systemd", "debug", "set_fact", "template", "user", "group",
}
for _, mod := range shortModules {
assert.Contains(t, KnownModules, mod, "expected short-form module %s", mod)