feat: extract ansible package from go-devops

Pure Go Ansible playbook engine: YAML parser, SSH executor, 30+ module
implementations, Jinja2-compatible templating, privilege escalation,
event-driven callbacks, and inventory pattern matching.

438 tests passing with race detector.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-09 11:37:27 +00:00
commit 4fe5484e1f
18 changed files with 11769 additions and 0 deletions

37
CLAUDE.md Normal file
View file

@ -0,0 +1,37 @@
# CLAUDE.md
## 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. 174 module implementations, Jinja2-compatible templating, privilege escalation (become), and event-driven callbacks.
## Build & Development
```bash
go test ./...
go test -race ./...
```
## Architecture
Single `ansible` package:
- `types.go` — Playbook, Play, Task, TaskResult, Inventory, Host, Facts structs
- `parser.go` — YAML parser for playbooks, inventories, tasks, roles
- `executor.go` — Task execution engine with module dispatch, templating, condition evaluation
- `modules.go` — 30+ module implementations (shell, copy, apt, systemd, git, docker-compose, etc.)
- `ssh.go` — SSH client with known_hosts verification, become/sudo, file transfer
## Dependencies
- `go-log` — Structured logging
- `golang.org/x/crypto` — SSH protocol
- `gopkg.in/yaml.v3` — YAML parsing
- `testify` — Test assertions
## Coding Standards
- UK English
- All functions have typed params/returns
- Tests use testify + mock SSH client
- Test naming: `_Good`, `_Bad`, `_Ugly` suffixes
- License: EUPL-1.2

1018
executor.go Normal file

File diff suppressed because it is too large Load diff

427
executor_test.go Normal file
View file

@ -0,0 +1,427 @@
package ansible
import (
"testing"
"github.com/stretchr/testify/assert"
)
// --- NewExecutor ---
func TestNewExecutor_Good(t *testing.T) {
e := NewExecutor("/some/path")
assert.NotNil(t, e)
assert.NotNil(t, e.parser)
assert.NotNil(t, e.vars)
assert.NotNil(t, e.facts)
assert.NotNil(t, e.results)
assert.NotNil(t, e.handlers)
assert.NotNil(t, e.notified)
assert.NotNil(t, e.clients)
}
// --- SetVar ---
func TestSetVar_Good(t *testing.T) {
e := NewExecutor("/tmp")
e.SetVar("foo", "bar")
e.SetVar("count", 42)
assert.Equal(t, "bar", e.vars["foo"])
assert.Equal(t, 42, e.vars["count"])
}
// --- SetInventoryDirect ---
func TestSetInventoryDirect_Good(t *testing.T) {
e := NewExecutor("/tmp")
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"web1": {AnsibleHost: "10.0.0.1"},
},
},
}
e.SetInventoryDirect(inv)
assert.Equal(t, inv, e.inventory)
}
// --- getHosts ---
func TestGetHosts_Executor_Good_WithInventory(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {},
"host2": {},
},
},
})
hosts := e.getHosts("all")
assert.Len(t, hosts, 2)
}
func TestGetHosts_Executor_Good_Localhost(t *testing.T) {
e := NewExecutor("/tmp")
// No inventory set
hosts := e.getHosts("localhost")
assert.Equal(t, []string{"localhost"}, hosts)
}
func TestGetHosts_Executor_Good_NoInventory(t *testing.T) {
e := NewExecutor("/tmp")
hosts := e.getHosts("webservers")
assert.Nil(t, hosts)
}
func TestGetHosts_Executor_Good_WithLimit(t *testing.T) {
e := NewExecutor("/tmp")
e.SetInventoryDirect(&Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {},
"host2": {},
"host3": {},
},
},
})
e.Limit = "host2"
hosts := e.getHosts("all")
assert.Len(t, hosts, 1)
assert.Contains(t, hosts, "host2")
}
// --- matchesTags ---
func TestMatchesTags_Good_NoTagsFilter(t *testing.T) {
e := NewExecutor("/tmp")
assert.True(t, e.matchesTags(nil))
assert.True(t, e.matchesTags([]string{"any", "tags"}))
}
func TestMatchesTags_Good_IncludeTag(t *testing.T) {
e := NewExecutor("/tmp")
e.Tags = []string{"deploy"}
assert.True(t, e.matchesTags([]string{"deploy"}))
assert.True(t, e.matchesTags([]string{"setup", "deploy"}))
assert.False(t, e.matchesTags([]string{"other"}))
}
func TestMatchesTags_Good_SkipTag(t *testing.T) {
e := NewExecutor("/tmp")
e.SkipTags = []string{"slow"}
assert.True(t, e.matchesTags([]string{"fast"}))
assert.False(t, e.matchesTags([]string{"slow"}))
assert.False(t, e.matchesTags([]string{"fast", "slow"}))
}
func TestMatchesTags_Good_AllTag(t *testing.T) {
e := NewExecutor("/tmp")
e.Tags = []string{"all"}
assert.True(t, e.matchesTags([]string{"anything"}))
}
func TestMatchesTags_Good_NoTaskTags(t *testing.T) {
e := NewExecutor("/tmp")
e.Tags = []string{"deploy"}
// Tasks with no tags should not match when include tags are set
assert.False(t, e.matchesTags(nil))
assert.False(t, e.matchesTags([]string{}))
}
// --- handleNotify ---
func TestHandleNotify_Good_String(t *testing.T) {
e := NewExecutor("/tmp")
e.handleNotify("restart nginx")
assert.True(t, e.notified["restart nginx"])
}
func TestHandleNotify_Good_StringList(t *testing.T) {
e := NewExecutor("/tmp")
e.handleNotify([]string{"restart nginx", "reload config"})
assert.True(t, e.notified["restart nginx"])
assert.True(t, e.notified["reload config"])
}
func TestHandleNotify_Good_AnyList(t *testing.T) {
e := NewExecutor("/tmp")
e.handleNotify([]any{"restart nginx", "reload config"})
assert.True(t, e.notified["restart nginx"])
assert.True(t, e.notified["reload config"])
}
// --- normalizeConditions ---
func TestNormalizeConditions_Good_String(t *testing.T) {
result := normalizeConditions("my_var is defined")
assert.Equal(t, []string{"my_var is defined"}, result)
}
func TestNormalizeConditions_Good_StringSlice(t *testing.T) {
result := normalizeConditions([]string{"cond1", "cond2"})
assert.Equal(t, []string{"cond1", "cond2"}, result)
}
func TestNormalizeConditions_Good_AnySlice(t *testing.T) {
result := normalizeConditions([]any{"cond1", "cond2"})
assert.Equal(t, []string{"cond1", "cond2"}, result)
}
func TestNormalizeConditions_Good_Nil(t *testing.T) {
result := normalizeConditions(nil)
assert.Nil(t, result)
}
// --- evaluateWhen ---
func TestEvaluateWhen_Good_TrueLiteral(t *testing.T) {
e := NewExecutor("/tmp")
assert.True(t, e.evaluateWhen("true", "host1", nil))
assert.True(t, e.evaluateWhen("True", "host1", nil))
}
func TestEvaluateWhen_Good_FalseLiteral(t *testing.T) {
e := NewExecutor("/tmp")
assert.False(t, e.evaluateWhen("false", "host1", nil))
assert.False(t, e.evaluateWhen("False", "host1", nil))
}
func TestEvaluateWhen_Good_Negation(t *testing.T) {
e := NewExecutor("/tmp")
assert.False(t, e.evaluateWhen("not true", "host1", nil))
assert.True(t, e.evaluateWhen("not false", "host1", nil))
}
func TestEvaluateWhen_Good_RegisteredVarDefined(t *testing.T) {
e := NewExecutor("/tmp")
e.results["host1"] = map[string]*TaskResult{
"myresult": {Changed: true, Failed: false},
}
assert.True(t, e.evaluateWhen("myresult is defined", "host1", nil))
assert.False(t, e.evaluateWhen("myresult is not defined", "host1", nil))
assert.False(t, e.evaluateWhen("nonexistent is defined", "host1", nil))
assert.True(t, e.evaluateWhen("nonexistent is not defined", "host1", nil))
}
func TestEvaluateWhen_Good_RegisteredVarStatus(t *testing.T) {
e := NewExecutor("/tmp")
e.results["host1"] = map[string]*TaskResult{
"success_result": {Changed: true, Failed: false},
"failed_result": {Failed: true},
"skipped_result": {Skipped: true},
}
assert.True(t, e.evaluateWhen("success_result is success", "host1", nil))
assert.True(t, e.evaluateWhen("success_result is succeeded", "host1", nil))
assert.True(t, e.evaluateWhen("success_result is changed", "host1", nil))
assert.True(t, e.evaluateWhen("failed_result is failed", "host1", nil))
assert.True(t, e.evaluateWhen("skipped_result is skipped", "host1", nil))
}
func TestEvaluateWhen_Good_VarTruthy(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["enabled"] = true
e.vars["disabled"] = false
e.vars["name"] = "hello"
e.vars["empty"] = ""
e.vars["count"] = 5
e.vars["zero"] = 0
assert.True(t, e.evalCondition("enabled", "host1"))
assert.False(t, e.evalCondition("disabled", "host1"))
assert.True(t, e.evalCondition("name", "host1"))
assert.False(t, e.evalCondition("empty", "host1"))
assert.True(t, e.evalCondition("count", "host1"))
assert.False(t, e.evalCondition("zero", "host1"))
}
func TestEvaluateWhen_Good_MultipleConditions(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["enabled"] = true
// All conditions must be true (AND)
assert.True(t, e.evaluateWhen([]any{"true", "True"}, "host1", nil))
assert.False(t, e.evaluateWhen([]any{"true", "false"}, "host1", nil))
}
// --- templateString ---
func TestTemplateString_Good_SimpleVar(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["name"] = "world"
result := e.templateString("hello {{ name }}", "", nil)
assert.Equal(t, "hello world", result)
}
func TestTemplateString_Good_MultVars(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["host"] = "example.com"
e.vars["port"] = 8080
result := e.templateString("http://{{ host }}:{{ port }}", "", nil)
assert.Equal(t, "http://example.com:8080", result)
}
func TestTemplateString_Good_Unresolved(t *testing.T) {
e := NewExecutor("/tmp")
result := e.templateString("{{ undefined_var }}", "", nil)
assert.Equal(t, "{{ undefined_var }}", result)
}
func TestTemplateString_Good_NoTemplate(t *testing.T) {
e := NewExecutor("/tmp")
result := e.templateString("plain string", "", nil)
assert.Equal(t, "plain string", result)
}
// --- applyFilter ---
func TestApplyFilter_Good_Default(t *testing.T) {
e := NewExecutor("/tmp")
assert.Equal(t, "hello", e.applyFilter("hello", "default('fallback')"))
assert.Equal(t, "fallback", e.applyFilter("", "default('fallback')"))
}
func TestApplyFilter_Good_Bool(t *testing.T) {
e := NewExecutor("/tmp")
assert.Equal(t, "true", e.applyFilter("true", "bool"))
assert.Equal(t, "true", e.applyFilter("yes", "bool"))
assert.Equal(t, "true", e.applyFilter("1", "bool"))
assert.Equal(t, "false", e.applyFilter("false", "bool"))
assert.Equal(t, "false", e.applyFilter("no", "bool"))
assert.Equal(t, "false", e.applyFilter("anything", "bool"))
}
func TestApplyFilter_Good_Trim(t *testing.T) {
e := NewExecutor("/tmp")
assert.Equal(t, "hello", e.applyFilter(" hello ", "trim"))
}
// --- resolveLoop ---
func TestResolveLoop_Good_SliceAny(t *testing.T) {
e := NewExecutor("/tmp")
items := e.resolveLoop([]any{"a", "b", "c"}, "host1")
assert.Len(t, items, 3)
}
func TestResolveLoop_Good_SliceString(t *testing.T) {
e := NewExecutor("/tmp")
items := e.resolveLoop([]string{"a", "b", "c"}, "host1")
assert.Len(t, items, 3)
}
func TestResolveLoop_Good_Nil(t *testing.T) {
e := NewExecutor("/tmp")
items := e.resolveLoop(nil, "host1")
assert.Nil(t, items)
}
// --- templateArgs ---
func TestTemplateArgs_Good(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["myvar"] = "resolved"
args := map[string]any{
"plain": "no template",
"templated": "{{ myvar }}",
"number": 42,
}
result := e.templateArgs(args, "host1", nil)
assert.Equal(t, "no template", result["plain"])
assert.Equal(t, "resolved", result["templated"])
assert.Equal(t, 42, result["number"])
}
func TestTemplateArgs_Good_NestedMap(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["port"] = "8080"
args := map[string]any{
"nested": map[string]any{
"port": "{{ port }}",
},
}
result := e.templateArgs(args, "host1", nil)
nested := result["nested"].(map[string]any)
assert.Equal(t, "8080", nested["port"])
}
func TestTemplateArgs_Good_ArrayValues(t *testing.T) {
e := NewExecutor("/tmp")
e.vars["pkg"] = "nginx"
args := map[string]any{
"packages": []any{"{{ pkg }}", "curl"},
}
result := e.templateArgs(args, "host1", nil)
pkgs := result["packages"].([]any)
assert.Equal(t, "nginx", pkgs[0])
assert.Equal(t, "curl", pkgs[1])
}
// --- Helper functions ---
func TestGetStringArg_Good(t *testing.T) {
args := map[string]any{
"name": "value",
"number": 42,
}
assert.Equal(t, "value", getStringArg(args, "name", ""))
assert.Equal(t, "42", getStringArg(args, "number", ""))
assert.Equal(t, "default", getStringArg(args, "missing", "default"))
}
func TestGetBoolArg_Good(t *testing.T) {
args := map[string]any{
"enabled": true,
"disabled": false,
"yes_str": "yes",
"true_str": "true",
"one_str": "1",
"no_str": "no",
}
assert.True(t, getBoolArg(args, "enabled", false))
assert.False(t, getBoolArg(args, "disabled", true))
assert.True(t, getBoolArg(args, "yes_str", false))
assert.True(t, getBoolArg(args, "true_str", false))
assert.True(t, getBoolArg(args, "one_str", false))
assert.False(t, getBoolArg(args, "no_str", true))
assert.True(t, getBoolArg(args, "missing", true))
assert.False(t, getBoolArg(args, "missing", false))
}
// --- Close ---
func TestClose_Good_EmptyClients(t *testing.T) {
e := NewExecutor("/tmp")
// Should not panic with no clients
e.Close()
assert.Empty(t, e.clients)
}

17
go.mod Normal file
View file

@ -0,0 +1,17 @@
module forge.lthn.ai/core/go-ansible
go 1.26.0
require (
forge.lthn.ai/core/go-log v0.0.1
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.48.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
golang.org/x/sys v0.41.0 // indirect
)

26
go.sum Normal file
View file

@ -0,0 +1,26 @@
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

1416
mock_ssh_test.go Normal file

File diff suppressed because it is too large Load diff

1435
modules.go Normal file

File diff suppressed because it is too large Load diff

1127
modules_adv_test.go Normal file

File diff suppressed because it is too large Load diff

722
modules_cmd_test.go Normal file
View file

@ -0,0 +1,722 @@
package ansible
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ============================================================
// Step 1.1: command / shell / raw / script module tests
// ============================================================
// --- MockSSHClient basic tests ---
func TestMockSSHClient_Good_RunRecordsExecution(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommand("echo hello", "hello\n", "", 0)
stdout, stderr, rc, err := mock.Run(nil, "echo hello")
assert.NoError(t, err)
assert.Equal(t, "hello\n", stdout)
assert.Equal(t, "", stderr)
assert.Equal(t, 0, rc)
assert.Equal(t, 1, mock.commandCount())
assert.Equal(t, "Run", mock.lastCommand().Method)
assert.Equal(t, "echo hello", mock.lastCommand().Cmd)
}
func TestMockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommand("set -e", "ok", "", 0)
stdout, _, rc, err := mock.RunScript(nil, "set -e\necho done")
assert.NoError(t, err)
assert.Equal(t, "ok", stdout)
assert.Equal(t, 0, rc)
assert.Equal(t, 1, mock.commandCount())
assert.Equal(t, "RunScript", mock.lastCommand().Method)
}
func TestMockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
mock := NewMockSSHClient()
// No expectations registered — should return empty success
stdout, stderr, rc, err := mock.Run(nil, "anything")
assert.NoError(t, err)
assert.Equal(t, "", stdout)
assert.Equal(t, "", stderr)
assert.Equal(t, 0, rc)
}
func TestMockSSHClient_Good_LastMatchWins(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommand("echo", "first", "", 0)
mock.expectCommand("echo", "second", "", 0)
stdout, _, _, _ := mock.Run(nil, "echo hello")
assert.Equal(t, "second", stdout)
}
func TestMockSSHClient_Good_FileOperations(t *testing.T) {
mock := NewMockSSHClient()
// File does not exist initially
exists, err := mock.FileExists(nil, "/etc/config")
assert.NoError(t, err)
assert.False(t, exists)
// Add file
mock.addFile("/etc/config", []byte("key=value"))
// Now it exists
exists, err = mock.FileExists(nil, "/etc/config")
assert.NoError(t, err)
assert.True(t, exists)
// Download it
content, err := mock.Download(nil, "/etc/config")
assert.NoError(t, err)
assert.Equal(t, []byte("key=value"), content)
// Download non-existent file
_, err = mock.Download(nil, "/nonexistent")
assert.Error(t, err)
}
func TestMockSSHClient_Good_StatWithExplicit(t *testing.T) {
mock := NewMockSSHClient()
mock.addStat("/var/log", map[string]any{"exists": true, "isdir": true})
info, err := mock.Stat(nil, "/var/log")
assert.NoError(t, err)
assert.Equal(t, true, info["exists"])
assert.Equal(t, true, info["isdir"])
}
func TestMockSSHClient_Good_StatFallback(t *testing.T) {
mock := NewMockSSHClient()
mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost"))
info, err := mock.Stat(nil, "/etc/hosts")
assert.NoError(t, err)
assert.Equal(t, true, info["exists"])
assert.Equal(t, false, info["isdir"])
info, err = mock.Stat(nil, "/nonexistent")
assert.NoError(t, err)
assert.Equal(t, false, info["exists"])
}
func TestMockSSHClient_Good_BecomeTracking(t *testing.T) {
mock := NewMockSSHClient()
assert.False(t, mock.become)
assert.Equal(t, "", mock.becomeUser)
mock.SetBecome(true, "root", "secret")
assert.True(t, mock.become)
assert.Equal(t, "root", mock.becomeUser)
assert.Equal(t, "secret", mock.becomePass)
}
func TestMockSSHClient_Good_HasExecuted(t *testing.T) {
mock := NewMockSSHClient()
_, _, _, _ = mock.Run(nil, "systemctl restart nginx")
_, _, _, _ = mock.Run(nil, "apt-get update")
assert.True(t, mock.hasExecuted("systemctl.*nginx"))
assert.True(t, mock.hasExecuted("apt-get"))
assert.False(t, mock.hasExecuted("yum"))
}
func TestMockSSHClient_Good_HasExecutedMethod(t *testing.T) {
mock := NewMockSSHClient()
_, _, _, _ = mock.Run(nil, "echo run")
_, _, _, _ = mock.RunScript(nil, "echo script")
assert.True(t, mock.hasExecutedMethod("Run", "echo run"))
assert.True(t, mock.hasExecutedMethod("RunScript", "echo script"))
assert.False(t, mock.hasExecutedMethod("Run", "echo script"))
assert.False(t, mock.hasExecutedMethod("RunScript", "echo run"))
}
func TestMockSSHClient_Good_Reset(t *testing.T) {
mock := NewMockSSHClient()
_, _, _, _ = mock.Run(nil, "echo hello")
assert.Equal(t, 1, mock.commandCount())
mock.reset()
assert.Equal(t, 0, mock.commandCount())
}
func TestMockSSHClient_Good_ErrorExpectation(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommandError("bad cmd", assert.AnError)
_, _, _, err := mock.Run(nil, "bad cmd")
assert.Error(t, err)
}
// --- command module ---
func TestModuleCommand_Good_BasicCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("ls -la /tmp", "total 0\n", "", 0)
result, err := moduleCommandWithClient(e, mock, map[string]any{
"_raw_params": "ls -la /tmp",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.Equal(t, "total 0\n", result.Stdout)
assert.Equal(t, 0, result.RC)
// Verify it used Run (not RunScript)
assert.True(t, mock.hasExecutedMethod("Run", "ls -la /tmp"))
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
}
func TestModuleCommand_Good_CmdArg(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("whoami", "root\n", "", 0)
result, err := moduleCommandWithClient(e, mock, map[string]any{
"cmd": "whoami",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, "root\n", result.Stdout)
assert.True(t, mock.hasExecutedMethod("Run", "whoami"))
}
func TestModuleCommand_Good_WithChdir(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`cd "/var/log" && ls`, "syslog\n", "", 0)
result, err := moduleCommandWithClient(e, mock, map[string]any{
"_raw_params": "ls",
"chdir": "/var/log",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// The command should have been wrapped with cd
last := mock.lastCommand()
assert.Equal(t, "Run", last.Method)
assert.Contains(t, last.Cmd, `cd "/var/log"`)
assert.Contains(t, last.Cmd, "ls")
}
func TestModuleCommand_Bad_NoCommand(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
_, err := moduleCommandWithClient(e, mock, map[string]any{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no command specified")
}
func TestModuleCommand_Good_NonZeroRC(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("false", "", "error occurred", 1)
result, err := moduleCommandWithClient(e, mock, map[string]any{
"_raw_params": "false",
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Equal(t, 1, result.RC)
assert.Equal(t, "error occurred", result.Stderr)
}
func TestModuleCommand_Good_SSHError(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
result, err := moduleCommandWithClient(e, mock, map[string]any{
"_raw_params": "any command",
})
require.NoError(t, err) // Module wraps SSH errors into result.Failed
assert.True(t, result.Failed)
assert.Contains(t, result.Msg, assert.AnError.Error())
}
func TestModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("from_raw", "raw\n", "", 0)
result, err := moduleCommandWithClient(e, mock, map[string]any{
"_raw_params": "from_raw",
"cmd": "from_cmd",
})
require.NoError(t, err)
assert.Equal(t, "raw\n", result.Stdout)
assert.True(t, mock.hasExecuted("from_raw"))
}
// --- shell module ---
func TestModuleShell_Good_BasicShell(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo hello", "hello\n", "", 0)
result, err := moduleShellWithClient(e, mock, map[string]any{
"_raw_params": "echo hello",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.Equal(t, "hello\n", result.Stdout)
// Shell must use RunScript (not Run)
assert.True(t, mock.hasExecutedMethod("RunScript", "echo hello"))
assert.False(t, mock.hasExecutedMethod("Run", ".*"))
}
func TestModuleShell_Good_CmdArg(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("date", "Thu Feb 20\n", "", 0)
result, err := moduleShellWithClient(e, mock, map[string]any{
"cmd": "date",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecutedMethod("RunScript", "date"))
}
func TestModuleShell_Good_WithChdir(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`cd "/app" && npm install`, "done\n", "", 0)
result, err := moduleShellWithClient(e, mock, map[string]any{
"_raw_params": "npm install",
"chdir": "/app",
})
require.NoError(t, err)
assert.True(t, result.Changed)
last := mock.lastCommand()
assert.Equal(t, "RunScript", last.Method)
assert.Contains(t, last.Cmd, `cd "/app"`)
assert.Contains(t, last.Cmd, "npm install")
}
func TestModuleShell_Bad_NoCommand(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
_, err := moduleShellWithClient(e, mock, map[string]any{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no command specified")
}
func TestModuleShell_Good_NonZeroRC(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("exit 2", "", "failed", 2)
result, err := moduleShellWithClient(e, mock, map[string]any{
"_raw_params": "exit 2",
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Equal(t, 2, result.RC)
}
func TestModuleShell_Good_SSHError(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
result, err := moduleShellWithClient(e, mock, map[string]any{
"_raw_params": "some command",
})
require.NoError(t, err)
assert.True(t, result.Failed)
}
func TestModuleShell_Good_PipelineCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`cat /etc/passwd \| grep root`, "root:x:0:0\n", "", 0)
result, err := moduleShellWithClient(e, mock, map[string]any{
"_raw_params": "cat /etc/passwd | grep root",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Shell uses RunScript, so pipes work
assert.True(t, mock.hasExecutedMethod("RunScript", "cat /etc/passwd"))
}
// --- raw module ---
func TestModuleRaw_Good_BasicRaw(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("uname -a", "Linux host1 5.15\n", "", 0)
result, err := moduleRawWithClient(e, mock, map[string]any{
"_raw_params": "uname -a",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, "Linux host1 5.15\n", result.Stdout)
// Raw must use Run (not RunScript) — no shell wrapping
assert.True(t, mock.hasExecutedMethod("Run", "uname -a"))
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
}
func TestModuleRaw_Bad_NoCommand(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
_, err := moduleRawWithClient(e, mock, map[string]any{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no command specified")
}
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)
result, err := moduleRawWithClient(e, mock, map[string]any{
"_raw_params": "echo test",
"chdir": "/should/be/ignored",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// The chdir should NOT appear in the command
last := mock.lastCommand()
assert.Equal(t, "echo test", last.Cmd)
assert.NotContains(t, last.Cmd, "cd")
}
func TestModuleRaw_Good_NonZeroRC(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("invalid", "", "not found", 127)
result, err := moduleRawWithClient(e, mock, map[string]any{
"_raw_params": "invalid",
})
require.NoError(t, err)
// Note: raw module does NOT set Failed based on RC
assert.Equal(t, 127, result.RC)
assert.Equal(t, "not found", result.Stderr)
}
func TestModuleRaw_Good_SSHError(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
result, err := moduleRawWithClient(e, mock, map[string]any{
"_raw_params": "any",
})
require.NoError(t, err)
assert.True(t, result.Failed)
}
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)'`
mock.expectCommand(".*python3.*", "3.10.0\n", "", 0)
result, err := moduleRawWithClient(e, mock, map[string]any{
"_raw_params": complexCmd,
})
require.NoError(t, err)
assert.True(t, result.Changed)
last := mock.lastCommand()
assert.Equal(t, complexCmd, last.Cmd)
}
// --- script module ---
func TestModuleScript_Good_BasicScript(t *testing.T) {
// Create a temporary script file
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "setup.sh")
scriptContent := "#!/bin/bash\necho 'setup complete'\nexit 0"
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("setup complete", "setup complete\n", "", 0)
result, err := moduleScriptWithClient(e, mock, map[string]any{
"_raw_params": scriptPath,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
// Script must use RunScript (not Run) — it sends the file content
assert.True(t, mock.hasExecutedMethod("RunScript", "setup complete"))
assert.False(t, mock.hasExecutedMethod("Run", ".*"))
// Verify the full script content was sent
last := mock.lastCommand()
assert.Equal(t, scriptContent, last.Cmd)
}
func TestModuleScript_Bad_NoScript(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
_, err := moduleScriptWithClient(e, mock, map[string]any{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no script specified")
}
func TestModuleScript_Bad_FileNotFound(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
_, err := moduleScriptWithClient(e, mock, map[string]any{
"_raw_params": "/nonexistent/script.sh",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "read script")
}
func TestModuleScript_Good_NonZeroRC(t *testing.T) {
tmpDir := t.TempDir()
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)
result, err := moduleScriptWithClient(e, mock, map[string]any{
"_raw_params": scriptPath,
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Equal(t, 1, result.RC)
}
func TestModuleScript_Good_MultiLineScript(t *testing.T) {
tmpDir := t.TempDir()
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, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("apt-get", "done\n", "", 0)
result, err := moduleScriptWithClient(e, mock, map[string]any{
"_raw_params": scriptPath,
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Verify RunScript was called with the full content
last := mock.lastCommand()
assert.Equal(t, "RunScript", last.Method)
assert.Equal(t, scriptContent, last.Cmd)
}
func TestModuleScript_Good_SSHError(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "ok.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("echo ok"), 0755))
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
result, err := moduleScriptWithClient(e, mock, map[string]any{
"_raw_params": scriptPath,
})
require.NoError(t, err)
assert.True(t, result.Failed)
}
// --- Cross-module differentiation tests ---
func TestModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
_, _ = moduleCommandWithClient(e, mock, map[string]any{"_raw_params": "echo test"})
cmds := mock.executedCommands()
require.Len(t, cmds, 1)
assert.Equal(t, "Run", cmds[0].Method, "command module must use Run()")
}
func TestModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
_, _ = moduleShellWithClient(e, mock, map[string]any{"_raw_params": "echo test"})
cmds := mock.executedCommands()
require.Len(t, cmds, 1)
assert.Equal(t, "RunScript", cmds[0].Method, "shell module must use RunScript()")
}
func TestModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
_, _ = moduleRawWithClient(e, mock, map[string]any{"_raw_params": "echo test"})
cmds := mock.executedCommands()
require.Len(t, cmds, 1)
assert.Equal(t, "Run", cmds[0].Method, "raw module must use Run()")
}
func TestModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
tmpDir := t.TempDir()
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)
_, _ = moduleScriptWithClient(e, mock, map[string]any{"_raw_params": scriptPath})
cmds := mock.executedCommands()
require.Len(t, cmds, 1)
assert.Equal(t, "RunScript", cmds[0].Method, "script module must use RunScript()")
}
// --- executeModuleWithMock dispatch tests ---
func TestExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("uptime", "up 5 days\n", "", 0)
task := &Task{
Module: "command",
Args: map[string]any{"_raw_params": "uptime"},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, "up 5 days\n", result.Stdout)
}
func TestExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("ps aux", "root.*bash\n", "", 0)
task := &Task{
Module: "ansible.builtin.shell",
Args: map[string]any{"_raw_params": "ps aux | grep bash"},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("cat /etc/hostname", "web01\n", "", 0)
task := &Task{
Module: "raw",
Args: map[string]any{"_raw_params": "cat /etc/hostname"},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, "web01\n", result.Stdout)
}
func TestExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
tmpDir := t.TempDir()
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)
task := &Task{
Module: "script",
Args: map[string]any{"_raw_params": scriptPath},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
task := &Task{
Module: "ansible.builtin.hostname",
Args: map[string]any{},
}
_, err := executeModuleWithMock(e, mock, "host1", task)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported module")
}
// --- Template integration tests ---
func TestModuleCommand_Good_TemplatedArgs(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
e.SetVar("service_name", "nginx")
mock.expectCommand("systemctl status nginx", "active\n", "", 0)
task := &Task{
Module: "command",
Args: map[string]any{"_raw_params": "systemctl status {{ service_name }}"},
}
// Template the args the way the executor does
args := e.templateArgs(task.Args, "host1", task)
result, err := moduleCommandWithClient(e, mock, args)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted("systemctl status nginx"))
}

899
modules_file_test.go Normal file
View file

@ -0,0 +1,899 @@
package ansible
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ============================================================
// Step 1.2: copy / template / file / lineinfile / blockinfile / stat module tests
// ============================================================
// --- copy module ---
func TestModuleCopy_Good_ContentUpload(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleCopyWithClient(e, mock, map[string]any{
"content": "server_name=web01",
"dest": "/etc/app/config",
}, "host1", &Task{})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Contains(t, result.Msg, "copied to /etc/app/config")
// Verify upload was performed
assert.Equal(t, 1, mock.uploadCount())
up := mock.lastUpload()
require.NotNil(t, up)
assert.Equal(t, "/etc/app/config", up.Remote)
assert.Equal(t, []byte("server_name=web01"), up.Content)
assert.Equal(t, os.FileMode(0644), up.Mode)
}
func TestModuleCopy_Good_SrcFile(t *testing.T) {
tmpDir := t.TempDir()
srcPath := filepath.Join(tmpDir, "nginx.conf")
require.NoError(t, os.WriteFile(srcPath, []byte("worker_processes auto;"), 0644))
e, mock := newTestExecutorWithMock("host1")
result, err := moduleCopyWithClient(e, mock, map[string]any{
"src": srcPath,
"dest": "/etc/nginx/nginx.conf",
}, "host1", &Task{})
require.NoError(t, err)
assert.True(t, result.Changed)
up := mock.lastUpload()
require.NotNil(t, up)
assert.Equal(t, "/etc/nginx/nginx.conf", up.Remote)
assert.Equal(t, []byte("worker_processes auto;"), up.Content)
}
func TestModuleCopy_Good_OwnerGroup(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleCopyWithClient(e, mock, map[string]any{
"content": "data",
"dest": "/opt/app/data.txt",
"owner": "appuser",
"group": "appgroup",
}, "host1", &Task{})
require.NoError(t, err)
assert.True(t, result.Changed)
// Upload + chown + chgrp = 1 upload + 2 Run calls
assert.Equal(t, 1, mock.uploadCount())
assert.True(t, mock.hasExecuted(`chown appuser "/opt/app/data.txt"`))
assert.True(t, mock.hasExecuted(`chgrp appgroup "/opt/app/data.txt"`))
}
func TestModuleCopy_Good_CustomMode(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleCopyWithClient(e, mock, map[string]any{
"content": "#!/bin/bash\necho hello",
"dest": "/usr/local/bin/hello.sh",
"mode": "0755",
}, "host1", &Task{})
require.NoError(t, err)
assert.True(t, result.Changed)
up := mock.lastUpload()
require.NotNil(t, up)
assert.Equal(t, os.FileMode(0755), up.Mode)
}
func TestModuleCopy_Bad_MissingDest(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleCopyWithClient(e, mock, map[string]any{
"content": "data",
}, "host1", &Task{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "dest required")
}
func TestModuleCopy_Bad_MissingSrcAndContent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleCopyWithClient(e, mock, map[string]any{
"dest": "/tmp/out",
}, "host1", &Task{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "src or content required")
}
func TestModuleCopy_Bad_SrcFileNotFound(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleCopyWithClient(e, mock, map[string]any{
"src": "/nonexistent/file.txt",
"dest": "/tmp/out",
}, "host1", &Task{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "read src")
}
func TestModuleCopy_Good_ContentTakesPrecedenceOverSrc(t *testing.T) {
// When both content and src are given, src is checked first in the implementation
// but if src is empty string, content is used
e, mock := newTestExecutorWithMock("host1")
result, err := moduleCopyWithClient(e, mock, map[string]any{
"content": "from_content",
"dest": "/tmp/out",
}, "host1", &Task{})
require.NoError(t, err)
assert.True(t, result.Changed)
up := mock.lastUpload()
assert.Equal(t, []byte("from_content"), up.Content)
}
// --- file module ---
func TestModuleFile_Good_StateDirectory(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/var/lib/app",
"state": "directory",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should execute mkdir -p with default mode 0755
assert.True(t, mock.hasExecuted(`mkdir -p "/var/lib/app"`))
assert.True(t, mock.hasExecuted(`chmod 0755`))
}
func TestModuleFile_Good_StateDirectoryCustomMode(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/opt/data",
"state": "directory",
"mode": "0700",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`mkdir -p "/opt/data" && chmod 0700 "/opt/data"`))
}
func TestModuleFile_Good_StateAbsent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/tmp/old-dir",
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`rm -rf "/tmp/old-dir"`))
}
func TestModuleFile_Good_StateTouch(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/var/log/app.log",
"state": "touch",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`touch "/var/log/app.log"`))
}
func TestModuleFile_Good_StateLink(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/usr/local/bin/node",
"state": "link",
"src": "/opt/node/bin/node",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`ln -sf "/opt/node/bin/node" "/usr/local/bin/node"`))
}
func TestModuleFile_Bad_LinkMissingSrc(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/usr/local/bin/node",
"state": "link",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "src required for link state")
}
func TestModuleFile_Good_OwnerGroupMode(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/var/lib/app/data",
"state": "directory",
"owner": "www-data",
"group": "www-data",
"mode": "0775",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should have mkdir, chmod in the directory command, then chown and chgrp
assert.True(t, mock.hasExecuted(`mkdir -p "/var/lib/app/data" && chmod 0775 "/var/lib/app/data"`))
assert.True(t, mock.hasExecuted(`chown www-data "/var/lib/app/data"`))
assert.True(t, mock.hasExecuted(`chgrp www-data "/var/lib/app/data"`))
}
func TestModuleFile_Good_RecurseOwner(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/var/www",
"state": "directory",
"owner": "www-data",
"recurse": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should have both regular chown and recursive chown
assert.True(t, mock.hasExecuted(`chown www-data "/var/www"`))
assert.True(t, mock.hasExecuted(`chown -R www-data "/var/www"`))
}
func TestModuleFile_Bad_MissingPath(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleFileWithClient(e, mock, map[string]any{
"state": "directory",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "path required")
}
func TestModuleFile_Good_DestAliasForPath(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleFileWithClient(e, mock, map[string]any{
"dest": "/opt/myapp",
"state": "directory",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`mkdir -p "/opt/myapp"`))
}
func TestModuleFile_Good_StateFileWithMode(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/etc/config.yml",
"state": "file",
"mode": "0600",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`chmod 0600 "/etc/config.yml"`))
}
func TestModuleFile_Good_DirectoryCommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("mkdir", "", "permission denied", 1)
result, err := moduleFileWithClient(e, mock, map[string]any{
"path": "/root/protected",
"state": "directory",
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Contains(t, result.Msg, "permission denied")
}
// --- lineinfile module ---
func TestModuleLineinfile_Good_InsertLine(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
"path": "/etc/hosts",
"line": "192.168.1.100 web01",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should use grep -qxF to check and echo to append
assert.True(t, mock.hasExecuted(`grep -qxF`))
assert.True(t, mock.hasExecuted(`192.168.1.100 web01`))
}
func TestModuleLineinfile_Good_ReplaceRegexp(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
"path": "/etc/ssh/sshd_config",
"regexp": "^#?PermitRootLogin",
"line": "PermitRootLogin no",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should use sed to replace
assert.True(t, mock.hasExecuted(`sed -i 's/\^#\?PermitRootLogin/PermitRootLogin no/'`))
}
func TestModuleLineinfile_Good_RemoveLine(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
"path": "/etc/hosts",
"regexp": "^192\\.168\\.1\\.100",
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should use sed to delete matching lines
assert.True(t, mock.hasExecuted(`sed -i '/\^192`))
assert.True(t, mock.hasExecuted(`/d'`))
}
func TestModuleLineinfile_Good_RegexpFallsBackToAppend(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
// Simulate sed returning non-zero (pattern not found)
mock.expectCommand("sed -i", "", "", 1)
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
"path": "/etc/config",
"regexp": "^setting=",
"line": "setting=value",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should have attempted sed, then fallen back to echo append
cmds := mock.executedCommands()
assert.GreaterOrEqual(t, len(cmds), 2)
assert.True(t, mock.hasExecuted(`echo`))
}
func TestModuleLineinfile_Bad_MissingPath(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleLineinfileWithClient(e, mock, map[string]any{
"line": "test",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "path required")
}
func TestModuleLineinfile_Good_DestAliasForPath(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
"dest": "/etc/config",
"line": "key=value",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`/etc/config`))
}
func TestModuleLineinfile_Good_AbsentWithNoRegexp(t *testing.T) {
// When state=absent but no regexp, nothing happens (no commands)
e, mock := newTestExecutorWithMock("host1")
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
"path": "/etc/config",
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, 0, mock.commandCount())
}
func TestModuleLineinfile_Good_LineWithSlashes(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
"path": "/etc/nginx/conf.d/default.conf",
"regexp": "^root /",
"line": "root /var/www/html;",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Slashes in the line should be escaped
assert.True(t, mock.hasExecuted(`root \\/var\\/www\\/html;`))
}
// --- blockinfile module ---
func TestModuleBlockinfile_Good_InsertBlock(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
"path": "/etc/nginx/conf.d/upstream.conf",
"block": "server 10.0.0.1:8080;\nserver 10.0.0.2:8080;",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should use RunScript for the heredoc approach
assert.True(t, mock.hasExecutedMethod("RunScript", "BEGIN ANSIBLE MANAGED BLOCK"))
assert.True(t, mock.hasExecutedMethod("RunScript", "END ANSIBLE MANAGED BLOCK"))
assert.True(t, mock.hasExecutedMethod("RunScript", "10\\.0\\.0\\.1"))
}
func TestModuleBlockinfile_Good_CustomMarkers(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
"path": "/etc/hosts",
"block": "10.0.0.5 db01",
"marker": "# {mark} managed by devops",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should use custom markers instead of default
assert.True(t, mock.hasExecutedMethod("RunScript", "# BEGIN managed by devops"))
assert.True(t, mock.hasExecutedMethod("RunScript", "# END managed by devops"))
}
func TestModuleBlockinfile_Good_RemoveBlock(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
"path": "/etc/config",
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should use sed to remove the block between markers
assert.True(t, mock.hasExecuted(`sed -i '/.*BEGIN ANSIBLE MANAGED BLOCK/,/.*END ANSIBLE MANAGED BLOCK/d'`))
}
func TestModuleBlockinfile_Good_CreateFile(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
"path": "/etc/new-config",
"block": "setting=value",
"create": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Should touch the file first when create=true
assert.True(t, mock.hasExecuted(`touch "/etc/new-config"`))
}
func TestModuleBlockinfile_Bad_MissingPath(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleBlockinfileWithClient(e, mock, map[string]any{
"block": "content",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "path required")
}
func TestModuleBlockinfile_Good_DestAliasForPath(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
"dest": "/etc/config",
"block": "data",
})
require.NoError(t, err)
assert.True(t, result.Changed)
}
func TestModuleBlockinfile_Good_ScriptFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("BLOCK_EOF", "", "write error", 1)
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
"path": "/etc/config",
"block": "data",
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Contains(t, result.Msg, "write error")
}
// --- stat module ---
func TestModuleStat_Good_ExistingFile(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.addStat("/etc/nginx/nginx.conf", map[string]any{
"exists": true,
"isdir": false,
"mode": "0644",
"size": 1234,
"uid": 0,
"gid": 0,
})
result, err := moduleStatWithClient(e, mock, map[string]any{
"path": "/etc/nginx/nginx.conf",
})
require.NoError(t, err)
assert.False(t, result.Changed) // stat never changes anything
require.NotNil(t, result.Data)
stat, ok := result.Data["stat"].(map[string]any)
require.True(t, ok)
assert.Equal(t, true, stat["exists"])
assert.Equal(t, false, stat["isdir"])
assert.Equal(t, "0644", stat["mode"])
assert.Equal(t, 1234, stat["size"])
}
func TestModuleStat_Good_MissingFile(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleStatWithClient(e, mock, map[string]any{
"path": "/nonexistent/file.txt",
})
require.NoError(t, err)
assert.False(t, result.Changed)
require.NotNil(t, result.Data)
stat, ok := result.Data["stat"].(map[string]any)
require.True(t, ok)
assert.Equal(t, false, stat["exists"])
}
func TestModuleStat_Good_Directory(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.addStat("/var/log", map[string]any{
"exists": true,
"isdir": true,
"mode": "0755",
})
result, err := moduleStatWithClient(e, mock, map[string]any{
"path": "/var/log",
})
require.NoError(t, err)
stat := result.Data["stat"].(map[string]any)
assert.Equal(t, true, stat["exists"])
assert.Equal(t, true, stat["isdir"])
}
func TestModuleStat_Good_FallbackFromFileSystem(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
// No explicit stat, but add a file — stat falls back to file existence
mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost"))
result, err := moduleStatWithClient(e, mock, map[string]any{
"path": "/etc/hosts",
})
require.NoError(t, err)
stat := result.Data["stat"].(map[string]any)
assert.Equal(t, true, stat["exists"])
assert.Equal(t, false, stat["isdir"])
}
func TestModuleStat_Bad_MissingPath(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleStatWithClient(e, mock, map[string]any{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "path required")
}
// --- template module ---
func TestModuleTemplate_Good_BasicTemplate(t *testing.T) {
tmpDir := t.TempDir()
srcPath := filepath.Join(tmpDir, "app.conf.j2")
require.NoError(t, os.WriteFile(srcPath, []byte("server_name={{ server_name }};"), 0644))
e, mock := newTestExecutorWithMock("host1")
e.SetVar("server_name", "web01.example.com")
result, err := moduleTemplateWithClient(e, mock, map[string]any{
"src": srcPath,
"dest": "/etc/nginx/conf.d/app.conf",
}, "host1", &Task{})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Contains(t, result.Msg, "templated to /etc/nginx/conf.d/app.conf")
// Verify upload was performed with templated content
assert.Equal(t, 1, mock.uploadCount())
up := mock.lastUpload()
require.NotNil(t, up)
assert.Equal(t, "/etc/nginx/conf.d/app.conf", up.Remote)
// Template replaces {{ var }} — the TemplateFile does Jinja2 to Go conversion
assert.Contains(t, string(up.Content), "web01.example.com")
}
func TestModuleTemplate_Good_CustomMode(t *testing.T) {
tmpDir := t.TempDir()
srcPath := filepath.Join(tmpDir, "script.sh.j2")
require.NoError(t, os.WriteFile(srcPath, []byte("#!/bin/bash\necho done"), 0644))
e, mock := newTestExecutorWithMock("host1")
result, err := moduleTemplateWithClient(e, mock, map[string]any{
"src": srcPath,
"dest": "/usr/local/bin/run.sh",
"mode": "0755",
}, "host1", &Task{})
require.NoError(t, err)
assert.True(t, result.Changed)
up := mock.lastUpload()
require.NotNil(t, up)
assert.Equal(t, os.FileMode(0755), up.Mode)
}
func TestModuleTemplate_Bad_MissingSrc(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleTemplateWithClient(e, mock, map[string]any{
"dest": "/tmp/out",
}, "host1", &Task{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "src and dest required")
}
func TestModuleTemplate_Bad_MissingDest(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleTemplateWithClient(e, mock, map[string]any{
"src": "/tmp/in.j2",
}, "host1", &Task{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "src and dest required")
}
func TestModuleTemplate_Bad_SrcFileNotFound(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
_, err := moduleTemplateWithClient(e, mock, map[string]any{
"src": "/nonexistent/template.j2",
"dest": "/tmp/out",
}, "host1", &Task{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "template")
}
func TestModuleTemplate_Good_PlainTextNoVars(t *testing.T) {
tmpDir := t.TempDir()
srcPath := filepath.Join(tmpDir, "static.conf")
content := "listen 80;\nserver_name localhost;"
require.NoError(t, os.WriteFile(srcPath, []byte(content), 0644))
e, mock := newTestExecutorWithMock("host1")
result, err := moduleTemplateWithClient(e, mock, map[string]any{
"src": srcPath,
"dest": "/etc/config",
}, "host1", &Task{})
require.NoError(t, err)
assert.True(t, result.Changed)
up := mock.lastUpload()
require.NotNil(t, up)
assert.Equal(t, content, string(up.Content))
}
// --- Cross-module dispatch tests for file modules ---
func TestExecuteModuleWithMock_Good_DispatchCopy(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
task := &Task{
Module: "copy",
Args: map[string]any{
"content": "hello world",
"dest": "/tmp/hello.txt",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, 1, mock.uploadCount())
}
func TestExecuteModuleWithMock_Good_DispatchFile(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
task := &Task{
Module: "file",
Args: map[string]any{
"path": "/opt/data",
"state": "directory",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted("mkdir"))
}
func TestExecuteModuleWithMock_Good_DispatchStat(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.addStat("/etc/hosts", map[string]any{"exists": true, "isdir": false})
task := &Task{
Module: "stat",
Args: map[string]any{
"path": "/etc/hosts",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.False(t, result.Changed)
stat := result.Data["stat"].(map[string]any)
assert.Equal(t, true, stat["exists"])
}
func TestExecuteModuleWithMock_Good_DispatchLineinfile(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
task := &Task{
Module: "lineinfile",
Args: map[string]any{
"path": "/etc/hosts",
"line": "10.0.0.1 dbhost",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Good_DispatchBlockinfile(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
task := &Task{
Module: "blockinfile",
Args: map[string]any{
"path": "/etc/config",
"block": "key=value",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Good_DispatchTemplate(t *testing.T) {
tmpDir := t.TempDir()
srcPath := filepath.Join(tmpDir, "test.j2")
require.NoError(t, os.WriteFile(srcPath, []byte("static content"), 0644))
e, mock := newTestExecutorWithMock("host1")
task := &Task{
Module: "template",
Args: map[string]any{
"src": srcPath,
"dest": "/etc/out.conf",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, 1, mock.uploadCount())
}
// --- Template variable resolution integration ---
func TestModuleCopy_Good_TemplatedArgs(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
e.SetVar("deploy_path", "/opt/myapp")
task := &Task{
Module: "copy",
Args: map[string]any{
"content": "deployed",
"dest": "{{ deploy_path }}/config.yml",
},
}
// Template the args as the executor does
args := e.templateArgs(task.Args, "host1", task)
result, err := moduleCopyWithClient(e, mock, args, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
up := mock.lastUpload()
require.NotNil(t, up)
assert.Equal(t, "/opt/myapp/config.yml", up.Remote)
}
func TestModuleFile_Good_TemplatedPath(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
e.SetVar("app_dir", "/var/www/html")
task := &Task{
Module: "file",
Args: map[string]any{
"path": "{{ app_dir }}/uploads",
"state": "directory",
"owner": "www-data",
},
}
args := e.templateArgs(task.Args, "host1", task)
result, err := moduleFileWithClient(e, mock, args)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`mkdir -p "/var/www/html/uploads"`))
assert.True(t, mock.hasExecuted(`chown www-data "/var/www/html/uploads"`))
}

1261
modules_infra_test.go Normal file

File diff suppressed because it is too large Load diff

950
modules_svc_test.go Normal file
View file

@ -0,0 +1,950 @@
package ansible
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ============================================================
// Step 1.3: service / systemd / apt / apt_key / apt_repository / package / pip module tests
// ============================================================
// --- service module ---
func TestModuleService_Good_Start(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start nginx`, "Started", "", 0)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "started",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`systemctl start nginx`))
assert.Equal(t, 1, mock.commandCount())
}
func TestModuleService_Good_Stop(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl stop nginx`, "", "", 0)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "stopped",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`systemctl stop nginx`))
}
func TestModuleService_Good_Restart(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl restart docker`, "", "", 0)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "docker",
"state": "restarted",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`systemctl restart docker`))
}
func TestModuleService_Good_Reload(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl reload nginx`, "", "", 0)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "reloaded",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`systemctl reload nginx`))
}
func TestModuleService_Good_Enable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "nginx",
"enabled": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
}
func TestModuleService_Good_Disable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl disable nginx`, "", "", 0)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "nginx",
"enabled": false,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`systemctl disable nginx`))
}
func TestModuleService_Good_StartAndEnable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start nginx`, "", "", 0)
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "started",
"enabled": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.Equal(t, 2, mock.commandCount())
assert.True(t, mock.hasExecuted(`systemctl start nginx`))
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
}
func TestModuleService_Good_RestartAndDisable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl restart sshd`, "", "", 0)
mock.expectCommand(`systemctl disable sshd`, "", "", 0)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "sshd",
"state": "restarted",
"enabled": false,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.Equal(t, 2, mock.commandCount())
assert.True(t, mock.hasExecuted(`systemctl restart sshd`))
assert.True(t, mock.hasExecuted(`systemctl disable sshd`))
}
func TestModuleService_Bad_MissingName(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
_, err := moduleServiceWithClient(e, mock, map[string]any{
"state": "started",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "name required")
}
func TestModuleService_Good_NoStateNoEnabled(t *testing.T) {
// When neither state nor enabled is provided, no commands run
e, mock := newTestExecutorWithMock("host1")
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "nginx",
})
require.NoError(t, err)
assert.False(t, result.Changed)
assert.False(t, result.Failed)
assert.Equal(t, 0, mock.commandCount())
}
func TestModuleService_Good_CommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start.*`, "", "Failed to start nginx.service", 1)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "started",
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Contains(t, result.Msg, "Failed to start nginx.service")
assert.Equal(t, 1, result.RC)
}
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)
result, err := moduleServiceWithClient(e, mock, map[string]any{
"name": "nonexistent",
"state": "started",
"enabled": true,
})
require.NoError(t, err)
assert.True(t, result.Failed)
// Only the start command should have been attempted
assert.Equal(t, 1, mock.commandCount())
assert.False(t, mock.hasExecuted(`systemctl enable`))
}
// --- systemd module ---
func TestModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
mock.expectCommand(`systemctl start nginx`, "", "", 0)
result, err := moduleSystemdWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "started",
"daemon_reload": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
// daemon-reload must run first, then start
cmds := mock.executedCommands()
require.GreaterOrEqual(t, len(cmds), 2)
assert.Contains(t, cmds[0].Cmd, "daemon-reload")
assert.Contains(t, cmds[1].Cmd, "systemctl start nginx")
}
func TestModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
result, err := moduleSystemdWithClient(e, mock, map[string]any{
"name": "nginx",
"daemon_reload": true,
})
require.NoError(t, err)
// daemon-reload runs, but no state/enabled means no further commands
// Changed is false because moduleService returns Changed: len(cmds) > 0
// and no cmds were built (no state, no enabled)
assert.False(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
}
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)
result, err := moduleSystemdWithClient(e, mock, map[string]any{
"name": "docker",
"state": "restarted",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`systemctl restart docker`))
// No daemon-reload should have run
assert.False(t, mock.hasExecuted(`daemon-reload`))
}
func TestModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
mock.expectCommand(`systemctl enable myapp`, "", "", 0)
result, err := moduleSystemdWithClient(e, mock, map[string]any{
"name": "myapp",
"enabled": true,
"daemon_reload": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
assert.True(t, mock.hasExecuted(`systemctl enable myapp`))
}
// --- apt module ---
func TestModuleApt_Good_InstallPresent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq nginx`, "installed", "", 0)
result, err := moduleAptWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "present",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx`))
}
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)
result, err := moduleAptWithClient(e, mock, map[string]any{
"name": "curl",
"state": "installed",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`apt-get install -y -qq curl`))
}
func TestModuleApt_Good_RemoveAbsent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
result, err := moduleAptWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq nginx`))
}
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)
result, err := moduleAptWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "removed",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nginx`))
}
func TestModuleApt_Good_UpgradeLatest(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq --only-upgrade nginx`, "", "", 0)
result, err := moduleAptWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "latest",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade nginx`))
}
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)
result, err := moduleAptWithClient(e, mock, map[string]any{
"name": "nginx",
"state": "present",
"update_cache": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
// apt-get update must run before install
cmds := mock.executedCommands()
require.GreaterOrEqual(t, len(cmds), 2)
assert.Contains(t, cmds[0].Cmd, "apt-get update")
assert.Contains(t, cmds[1].Cmd, "apt-get install")
}
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)
result, err := moduleAptWithClient(e, mock, map[string]any{
"update_cache": true,
})
require.NoError(t, err)
// No package to install → not changed (cmd is empty)
assert.False(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`apt-get update`))
}
func TestModuleApt_Good_CommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install`, "", "E: Unable to locate package badpkg", 100)
result, err := moduleAptWithClient(e, mock, map[string]any{
"name": "badpkg",
"state": "present",
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Contains(t, result.Msg, "Unable to locate package")
assert.Equal(t, 100, result.RC)
}
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)
result, err := moduleAptWithClient(e, mock, map[string]any{
"name": "vim",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
}
// --- apt_key module ---
func TestModuleAptKey_Good_AddWithKeyring(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`curl -fsSL.*gpg --dearmor`, "", "", 0)
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
"url": "https://packages.example.com/key.gpg",
"keyring": "/etc/apt/keyrings/example.gpg",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`curl -fsSL`))
assert.True(t, mock.hasExecuted(`gpg --dearmor -o`))
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/example.gpg"))
}
func TestModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`curl -fsSL.*apt-key add -`, "", "", 0)
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
"url": "https://packages.example.com/key.gpg",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`apt-key add -`))
}
func TestModuleAptKey_Good_RemoveKey(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
"keyring": "/etc/apt/keyrings/old.gpg",
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`rm -f`))
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/old.gpg"))
}
func TestModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
// Absent with no keyring — still succeeds, just no rm command
e, mock := newTestExecutorWithMock("host1")
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.Equal(t, 0, mock.commandCount())
}
func TestModuleAptKey_Bad_MissingURL(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
_, err := moduleAptKeyWithClient(e, mock, map[string]any{
"state": "present",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "url required")
}
func TestModuleAptKey_Good_CommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`curl`, "", "curl: (22) 404 Not Found", 22)
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
"url": "https://invalid.example.com/key.gpg",
"keyring": "/etc/apt/keyrings/bad.gpg",
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Contains(t, result.Msg, "404 Not Found")
}
// --- apt_repository module ---
func TestModuleAptRepository_Good_AddRepository(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo.*sources\.list\.d`, "", "", 0)
mock.expectCommand(`apt-get update`, "", "", 0)
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
"repo": "deb https://packages.example.com/apt stable main",
"filename": "example",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/example.list"))
}
func TestModuleAptRepository_Good_RemoveRepository(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
"repo": "deb https://packages.example.com/apt stable main",
"filename": "example",
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`rm -f`))
assert.True(t, mock.containsSubstring("example.list"))
}
func TestModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
mock.expectCommand(`apt-get update`, "", "", 0)
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
"repo": "deb https://ppa.example.com/repo main",
"filename": "ppa-example",
"update_cache": true,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
// update_cache defaults to true, so apt-get update should run
assert.True(t, mock.hasExecuted(`apt-get update`))
}
func TestModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
"repo": "deb https://ppa.example.com/repo main",
"filename": "no-update",
"update_cache": false,
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
// update_cache=false, so no apt-get update
assert.False(t, mock.hasExecuted(`apt-get update`))
}
func TestModuleAptRepository_Good_CustomFilename(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
mock.expectCommand(`apt-get update`, "", "", 0)
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
"repo": "deb http://ppa.launchpad.net/test/ppa/ubuntu jammy main",
"filename": "custom-ppa",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/custom-ppa.list"))
}
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)
mock.expectCommand(`apt-get update`, "", "", 0)
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
"repo": "deb https://example.com/repo main",
})
require.NoError(t, err)
assert.True(t, result.Changed)
// Filename should be derived from repo: spaces→dashes, slashes→dashes, colons removed
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/"))
}
func TestModuleAptRepository_Bad_MissingRepo(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
_, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
"filename": "test",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "repo required")
}
func TestModuleAptRepository_Good_WriteFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "permission denied", 1)
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
"repo": "deb https://example.com/repo main",
"filename": "test",
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Contains(t, result.Msg, "permission denied")
}
// --- package module ---
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)
// Second command: the actual apt install
mock.expectCommand(`apt-get install -y -qq 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(`which apt-get`))
assert.True(t, mock.hasExecuted(`apt-get install -y -qq htop`))
}
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)
mock.expectCommand(`apt-get install -y -qq vim`, "", "", 0)
result, err := modulePackageWithClient(e, mock, map[string]any{
"name": "vim",
"state": "present",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
}
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)
result, err := modulePackageWithClient(e, mock, map[string]any{
"name": "nano",
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nano`))
}
// --- pip module ---
func TestModulePip_Good_InstallPresent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install flask`, "Successfully installed", "", 0)
result, err := modulePipWithClient(e, mock, map[string]any{
"name": "flask",
"state": "present",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`pip3 install flask`))
}
func TestModulePip_Good_UninstallAbsent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 uninstall -y flask`, "Successfully uninstalled", "", 0)
result, err := modulePipWithClient(e, mock, map[string]any{
"name": "flask",
"state": "absent",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`pip3 uninstall -y flask`))
}
func TestModulePip_Good_UpgradeLatest(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install --upgrade flask`, "Successfully installed", "", 0)
result, err := modulePipWithClient(e, mock, map[string]any{
"name": "flask",
"state": "latest",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.True(t, mock.hasExecuted(`pip3 install --upgrade flask`))
}
func TestModulePip_Good_CustomExecutable(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",
"executable": "/opt/venv/bin/pip",
})
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 TestModulePip_Good_DefaultStateIsPresent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install django`, "", "", 0)
result, err := modulePipWithClient(e, mock, map[string]any{
"name": "django",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`pip3 install django`))
}
func TestModulePip_Good_CommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install`, "", "ERROR: No matching distribution found", 1)
result, err := modulePipWithClient(e, mock, map[string]any{
"name": "nonexistent-pkg-xyz",
"state": "present",
})
require.NoError(t, err)
assert.True(t, result.Failed)
assert.Contains(t, result.Msg, "No matching distribution found")
}
func TestModulePip_Good_InstalledAlias(t *testing.T) {
// state=installed is an alias for present
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install boto3`, "", "", 0)
result, err := modulePipWithClient(e, mock, map[string]any{
"name": "boto3",
"state": "installed",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`pip3 install boto3`))
}
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)
result, err := modulePipWithClient(e, mock, map[string]any{
"name": "boto3",
"state": "removed",
})
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`pip3 uninstall -y boto3`))
}
// --- Cross-module dispatch tests ---
func TestExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl restart nginx`, "", "", 0)
task := &Task{
Module: "service",
Args: map[string]any{
"name": "nginx",
"state": "restarted",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`systemctl restart nginx`))
}
func TestExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
mock.expectCommand(`systemctl start myapp`, "", "", 0)
task := &Task{
Module: "ansible.builtin.systemd",
Args: map[string]any{
"name": "myapp",
"state": "started",
"daemon_reload": true,
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
assert.True(t, mock.hasExecuted(`systemctl start myapp`))
}
func TestExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
task := &Task{
Module: "apt",
Args: map[string]any{
"name": "nginx",
"state": "present",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`apt-get install`))
}
func TestExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`curl.*gpg`, "", "", 0)
task := &Task{
Module: "apt_key",
Args: map[string]any{
"url": "https://example.com/key.gpg",
"keyring": "/etc/apt/keyrings/example.gpg",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
mock.expectCommand(`apt-get update`, "", "", 0)
task := &Task{
Module: "apt_repository",
Args: map[string]any{
"repo": "deb https://example.com/repo main",
"filename": "example",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
}
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)
task := &Task{
Module: "package",
Args: map[string]any{
"name": "git",
"state": "present",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install ansible`, "", "", 0)
task := &Task{
Module: "pip",
Args: map[string]any{
"name": "ansible",
"state": "present",
},
}
result, err := executeModuleWithMock(e, mock, "host1", task)
require.NoError(t, err)
assert.True(t, result.Changed)
assert.True(t, mock.hasExecuted(`pip3 install ansible`))
}

510
parser.go Normal file
View file

@ -0,0 +1,510 @@
package ansible
import (
"fmt"
"iter"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"forge.lthn.ai/core/go-log"
"gopkg.in/yaml.v3"
)
// Parser handles Ansible YAML parsing.
type Parser struct {
basePath string
vars map[string]any
}
// NewParser creates a new Ansible parser.
func NewParser(basePath string) *Parser {
return &Parser{
basePath: basePath,
vars: make(map[string]any),
}
}
// ParsePlaybook parses an Ansible playbook file.
func (p *Parser) ParsePlaybook(path string) ([]Play, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read playbook: %w", err)
}
var plays []Play
if err := yaml.Unmarshal(data, &plays); err != nil {
return nil, fmt.Errorf("parse playbook: %w", err)
}
// Process each play
for i := range plays {
if err := p.processPlay(&plays[i]); err != nil {
return nil, fmt.Errorf("process play %d: %w", i, err)
}
}
return plays, nil
}
// ParsePlaybookIter returns an iterator for plays in an Ansible playbook file.
func (p *Parser) ParsePlaybookIter(path string) (iter.Seq[Play], error) {
plays, err := p.ParsePlaybook(path)
if err != nil {
return nil, err
}
return func(yield func(Play) bool) {
for _, play := range plays {
if !yield(play) {
return
}
}
}, nil
}
// ParseInventory parses an Ansible inventory file.
func (p *Parser) ParseInventory(path string) (*Inventory, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read inventory: %w", err)
}
var inv Inventory
if err := yaml.Unmarshal(data, &inv); err != nil {
return nil, fmt.Errorf("parse inventory: %w", err)
}
return &inv, nil
}
// ParseTasks parses a tasks file (used by include_tasks).
func (p *Parser) ParseTasks(path string) ([]Task, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read tasks: %w", err)
}
var tasks []Task
if err := yaml.Unmarshal(data, &tasks); err != nil {
return nil, fmt.Errorf("parse tasks: %w", err)
}
for i := range tasks {
if err := p.extractModule(&tasks[i]); err != nil {
return nil, fmt.Errorf("task %d: %w", i, err)
}
}
return tasks, nil
}
// ParseTasksIter returns an iterator for tasks in a tasks file.
func (p *Parser) ParseTasksIter(path string) (iter.Seq[Task], error) {
tasks, err := p.ParseTasks(path)
if err != nil {
return nil, err
}
return func(yield func(Task) bool) {
for _, task := range tasks {
if !yield(task) {
return
}
}
}, nil
}
// ParseRole parses a role and returns its tasks.
func (p *Parser) ParseRole(name string, tasksFrom string) ([]Task, error) {
if tasksFrom == "" {
tasksFrom = "main.yml"
}
// Search paths for roles (in order of precedence)
searchPaths := []string{
// Relative to playbook
filepath.Join(p.basePath, "roles", name, "tasks", tasksFrom),
// Parent directory roles
filepath.Join(filepath.Dir(p.basePath), "roles", name, "tasks", tasksFrom),
// Sibling roles directory
filepath.Join(p.basePath, "..", "roles", name, "tasks", tasksFrom),
// playbooks/roles pattern
filepath.Join(p.basePath, "playbooks", "roles", name, "tasks", tasksFrom),
// Common DevOps structure
filepath.Join(filepath.Dir(filepath.Dir(p.basePath)), "roles", name, "tasks", tasksFrom),
}
var tasksPath string
for _, sp := range searchPaths {
// Clean the path to resolve .. segments
sp = filepath.Clean(sp)
if _, err := os.Stat(sp); err == nil {
tasksPath = sp
break
}
}
if tasksPath == "" {
return nil, log.E("parser.ParseRole", fmt.Sprintf("role %s not found in search paths: %v", name, searchPaths), nil)
}
// Load role defaults
defaultsPath := filepath.Join(filepath.Dir(filepath.Dir(tasksPath)), "defaults", "main.yml")
if data, err := os.ReadFile(defaultsPath); err == nil {
var defaults map[string]any
if yaml.Unmarshal(data, &defaults) == nil {
for k, v := range defaults {
if _, exists := p.vars[k]; !exists {
p.vars[k] = v
}
}
}
}
// Load role vars
varsPath := filepath.Join(filepath.Dir(filepath.Dir(tasksPath)), "vars", "main.yml")
if data, err := os.ReadFile(varsPath); err == nil {
var roleVars map[string]any
if yaml.Unmarshal(data, &roleVars) == nil {
for k, v := range roleVars {
p.vars[k] = v
}
}
}
return p.ParseTasks(tasksPath)
}
// processPlay processes a play and extracts modules from tasks.
func (p *Parser) processPlay(play *Play) error {
// Merge play vars
for k, v := range play.Vars {
p.vars[k] = v
}
for i := range play.PreTasks {
if err := p.extractModule(&play.PreTasks[i]); err != nil {
return fmt.Errorf("pre_task %d: %w", i, err)
}
}
for i := range play.Tasks {
if err := p.extractModule(&play.Tasks[i]); err != nil {
return fmt.Errorf("task %d: %w", i, err)
}
}
for i := range play.PostTasks {
if err := p.extractModule(&play.PostTasks[i]); err != nil {
return fmt.Errorf("post_task %d: %w", i, err)
}
}
for i := range play.Handlers {
if err := p.extractModule(&play.Handlers[i]); err != nil {
return fmt.Errorf("handler %d: %w", i, err)
}
}
return nil
}
// extractModule extracts the module name and args from a task.
func (p *Parser) extractModule(task *Task) error {
// First, unmarshal the raw YAML to get all keys
// This is a workaround since we need to find the module key dynamically
// Handle block tasks
for i := range task.Block {
if err := p.extractModule(&task.Block[i]); err != nil {
return err
}
}
for i := range task.Rescue {
if err := p.extractModule(&task.Rescue[i]); err != nil {
return err
}
}
for i := range task.Always {
if err := p.extractModule(&task.Always[i]); err != nil {
return err
}
}
return nil
}
// UnmarshalYAML implements custom YAML unmarshaling for Task.
func (t *Task) UnmarshalYAML(node *yaml.Node) error {
// First decode known fields
type rawTask Task
var raw rawTask
// Create a map to capture all fields
var m map[string]any
if err := node.Decode(&m); err != nil {
return err
}
// Decode into struct
if err := node.Decode(&raw); err != nil {
return err
}
*t = Task(raw)
t.raw = m
// Find the module key
knownKeys := map[string]bool{
"name": true, "register": true, "when": true, "loop": true,
"loop_control": true, "vars": true, "environment": true,
"changed_when": true, "failed_when": true, "ignore_errors": true,
"no_log": true, "become": true, "become_user": true,
"delegate_to": true, "run_once": true, "tags": true,
"block": true, "rescue": true, "always": true, "notify": true,
"retries": true, "delay": true, "until": true,
"include_tasks": true, "import_tasks": true,
"include_role": true, "import_role": true,
"with_items": true, "with_dict": true, "with_file": true,
}
for key, val := range m {
if knownKeys[key] {
continue
}
// Check if this is a module
if isModule(key) {
t.Module = key
t.Args = make(map[string]any)
switch v := val.(type) {
case string:
// Free-form args (e.g., shell: echo hello)
t.Args["_raw_params"] = v
case map[string]any:
t.Args = v
case nil:
// Module with no args
default:
t.Args["_raw_params"] = v
}
break
}
}
// Handle with_items as loop
if items, ok := m["with_items"]; ok && t.Loop == nil {
t.Loop = items
}
return nil
}
// isModule checks if a key is a known module.
func isModule(key string) bool {
for _, m := range KnownModules {
if key == m {
return true
}
// Also check without ansible.builtin. prefix
if strings.HasPrefix(m, "ansible.builtin.") {
if key == strings.TrimPrefix(m, "ansible.builtin.") {
return true
}
}
}
// Accept any key with dots (likely a module)
return strings.Contains(key, ".")
}
// NormalizeModule normalizes a module name to its canonical form.
func NormalizeModule(name string) string {
// Add ansible.builtin. prefix if missing
if !strings.Contains(name, ".") {
return "ansible.builtin." + name
}
return name
}
// GetHosts returns hosts matching a pattern from inventory.
func GetHosts(inv *Inventory, pattern string) []string {
if pattern == "all" {
return getAllHosts(inv.All)
}
if pattern == "localhost" {
return []string{"localhost"}
}
// Check if it's a group name
hosts := getGroupHosts(inv.All, pattern)
if len(hosts) > 0 {
return hosts
}
// Check if it's a specific host
if hasHost(inv.All, pattern) {
return []string{pattern}
}
// Handle patterns with : (intersection/union)
// For now, just return empty
return nil
}
// GetHostsIter returns an iterator for hosts matching a pattern from inventory.
func GetHostsIter(inv *Inventory, pattern string) iter.Seq[string] {
hosts := GetHosts(inv, pattern)
return func(yield func(string) bool) {
for _, host := range hosts {
if !yield(host) {
return
}
}
}
}
func getAllHosts(group *InventoryGroup) []string {
if group == nil {
return nil
}
var hosts []string
for name := range group.Hosts {
hosts = append(hosts, name)
}
for _, child := range group.Children {
hosts = append(hosts, getAllHosts(child)...)
}
return hosts
}
// AllHostsIter returns an iterator for all hosts in an inventory group.
func AllHostsIter(group *InventoryGroup) iter.Seq[string] {
return func(yield func(string) bool) {
if group == nil {
return
}
// Sort keys for deterministic iteration
keys := slices.Sorted(maps.Keys(group.Hosts))
for _, name := range keys {
if !yield(name) {
return
}
}
// Sort children keys for deterministic iteration
childKeys := slices.Sorted(maps.Keys(group.Children))
for _, name := range childKeys {
child := group.Children[name]
for host := range AllHostsIter(child) {
if !yield(host) {
return
}
}
}
}
}
func getGroupHosts(group *InventoryGroup, name string) []string {
if group == nil {
return nil
}
// Check children for the group name
if child, ok := group.Children[name]; ok {
return getAllHosts(child)
}
// Recurse
for _, child := range group.Children {
if hosts := getGroupHosts(child, name); len(hosts) > 0 {
return hosts
}
}
return nil
}
func hasHost(group *InventoryGroup, name string) bool {
if group == nil {
return false
}
if _, ok := group.Hosts[name]; ok {
return true
}
for _, child := range group.Children {
if hasHost(child, name) {
return true
}
}
return false
}
// GetHostVars returns variables for a specific host.
func GetHostVars(inv *Inventory, hostname string) map[string]any {
vars := make(map[string]any)
// Collect vars from all levels
collectHostVars(inv.All, hostname, vars)
return vars
}
func collectHostVars(group *InventoryGroup, hostname string, vars map[string]any) bool {
if group == nil {
return false
}
// Check if host is in this group
found := false
if host, ok := group.Hosts[hostname]; ok {
found = true
// Apply group vars first
for k, v := range group.Vars {
vars[k] = v
}
// Then host vars
if host != nil {
if host.AnsibleHost != "" {
vars["ansible_host"] = host.AnsibleHost
}
if host.AnsiblePort != 0 {
vars["ansible_port"] = host.AnsiblePort
}
if host.AnsibleUser != "" {
vars["ansible_user"] = host.AnsibleUser
}
if host.AnsiblePassword != "" {
vars["ansible_password"] = host.AnsiblePassword
}
if host.AnsibleSSHPrivateKeyFile != "" {
vars["ansible_ssh_private_key_file"] = host.AnsibleSSHPrivateKeyFile
}
if host.AnsibleConnection != "" {
vars["ansible_connection"] = host.AnsibleConnection
}
for k, v := range host.Vars {
vars[k] = v
}
}
}
// Check children
for _, child := range group.Children {
if collectHostVars(child, hostname, vars) {
// Apply this group's vars (parent vars)
for k, v := range group.Vars {
if _, exists := vars[k]; !exists {
vars[k] = v
}
}
found = true
}
}
return found
}

