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:
parent
3d2466088e
commit
14cfa408e7
15 changed files with 0 additions and 11689 deletions
1018
ansible/executor.go
1018
ansible/executor.go
File diff suppressed because it is too large
Load diff
|
|
@ -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
1435
ansible/modules.go
1435
ansible/modules.go
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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`))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
451
ansible/ssh.go
451
ansible/ssh.go
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
258
ansible/types.go
258
ansible/types.go
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue