refactor: remove ansible/ — extracted to core/go-ansible

Package lives at forge.lthn.ai/core/go-ansible v0.1.0.
All consumers already updated to import from there.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-09 11:51:40 +00:00
parent 3d2466088e
commit 14cfa408e7
15 changed files with 0 additions and 11689 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,427 +0,0 @@
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)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,722 +0,0 @@
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"))
}

View file

@ -1,899 +0,0 @@
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"`))
}

File diff suppressed because it is too large Load diff

View file

@ -1,950 +0,0 @@
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`))
}

View file

@ -1,510 +0,0 @@
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
}

View file

@ -1,777 +0,0 @@
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)
}

View file

@ -1,451 +0,0 @@
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
}
}

View file

@ -1,36 +0,0 @@
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)
}

View file

@ -1,258 +0,0 @@
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",
}

View file

@ -1,402 +0,0 @@
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)
}
}