777
parser_test.go Normal file
View file

@ -0,0 +1,777 @@
package ansible
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- ParsePlaybook ---
func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Configure webserver
hosts: webservers
become: true
tasks:
- name: Install nginx
apt:
name: nginx
state: present
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Equal(t, "Configure webserver", plays[0].Name)
assert.Equal(t, "webservers", plays[0].Hosts)
assert.True(t, plays[0].Become)
require.Len(t, plays[0].Tasks, 1)
assert.Equal(t, "Install nginx", plays[0].Tasks[0].Name)
assert.Equal(t, "apt", plays[0].Tasks[0].Module)
assert.Equal(t, "nginx", plays[0].Tasks[0].Args["name"])
assert.Equal(t, "present", plays[0].Tasks[0].Args["state"])
}
func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Play one
hosts: all
tasks:
- name: Say hello
debug:
msg: "Hello"
- name: Play two
hosts: localhost
connection: local
tasks:
- name: Say goodbye
debug:
msg: "Goodbye"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 2)
assert.Equal(t, "Play one", plays[0].Name)
assert.Equal(t, "all", plays[0].Hosts)
assert.Equal(t, "Play two", plays[1].Name)
assert.Equal(t, "localhost", plays[1].Hosts)
assert.Equal(t, "local", plays[1].Connection)
}
func TestParsePlaybook_Good_WithVars(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With vars
hosts: all
vars:
http_port: 8080
app_name: myapp
tasks:
- name: Print port
debug:
msg: "Port is {{ http_port }}"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Equal(t, 8080, plays[0].Vars["http_port"])
assert.Equal(t, "myapp", plays[0].Vars["app_name"])
}
func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Full lifecycle
hosts: all
pre_tasks:
- name: Pre task
debug:
msg: "pre"
tasks:
- name: Main task
debug:
msg: "main"
post_tasks:
- name: Post task
debug:
msg: "post"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Len(t, plays[0].PreTasks, 1)
assert.Len(t, plays[0].Tasks, 1)
assert.Len(t, plays[0].PostTasks, 1)
assert.Equal(t, "Pre task", plays[0].PreTasks[0].Name)
assert.Equal(t, "Main task", plays[0].Tasks[0].Name)
assert.Equal(t, "Post task", plays[0].PostTasks[0].Name)
}
func TestParsePlaybook_Good_Handlers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With handlers
hosts: all
tasks:
- name: Install package
apt:
name: nginx
notify: restart nginx
handlers:
- name: restart nginx
service:
name: nginx
state: restarted
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays, 1)
assert.Len(t, plays[0].Handlers, 1)
assert.Equal(t, "restart nginx", plays[0].Handlers[0].Name)
assert.Equal(t, "service", plays[0].Handlers[0].Module)
}
func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Shell tasks
hosts: all
tasks:
- name: Run a command
shell: echo hello world
- name: Run raw command
command: ls -la /tmp
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays[0].Tasks, 2)
assert.Equal(t, "shell", plays[0].Tasks[0].Module)
assert.Equal(t, "echo hello world", plays[0].Tasks[0].Args["_raw_params"])
assert.Equal(t, "command", plays[0].Tasks[1].Module)
assert.Equal(t, "ls -la /tmp", plays[0].Tasks[1].Args["_raw_params"])
}
func TestParsePlaybook_Good_WithTags(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Tagged play
hosts: all
tags:
- setup
tasks:
- name: Tagged task
debug:
msg: "tagged"
tags:
- debug
- always
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
assert.Equal(t, []string{"setup"}, plays[0].Tags)
assert.Equal(t, []string{"debug", "always"}, plays[0].Tasks[0].Tags)
}
func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With blocks
hosts: all
tasks:
- name: Protected block
block:
- name: Try this
shell: echo try
rescue:
- name: Handle error
debug:
msg: "rescued"
always:
- name: Always runs
debug:
msg: "always"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
task := plays[0].Tasks[0]
assert.Len(t, task.Block, 1)
assert.Len(t, task.Rescue, 1)
assert.Len(t, task.Always, 1)
assert.Equal(t, "Try this", task.Block[0].Name)
assert.Equal(t, "Handle error", task.Rescue[0].Name)
assert.Equal(t, "Always runs", task.Always[0].Name)
}
func TestParsePlaybook_Good_WithLoop(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Loop test
hosts: all
tasks:
- name: Install packages
apt:
name: "{{ item }}"
state: present
loop:
- vim
- curl
- git
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
task := plays[0].Tasks[0]
assert.Equal(t, "apt", task.Module)
items, ok := task.Loop.([]any)
require.True(t, ok)
assert.Len(t, items, 3)
}
func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: With roles
hosts: all
roles:
- common
- role: webserver
vars:
http_port: 80
tags:
- web
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.Len(t, plays[0].Roles, 2)
assert.Equal(t, "common", plays[0].Roles[0].Role)
assert.Equal(t, "webserver", plays[0].Roles[1].Role)
assert.Equal(t, 80, plays[0].Roles[1].Vars["http_port"])
assert.Equal(t, []string{"web"}, plays[0].Roles[1].Tags)
}
func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: FQCN modules
hosts: all
tasks:
- name: Copy file
ansible.builtin.copy:
src: /tmp/foo
dest: /tmp/bar
- name: Run shell
ansible.builtin.shell: echo hello
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
assert.Equal(t, "ansible.builtin.copy", plays[0].Tasks[0].Module)
assert.Equal(t, "/tmp/foo", plays[0].Tasks[0].Args["src"])
assert.Equal(t, "ansible.builtin.shell", plays[0].Tasks[1].Module)
assert.Equal(t, "echo hello", plays[0].Tasks[1].Args["_raw_params"])
}
func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: Conditional play
hosts: all
tasks:
- name: Check file
stat:
path: /etc/nginx/nginx.conf
register: nginx_conf
- name: Show result
debug:
msg: "File exists"
when: nginx_conf.stat.exists
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
assert.Equal(t, "nginx_conf", plays[0].Tasks[0].Register)
assert.NotNil(t, plays[0].Tasks[1].When)
}
func TestParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
require.NoError(t, os.WriteFile(path, []byte("---\n[]"), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
assert.Empty(t, plays)
}
func TestParsePlaybook_Bad_InvalidYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yml")
require.NoError(t, os.WriteFile(path, []byte("{{invalid yaml}}"), 0644))
p := NewParser(dir)
_, err := p.ParsePlaybook(path)
assert.Error(t, err)
assert.Contains(t, err.Error(), "parse playbook")
}
func TestParsePlaybook_Bad_FileNotFound(t *testing.T) {
p := NewParser(t.TempDir())
_, err := p.ParsePlaybook("/nonexistent/playbook.yml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "read playbook")
}
func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
yaml := `---
- name: No facts
hosts: all
gather_facts: false
tasks: []
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
require.NoError(t, err)
require.NotNil(t, plays[0].GatherFacts)
assert.False(t, *plays[0].GatherFacts)
}
// --- ParseInventory ---
func TestParseInventory_Good_SimpleInventory(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
yaml := `---
all:
hosts:
web1:
ansible_host: 192.168.1.10
web2:
ansible_host: 192.168.1.11
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
require.NoError(t, err)
require.NotNil(t, inv.All)
assert.Len(t, inv.All.Hosts, 2)
assert.Equal(t, "192.168.1.10", inv.All.Hosts["web1"].AnsibleHost)
assert.Equal(t, "192.168.1.11", inv.All.Hosts["web2"].AnsibleHost)
}
func TestParseInventory_Good_WithGroups(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
yaml := `---
all:
children:
webservers:
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, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
require.NoError(t, err)
require.NotNil(t, inv.All.Children["webservers"])
assert.Len(t, inv.All.Children["webservers"].Hosts, 2)
require.NotNil(t, inv.All.Children["databases"])
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
}
func TestParseInventory_Good_WithVars(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
yaml := `---
all:
vars:
ansible_user: admin
children:
production:
vars:
env: prod
hosts:
prod1:
ansible_host: 10.0.0.1
ansible_port: 2222
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
require.NoError(t, err)
assert.Equal(t, "admin", inv.All.Vars["ansible_user"])
assert.Equal(t, "prod", inv.All.Children["production"].Vars["env"])
assert.Equal(t, 2222, inv.All.Children["production"].Hosts["prod1"].AnsiblePort)
}
func TestParseInventory_Bad_InvalidYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yml")
require.NoError(t, os.WriteFile(path, []byte("{{{bad"), 0644))
p := NewParser(dir)
_, err := p.ParseInventory(path)
assert.Error(t, err)
assert.Contains(t, err.Error(), "parse inventory")
}
func TestParseInventory_Bad_FileNotFound(t *testing.T) {
p := NewParser(t.TempDir())
_, err := p.ParseInventory("/nonexistent/inventory.yml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "read inventory")
}
// --- ParseTasks ---
func TestParseTasks_Good_TaskFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.yml")
yaml := `---
- name: First task
shell: echo first
- name: Second task
copy:
src: /tmp/a
dest: /tmp/b
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
p := NewParser(dir)
tasks, err := p.ParseTasks(path)
require.NoError(t, err)
require.Len(t, tasks, 2)
assert.Equal(t, "shell", tasks[0].Module)
assert.Equal(t, "echo first", tasks[0].Args["_raw_params"])
assert.Equal(t, "copy", tasks[1].Module)
assert.Equal(t, "/tmp/a", tasks[1].Args["src"])
}
func TestParseTasks_Bad_InvalidYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yml")
require.NoError(t, os.WriteFile(path, []byte("not: [valid: tasks"), 0644))
p := NewParser(dir)
_, err := p.ParseTasks(path)
assert.Error(t, err)
}
// --- GetHosts ---
func TestGetHosts_Good_AllPattern(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
"host1": {},
"host2": {},
},
},
}
hosts := GetHosts(inv, "all")
assert.Len(t, hosts, 2)
assert.Contains(t, hosts, "host1")
assert.Contains(t, hosts, "host2")
}
func TestGetHosts_Good_LocalhostPattern(t *testing.T) {
inv := &Inventory{All: &InventoryGroup{}}
hosts := GetHosts(inv, "localhost")
assert.Equal(t, []string{"localhost"}, hosts)
}
func TestGetHosts_Good_GroupPattern(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Children: map[string]*InventoryGroup{
"web": {
Hosts: map[string]*Host{
"web1": {},
"web2": {},
},
},
"db": {
Hosts: map[string]*Host{
"db1": {},
},
},
},
},
}
hosts := GetHosts(inv, "web")
assert.Len(t, hosts, 2)
assert.Contains(t, hosts, "web1")
assert.Contains(t, hosts, "web2")
}
func TestGetHosts_Good_SpecificHost(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Children: map[string]*InventoryGroup{
"servers": {
Hosts: map[string]*Host{
"myhost": {},
},
},
},
},
}
hosts := GetHosts(inv, "myhost")
assert.Equal(t, []string{"myhost"}, hosts)
}
func TestGetHosts_Good_AllIncludesChildren(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"top": {}},
Children: map[string]*InventoryGroup{
"group1": {
Hosts: map[string]*Host{"child1": {}},
},
},
},
}
hosts := GetHosts(inv, "all")
assert.Len(t, hosts, 2)
assert.Contains(t, hosts, "top")
assert.Contains(t, hosts, "child1")
}
func TestGetHosts_Bad_NoMatch(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"host1": {}},
},
}
hosts := GetHosts(inv, "nonexistent")
assert.Empty(t, hosts)
}
func TestGetHosts_Bad_NilGroup(t *testing.T) {
inv := &Inventory{All: nil}
hosts := GetHosts(inv, "all")
assert.Empty(t, hosts)
}
// --- GetHostVars ---
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",
},
},
},
}
vars := GetHostVars(inv, "myhost")
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, "global", vars["global_var"])
}
func TestGetHostVars_Good_InheritedGroupVars(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Vars: map[string]any{"level": "all"},
Children: map[string]*InventoryGroup{
"production": {
Vars: map[string]any{"env": "prod", "level": "group"},
Hosts: map[string]*Host{
"prod1": {
AnsibleHost: "10.0.0.1",
},
},
},
},
},
}
vars := GetHostVars(inv, "prod1")
assert.Equal(t, "10.0.0.1", vars["ansible_host"])
assert.Equal(t, "prod", vars["env"])
}
func TestGetHostVars_Good_HostNotFound(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"other": {}},
},
}
vars := GetHostVars(inv, "nonexistent")
assert.Empty(t, vars)
}
// --- isModule ---
func TestIsModule_Good_KnownModules(t *testing.T) {
assert.True(t, isModule("shell"))
assert.True(t, isModule("command"))
assert.True(t, isModule("copy"))
assert.True(t, isModule("file"))
assert.True(t, isModule("apt"))
assert.True(t, isModule("service"))
assert.True(t, isModule("systemd"))
assert.True(t, isModule("debug"))
assert.True(t, isModule("set_fact"))
}
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"))
}
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 TestIsModule_Bad_NotAModule(t *testing.T) {
assert.False(t, isModule("some_random_key"))
assert.False(t, isModule("foobar"))
}
// --- NormalizeModule ---
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"))
}
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"))
}
// --- NewParser ---
func TestNewParser_Good(t *testing.T) {
p := NewParser("/some/path")
assert.NotNil(t, p)
assert.Equal(t, "/some/path", p.basePath)
assert.NotNil(t, p.vars)
}

451
ssh.go Normal file
View file

@ -0,0 +1,451 @@
package ansible
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
"forge.lthn.ai/core/go-log"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// SSHClient handles SSH connections to remote hosts.
type SSHClient struct {
host string
port int
user string
password string
keyFile string
client *ssh.Client
mu sync.Mutex
become bool
becomeUser string
becomePass string
timeout time.Duration
}
// SSHConfig holds SSH connection configuration.
type SSHConfig struct {
Host string
Port int
User string
Password string
KeyFile string
Become bool
BecomeUser string
BecomePass string
Timeout time.Duration
}
// NewSSHClient creates a new SSH client.
func NewSSHClient(cfg SSHConfig) (*SSHClient, error) {
if cfg.Port == 0 {
cfg.Port = 22
}
if cfg.User == "" {
cfg.User = "root"
}
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
client := &SSHClient{
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.
func (c *SSHClient) Connect(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil {
return nil
}
var authMethods []ssh.AuthMethod
// Try key-based auth first
if c.keyFile != "" {
keyPath := c.keyFile
if strings.HasPrefix(keyPath, "~") {
home, _ := os.UserHomeDir()
keyPath = filepath.Join(home, keyPath[1:])
}
if key, err := os.ReadFile(keyPath); err == nil {
if signer, err := ssh.ParsePrivateKey(key); err == nil {
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
}
}
// Try default SSH keys
if len(authMethods) == 0 {
home, _ := os.UserHomeDir()
defaultKeys := []string{
filepath.Join(home, ".ssh", "id_ed25519"),
filepath.Join(home, ".ssh", "id_rsa"),
}
for _, keyPath := range defaultKeys {
if key, err := os.ReadFile(keyPath); err == nil {
if signer, err := ssh.ParsePrivateKey(key); err == nil {
authMethods = append(authMethods, ssh.PublicKeys(signer))
break
}
}
}
}
// Fall back to password auth
if c.password != "" {
authMethods = append(authMethods, ssh.Password(c.password))
authMethods = append(authMethods, ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
answers[i] = c.password
}
return answers, nil
}))
}
if len(authMethods) == 0 {
return log.E("ssh.Connect", "no authentication method available", nil)
}
// Host key verification
var hostKeyCallback ssh.HostKeyCallback
home, err := os.UserHomeDir()
if err != nil {
return log.E("ssh.Connect", "failed to get user home dir", err)
}
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
// Ensure known_hosts file exists
if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0700); err != nil {
return log.E("ssh.Connect", "failed to create .ssh dir", err)
}
if err := os.WriteFile(knownHostsPath, nil, 0600); err != nil {
return log.E("ssh.Connect", "failed to create known_hosts file", err)
}
}
cb, err := knownhosts.New(knownHostsPath)
if err != nil {
return log.E("ssh.Connect", "failed to load known_hosts", err)
}
hostKeyCallback = cb
config := &ssh.ClientConfig{
User: c.user,
Auth: authMethods,
HostKeyCallback: hostKeyCallback,
Timeout: c.timeout,
}
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 log.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 log.E("ssh.Connect", fmt.Sprintf("ssh connect %s", addr), err)
}
c.client = ssh.NewClient(sshConn, chans, reqs)
return nil
}
// Close closes the SSH connection.
func (c *SSHClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil {
err := c.client.Close()
c.client = nil
return err
}
return nil
}
// Run executes a command on the remote host.
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
}
session, err := c.client.NewSession()
if err != nil {
return "", "", -1, log.E("ssh.Run", "new session", err)
}
defer func() { _ = session.Close() }()
var stdoutBuf, stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
// Apply become if needed
if c.become {
becomeUser := c.becomeUser
if becomeUser == "" {
becomeUser = "root"
}
// Escape single quotes in the command
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 = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
stdin, err := session.StdinPipe()
if err != nil {
return "", "", -1, log.E("ssh.Run", "stdin pipe", err)
}
go func() {
defer func() { _ = stdin.Close() }()
_, _ = io.WriteString(stdin, c.becomePass+"\n")
}()
} else if c.password != "" {
// Try using connection password for sudo
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
stdin, err := session.StdinPipe()
if err != nil {
return "", "", -1, log.E("ssh.Run", "stdin pipe", err)
}
go func() {
defer func() { _ = stdin.Close() }()
_, _ = io.WriteString(stdin, c.password+"\n")
}()
} else {
// Try passwordless sudo
cmd = fmt.Sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
}
}
// Run with context
done := make(chan error, 1)
go func() {
done <- session.Run(cmd)
}()
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGKILL)
return "", "", -1, ctx.Err()
case err := <-done:
exitCode = 0
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
exitCode = exitErr.ExitStatus()
} else {
return stdoutBuf.String(), stderrBuf.String(), -1, err
}
}
return stdoutBuf.String(), stderrBuf.String(), exitCode, nil
}
}
// RunScript runs a script on the remote host.
func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
// Escape the script for heredoc
cmd := fmt.Sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
return c.Run(ctx, cmd)
}
// Upload copies a file to the remote host.
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 := io.ReadAll(local)
if err != nil {
return log.E("ssh.Upload", "read content", err)
}
// Create parent directory
dir := filepath.Dir(remote)
dirCmd := fmt.Sprintf("mkdir -p %q", dir)
if c.become {
dirCmd = fmt.Sprintf("sudo mkdir -p %q", dir)
}
if _, _, _, err := c.Run(ctx, dirCmd); err != nil {
return log.E("ssh.Upload", "create parent dir", err)
}
// Use cat to write the file (simpler than SCP)
writeCmd := fmt.Sprintf("cat > %q && chmod %o %q", remote, mode, remote)
// 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.
// We'll use a session with piped stdin.
session2, err := c.client.NewSession()
if err != nil {
return log.E("ssh.Upload", "new session for write", err)
}
defer func() { _ = session2.Close() }()
stdin, err := session2.StdinPipe()
if err != nil {
return log.E("ssh.Upload", "stdin pipe", err)
}
var stderrBuf bytes.Buffer
session2.Stderr = &stderrBuf
if c.become {
becomeUser := c.becomeUser
if becomeUser == "" {
becomeUser = "root"
}
pass := c.becomePass
if pass == "" {
pass = c.password
}
if pass != "" {
// Use sudo -S with password from stdin
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 = fmt.Sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
becomeUser, remote, mode, remote)
}
if err := session2.Start(writeCmd); err != nil {
return log.E("ssh.Upload", "start write", err)
}
go func() {
defer func() { _ = stdin.Close() }()
if pass != "" {
_, _ = io.WriteString(stdin, pass+"\n")
}
_, _ = stdin.Write(content)
}()
} else {
// Normal write
if err := session2.Start(writeCmd); err != nil {
return log.E("ssh.Upload", "start write", err)
}
go func() {
defer func() { _ = stdin.Close() }()
_, _ = stdin.Write(content)
}()
}
if err := session2.Wait(); err != nil {
return log.E("ssh.Upload", fmt.Sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
}
return nil
}
// Download copies a file from the remote host.
func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error) {
if err := c.Connect(ctx); err != nil {
return nil, err
}
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, log.E("ssh.Download", fmt.Sprintf("cat failed: %s", stderr), nil)
}
return []byte(stdout), nil
}
// FileExists checks if a file exists on the remote host.
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
cmd := fmt.Sprintf("test -e %q && echo yes || echo no", path)
stdout, _, exitCode, err := c.Run(ctx, cmd)
if err != nil {
return false, err
}
if exitCode != 0 {
// test command failed but didn't error - file doesn't exist
return false, nil
}
return strings.TrimSpace(stdout) == "yes", nil
}
// Stat returns file info from the remote host.
func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error) {
// Simple approach - get basic file info
cmd := fmt.Sprintf(`
if [ -e %q ]; then
if [ -d %q ]; then
echo "exists=true isdir=true"
else
echo "exists=true isdir=false"
fi
else
echo "exists=false"
fi
`, path, path)
stdout, _, _, err := c.Run(ctx, cmd)
if err != nil {
return nil, err
}
result := make(map[string]any)
parts := strings.Fields(strings.TrimSpace(stdout))
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
if len(kv) == 2 {
result[kv[0]] = kv[1] == "true"
}
}
return result, nil
}
// SetBecome enables privilege escalation.
func (c *SSHClient) SetBecome(become bool, user, password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.become = become
if user != "" {
c.becomeUser = user
}
if password != "" {
c.becomePass = password
}
}

36
ssh_test.go Normal file
View file

@ -0,0 +1,36 @@
package ansible
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestNewSSHClient(t *testing.T) {
cfg := SSHConfig{
Host: "localhost",
Port: 2222,
User: "root",
}
client, err := NewSSHClient(cfg)
assert.NoError(t, err)
assert.NotNil(t, client)
assert.Equal(t, "localhost", client.host)
assert.Equal(t, 2222, client.port)
assert.Equal(t, "root", client.user)
assert.Equal(t, 30*time.Second, client.timeout)
}
func TestSSHConfig_Defaults(t *testing.T) {
cfg := SSHConfig{
Host: "localhost",
}
client, err := NewSSHClient(cfg)
assert.NoError(t, err)
assert.Equal(t, 22, client.port)
assert.Equal(t, "root", client.user)
assert.Equal(t, 30*time.Second, client.timeout)
}

258
types.go Normal file
View file

@ -0,0 +1,258 @@
package ansible
import (
"time"
)
// Playbook represents an Ansible playbook.
type Playbook struct {
Plays []Play `yaml:",inline"`
}
// Play represents a single play in a playbook.
type Play struct {
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.
type RoleRef struct {
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.
func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
// Try string first
var s string
if err := unmarshal(&s); err == nil {
r.Role = s
return nil
}
// Try struct
type rawRoleRef RoleRef
var raw rawRoleRef
if err := unmarshal(&raw); err != nil {
return err
}
*r = RoleRef(raw)
if r.Role == "" && r.Name != "" {
r.Role = r.Name
}
return nil
}
// Task represents an Ansible task.
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
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"`
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 behavior.
type LoopControl struct {
LoopVar string `yaml:"loop_var,omitempty"`
IndexVar string `yaml:"index_var,omitempty"`
Label string `yaml:"label,omitempty"`
Pause int `yaml:"pause,omitempty"`
Extended bool `yaml:"extended,omitempty"`
}
// TaskResult holds the result of executing a task.
type TaskResult struct {
Changed bool `json:"changed"`
Failed bool `json:"failed"`
Skipped bool `json:"skipped"`
Msg string `json:"msg,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
RC int `json:"rc,omitempty"`
Results []TaskResult `json:"results,omitempty"` // For loops
Data map[string]any `json:"data,omitempty"` // Module-specific data
Duration time.Duration `json:"duration,omitempty"`
}
// Inventory represents Ansible inventory.
type Inventory struct {
All *InventoryGroup `yaml:"all"`
}
// InventoryGroup represents a group in inventory.
type InventoryGroup struct {
Hosts map[string]*Host `yaml:"hosts,omitempty"`
Children map[string]*InventoryGroup `yaml:"children,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
}
// Host represents a host in inventory.
type Host struct {
AnsibleHost string `yaml:"ansible_host,omitempty"`
AnsiblePort int `yaml:"ansible_port,omitempty"`
AnsibleUser string `yaml:"ansible_user,omitempty"`
AnsiblePassword string `yaml:"ansible_password,omitempty"`
AnsibleSSHPrivateKeyFile string `yaml:"ansible_ssh_private_key_file,omitempty"`
AnsibleConnection string `yaml:"ansible_connection,omitempty"`
AnsibleBecomePassword string `yaml:"ansible_become_password,omitempty"`
// Custom vars
Vars map[string]any `yaml:",inline"`
}
// Facts holds gathered facts about a host.
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"`
Memory int64 `json:"ansible_memtotal_mb"`
CPUs int `json:"ansible_processor_vcpus"`
IPv4 string `json:"ansible_default_ipv4_address"`
}
// Known Ansible modules
var KnownModules = []string{
// Builtin
"ansible.builtin.shell",
"ansible.builtin.command",
"ansible.builtin.raw",
"ansible.builtin.script",
"ansible.builtin.copy",
"ansible.builtin.template",
"ansible.builtin.file",
"ansible.builtin.lineinfile",
"ansible.builtin.blockinfile",
"ansible.builtin.stat",
"ansible.builtin.slurp",
"ansible.builtin.fetch",
"ansible.builtin.get_url",
"ansible.builtin.uri",
"ansible.builtin.apt",
"ansible.builtin.apt_key",
"ansible.builtin.apt_repository",
"ansible.builtin.yum",
"ansible.builtin.dnf",
"ansible.builtin.package",
"ansible.builtin.pip",
"ansible.builtin.service",
"ansible.builtin.systemd",
"ansible.builtin.user",
"ansible.builtin.group",
"ansible.builtin.cron",
"ansible.builtin.git",
"ansible.builtin.unarchive",
"ansible.builtin.archive",
"ansible.builtin.debug",
"ansible.builtin.fail",
"ansible.builtin.assert",
"ansible.builtin.pause",
"ansible.builtin.wait_for",
"ansible.builtin.set_fact",
"ansible.builtin.include_vars",
"ansible.builtin.add_host",
"ansible.builtin.group_by",
"ansible.builtin.meta",
"ansible.builtin.setup",
// Short forms (legacy)
"shell",
"command",
"raw",
"script",
"copy",
"template",
"file",
"lineinfile",
"blockinfile",
"stat",
"slurp",
"fetch",
"get_url",
"uri",
"apt",
"apt_key",
"apt_repository",
"yum",
"dnf",
"package",
"pip",
"service",
"systemd",
"user",
"group",
"cron",
"git",
"unarchive",
"archive",
"debug",
"fail",
"assert",
"pause",
"wait_for",
"set_fact",
"include_vars",
"add_host",
"group_by",
"meta",
"setup",
}

402
types_test.go Normal file
View file

@ -0,0 +1,402 @@
package ansible
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
// --- RoleRef UnmarshalYAML ---
func TestRoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
input := `common`
var ref RoleRef
err := yaml.Unmarshal([]byte(input), &ref)
require.NoError(t, err)
assert.Equal(t, "common", ref.Role)
}
func TestRoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
input := `
role: webserver
vars:
http_port: 80
tags:
- web
`
var ref RoleRef
err := yaml.Unmarshal([]byte(input), &ref)
require.NoError(t, err)
assert.Equal(t, "webserver", ref.Role)
assert.Equal(t, 80, ref.Vars["http_port"])
assert.Equal(t, []string{"web"}, ref.Tags)
}
func TestRoleRef_UnmarshalYAML_Good_NameField(t *testing.T) {
// Some playbooks use "name:" instead of "role:"
input := `
name: myapp
tasks_from: install.yml
`
var ref RoleRef
err := yaml.Unmarshal([]byte(input), &ref)
require.NoError(t, err)
assert.Equal(t, "myapp", ref.Role) // Name is copied to Role
assert.Equal(t, "install.yml", ref.TasksFrom)
}
func TestRoleRef_UnmarshalYAML_Good_WithWhen(t *testing.T) {
input := `
role: conditional_role
when: ansible_os_family == "Debian"
`
var ref RoleRef
err := yaml.Unmarshal([]byte(input), &ref)
require.NoError(t, err)
assert.Equal(t, "conditional_role", ref.Role)
assert.NotNil(t, ref.When)
}
// --- Task UnmarshalYAML ---
func TestTask_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
input := `
name: Install nginx
apt:
name: nginx
state: present
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "Install nginx", task.Name)
assert.Equal(t, "apt", task.Module)
assert.Equal(t, "nginx", task.Args["name"])
assert.Equal(t, "present", task.Args["state"])
}
func TestTask_UnmarshalYAML_Good_FreeFormModule(t *testing.T) {
input := `
name: Run command
shell: echo hello world
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "shell", task.Module)
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
}
func TestTask_UnmarshalYAML_Good_ModuleNoArgs(t *testing.T) {
input := `
name: Gather facts
setup:
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "setup", task.Module)
assert.NotNil(t, task.Args)
}
func TestTask_UnmarshalYAML_Good_WithRegister(t *testing.T) {
input := `
name: Check file
stat:
path: /etc/hosts
register: stat_result
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "stat_result", task.Register)
assert.Equal(t, "stat", task.Module)
}
func TestTask_UnmarshalYAML_Good_WithWhen(t *testing.T) {
input := `
name: Conditional task
debug:
msg: "hello"
when: some_var is defined
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.NotNil(t, task.When)
}
func TestTask_UnmarshalYAML_Good_WithLoop(t *testing.T) {
input := `
name: Install packages
apt:
name: "{{ item }}"
loop:
- vim
- git
- curl
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
items, ok := task.Loop.([]any)
require.True(t, ok)
assert.Len(t, items, 3)
}
func TestTask_UnmarshalYAML_Good_WithItems(t *testing.T) {
// with_items should be converted to loop
input := `
name: Old-style loop
apt:
name: "{{ item }}"
with_items:
- vim
- git
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
// with_items should have been stored in Loop
items, ok := task.Loop.([]any)
require.True(t, ok)
assert.Len(t, items, 2)
}
func TestTask_UnmarshalYAML_Good_WithNotify(t *testing.T) {
input := `
name: Install package
apt:
name: nginx
notify: restart nginx
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.Equal(t, "restart nginx", task.Notify)
}
func TestTask_UnmarshalYAML_Good_WithNotifyList(t *testing.T) {
input := `
name: Install package
apt:
name: nginx
notify:
- restart nginx
- reload config
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
notifyList, ok := task.Notify.([]any)
require.True(t, ok)
assert.Len(t, notifyList, 2)
}
func TestTask_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
input := `
name: Include tasks
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 TestTask_UnmarshalYAML_Good_IncludeRole(t *testing.T) {
input := `
name: Include role
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.Name)
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
}
func TestTask_UnmarshalYAML_Good_BecomeFields(t *testing.T) {
input := `
name: Privileged task
shell: systemctl restart nginx
become: true
become_user: root
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.Become)
assert.True(t, *task.Become)
assert.Equal(t, "root", task.BecomeUser)
}
func TestTask_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
input := `
name: Might fail
shell: some risky command
ignore_errors: true
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
assert.True(t, task.IgnoreErrors)
}
// --- Inventory data structure ---
func TestInventory_UnmarshalYAML_Good_Complex(t *testing.T) {
input := `
all:
vars:
ansible_user: admin
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
hosts:
bastion:
ansible_host: 1.2.3.4
ansible_port: 4819
children:
webservers:
hosts:
web1:
ansible_host: 10.0.0.1
web2:
ansible_host: 10.0.0.2
vars:
http_port: 80
databases:
hosts:
db1:
ansible_host: 10.0.1.1
ansible_connection: ssh
`
var inv Inventory
err := yaml.Unmarshal([]byte(input), &inv)
require.NoError(t, err)
require.NotNil(t, inv.All)
// Check top-level vars
assert.Equal(t, "admin", inv.All.Vars["ansible_user"])
// Check top-level hosts
require.NotNil(t, inv.All.Hosts["bastion"])
assert.Equal(t, "1.2.3.4", inv.All.Hosts["bastion"].AnsibleHost)
assert.Equal(t, 4819, inv.All.Hosts["bastion"].AnsiblePort)
// Check children
require.NotNil(t, inv.All.Children["webservers"])
assert.Len(t, inv.All.Children["webservers"].Hosts, 2)
assert.Equal(t, 80, inv.All.Children["webservers"].Vars["http_port"])
require.NotNil(t, inv.All.Children["databases"])
assert.Equal(t, "ssh", inv.All.Children["databases"].Hosts["db1"].AnsibleConnection)
}
// --- Facts ---
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",
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, int64(16384), facts.Memory)
assert.Equal(t, 4, facts.CPUs)
}
// --- TaskResult ---
func TestTaskResult_Struct(t *testing.T) {
result := TaskResult{
Changed: true,
Failed: false,
Skipped: false,
Msg: "task completed",
Stdout: "output",
Stderr: "",
RC: 0,
}
assert.True(t, result.Changed)
assert.False(t, result.Failed)
assert.Equal(t, "task completed", result.Msg)
assert.Equal(t, 0, result.RC)
}
func TestTaskResult_WithLoopResults(t *testing.T) {
result := TaskResult{
Changed: true,
Results: []TaskResult{
{Changed: true, RC: 0},
{Changed: false, RC: 0},
{Changed: true, RC: 0},
},
}
assert.Len(t, result.Results, 3)
assert.True(t, result.Results[0].Changed)
assert.False(t, result.Results[1].Changed)
}
// --- KnownModules ---
func TestKnownModules_ContainsExpected(t *testing.T) {
// Verify both FQCN and short forms are present
fqcnModules := []string{
"ansible.builtin.shell",
"ansible.builtin.command",
"ansible.builtin.copy",
"ansible.builtin.file",
"ansible.builtin.apt",
"ansible.builtin.service",
"ansible.builtin.systemd",
"ansible.builtin.debug",
"ansible.builtin.set_fact",
}
for _, mod := range fqcnModules {
assert.Contains(t, KnownModules, mod, "expected FQCN module %s", mod)
}
shortModules := []string{
"shell", "command", "copy", "file", "apt", "service",
"systemd", "debug", "set_fact", "template", "user", "group",
}
for _, mod := range shortModules {
assert.Contains(t, KnownModules, mod, "expected short-form module %s", mod)
}
}