feat: extract ansible package from go-devops
Pure Go Ansible playbook engine: YAML parser, SSH executor, 30+ module implementations, Jinja2-compatible templating, privilege escalation, event-driven callbacks, and inventory pattern matching. 438 tests passing with race detector. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
commit
4fe5484e1f
18 changed files with 11769 additions and 0 deletions
37
CLAUDE.md
Normal file
37
CLAUDE.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# CLAUDE.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
`core/go-ansible` is a pure Go Ansible playbook engine. It parses YAML playbooks, inventories, and roles, then executes tasks on remote hosts via SSH. 174 module implementations, Jinja2-compatible templating, privilege escalation (become), and event-driven callbacks.
|
||||
|
||||
## Build & Development
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Single `ansible` package:
|
||||
|
||||
- `types.go` — Playbook, Play, Task, TaskResult, Inventory, Host, Facts structs
|
||||
- `parser.go` — YAML parser for playbooks, inventories, tasks, roles
|
||||
- `executor.go` — Task execution engine with module dispatch, templating, condition evaluation
|
||||
- `modules.go` — 30+ module implementations (shell, copy, apt, systemd, git, docker-compose, etc.)
|
||||
- `ssh.go` — SSH client with known_hosts verification, become/sudo, file transfer
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `go-log` — Structured logging
|
||||
- `golang.org/x/crypto` — SSH protocol
|
||||
- `gopkg.in/yaml.v3` — YAML parsing
|
||||
- `testify` — Test assertions
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- UK English
|
||||
- All functions have typed params/returns
|
||||
- Tests use testify + mock SSH client
|
||||
- Test naming: `_Good`, `_Bad`, `_Ugly` suffixes
|
||||
- License: EUPL-1.2
|
||||
1018
executor.go
Normal file
1018
executor.go
Normal file
File diff suppressed because it is too large
Load diff
427
executor_test.go
Normal file
427
executor_test.go
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- NewExecutor ---
|
||||
|
||||
func TestNewExecutor_Good(t *testing.T) {
|
||||
e := NewExecutor("/some/path")
|
||||
|
||||
assert.NotNil(t, e)
|
||||
assert.NotNil(t, e.parser)
|
||||
assert.NotNil(t, e.vars)
|
||||
assert.NotNil(t, e.facts)
|
||||
assert.NotNil(t, e.results)
|
||||
assert.NotNil(t, e.handlers)
|
||||
assert.NotNil(t, e.notified)
|
||||
assert.NotNil(t, e.clients)
|
||||
}
|
||||
|
||||
// --- SetVar ---
|
||||
|
||||
func TestSetVar_Good(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.SetVar("foo", "bar")
|
||||
e.SetVar("count", 42)
|
||||
|
||||
assert.Equal(t, "bar", e.vars["foo"])
|
||||
assert.Equal(t, 42, e.vars["count"])
|
||||
}
|
||||
|
||||
// --- SetInventoryDirect ---
|
||||
|
||||
func TestSetInventoryDirect_Good(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{
|
||||
"web1": {AnsibleHost: "10.0.0.1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
e.SetInventoryDirect(inv)
|
||||
assert.Equal(t, inv, e.inventory)
|
||||
}
|
||||
|
||||
// --- getHosts ---
|
||||
|
||||
func TestGetHosts_Executor_Good_WithInventory(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.SetInventoryDirect(&Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{
|
||||
"host1": {},
|
||||
"host2": {},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
hosts := e.getHosts("all")
|
||||
assert.Len(t, hosts, 2)
|
||||
}
|
||||
|
||||
func TestGetHosts_Executor_Good_Localhost(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
// No inventory set
|
||||
|
||||
hosts := e.getHosts("localhost")
|
||||
assert.Equal(t, []string{"localhost"}, hosts)
|
||||
}
|
||||
|
||||
func TestGetHosts_Executor_Good_NoInventory(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
|
||||
hosts := e.getHosts("webservers")
|
||||
assert.Nil(t, hosts)
|
||||
}
|
||||
|
||||
func TestGetHosts_Executor_Good_WithLimit(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.SetInventoryDirect(&Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{
|
||||
"host1": {},
|
||||
"host2": {},
|
||||
"host3": {},
|
||||
},
|
||||
},
|
||||
})
|
||||
e.Limit = "host2"
|
||||
|
||||
hosts := e.getHosts("all")
|
||||
assert.Len(t, hosts, 1)
|
||||
assert.Contains(t, hosts, "host2")
|
||||
}
|
||||
|
||||
// --- matchesTags ---
|
||||
|
||||
func TestMatchesTags_Good_NoTagsFilter(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
|
||||
assert.True(t, e.matchesTags(nil))
|
||||
assert.True(t, e.matchesTags([]string{"any", "tags"}))
|
||||
}
|
||||
|
||||
func TestMatchesTags_Good_IncludeTag(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.Tags = []string{"deploy"}
|
||||
|
||||
assert.True(t, e.matchesTags([]string{"deploy"}))
|
||||
assert.True(t, e.matchesTags([]string{"setup", "deploy"}))
|
||||
assert.False(t, e.matchesTags([]string{"other"}))
|
||||
}
|
||||
|
||||
func TestMatchesTags_Good_SkipTag(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.SkipTags = []string{"slow"}
|
||||
|
||||
assert.True(t, e.matchesTags([]string{"fast"}))
|
||||
assert.False(t, e.matchesTags([]string{"slow"}))
|
||||
assert.False(t, e.matchesTags([]string{"fast", "slow"}))
|
||||
}
|
||||
|
||||
func TestMatchesTags_Good_AllTag(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.Tags = []string{"all"}
|
||||
|
||||
assert.True(t, e.matchesTags([]string{"anything"}))
|
||||
}
|
||||
|
||||
func TestMatchesTags_Good_NoTaskTags(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.Tags = []string{"deploy"}
|
||||
|
||||
// Tasks with no tags should not match when include tags are set
|
||||
assert.False(t, e.matchesTags(nil))
|
||||
assert.False(t, e.matchesTags([]string{}))
|
||||
}
|
||||
|
||||
// --- handleNotify ---
|
||||
|
||||
func TestHandleNotify_Good_String(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.handleNotify("restart nginx")
|
||||
|
||||
assert.True(t, e.notified["restart nginx"])
|
||||
}
|
||||
|
||||
func TestHandleNotify_Good_StringList(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.handleNotify([]string{"restart nginx", "reload config"})
|
||||
|
||||
assert.True(t, e.notified["restart nginx"])
|
||||
assert.True(t, e.notified["reload config"])
|
||||
}
|
||||
|
||||
func TestHandleNotify_Good_AnyList(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.handleNotify([]any{"restart nginx", "reload config"})
|
||||
|
||||
assert.True(t, e.notified["restart nginx"])
|
||||
assert.True(t, e.notified["reload config"])
|
||||
}
|
||||
|
||||
// --- normalizeConditions ---
|
||||
|
||||
func TestNormalizeConditions_Good_String(t *testing.T) {
|
||||
result := normalizeConditions("my_var is defined")
|
||||
assert.Equal(t, []string{"my_var is defined"}, result)
|
||||
}
|
||||
|
||||
func TestNormalizeConditions_Good_StringSlice(t *testing.T) {
|
||||
result := normalizeConditions([]string{"cond1", "cond2"})
|
||||
assert.Equal(t, []string{"cond1", "cond2"}, result)
|
||||
}
|
||||
|
||||
func TestNormalizeConditions_Good_AnySlice(t *testing.T) {
|
||||
result := normalizeConditions([]any{"cond1", "cond2"})
|
||||
assert.Equal(t, []string{"cond1", "cond2"}, result)
|
||||
}
|
||||
|
||||
func TestNormalizeConditions_Good_Nil(t *testing.T) {
|
||||
result := normalizeConditions(nil)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
// --- evaluateWhen ---
|
||||
|
||||
func TestEvaluateWhen_Good_TrueLiteral(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
assert.True(t, e.evaluateWhen("true", "host1", nil))
|
||||
assert.True(t, e.evaluateWhen("True", "host1", nil))
|
||||
}
|
||||
|
||||
func TestEvaluateWhen_Good_FalseLiteral(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
assert.False(t, e.evaluateWhen("false", "host1", nil))
|
||||
assert.False(t, e.evaluateWhen("False", "host1", nil))
|
||||
}
|
||||
|
||||
func TestEvaluateWhen_Good_Negation(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
assert.False(t, e.evaluateWhen("not true", "host1", nil))
|
||||
assert.True(t, e.evaluateWhen("not false", "host1", nil))
|
||||
}
|
||||
|
||||
func TestEvaluateWhen_Good_RegisteredVarDefined(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.results["host1"] = map[string]*TaskResult{
|
||||
"myresult": {Changed: true, Failed: false},
|
||||
}
|
||||
|
||||
assert.True(t, e.evaluateWhen("myresult is defined", "host1", nil))
|
||||
assert.False(t, e.evaluateWhen("myresult is not defined", "host1", nil))
|
||||
assert.False(t, e.evaluateWhen("nonexistent is defined", "host1", nil))
|
||||
assert.True(t, e.evaluateWhen("nonexistent is not defined", "host1", nil))
|
||||
}
|
||||
|
||||
func TestEvaluateWhen_Good_RegisteredVarStatus(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.results["host1"] = map[string]*TaskResult{
|
||||
"success_result": {Changed: true, Failed: false},
|
||||
"failed_result": {Failed: true},
|
||||
"skipped_result": {Skipped: true},
|
||||
}
|
||||
|
||||
assert.True(t, e.evaluateWhen("success_result is success", "host1", nil))
|
||||
assert.True(t, e.evaluateWhen("success_result is succeeded", "host1", nil))
|
||||
assert.True(t, e.evaluateWhen("success_result is changed", "host1", nil))
|
||||
assert.True(t, e.evaluateWhen("failed_result is failed", "host1", nil))
|
||||
assert.True(t, e.evaluateWhen("skipped_result is skipped", "host1", nil))
|
||||
}
|
||||
|
||||
func TestEvaluateWhen_Good_VarTruthy(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.vars["enabled"] = true
|
||||
e.vars["disabled"] = false
|
||||
e.vars["name"] = "hello"
|
||||
e.vars["empty"] = ""
|
||||
e.vars["count"] = 5
|
||||
e.vars["zero"] = 0
|
||||
|
||||
assert.True(t, e.evalCondition("enabled", "host1"))
|
||||
assert.False(t, e.evalCondition("disabled", "host1"))
|
||||
assert.True(t, e.evalCondition("name", "host1"))
|
||||
assert.False(t, e.evalCondition("empty", "host1"))
|
||||
assert.True(t, e.evalCondition("count", "host1"))
|
||||
assert.False(t, e.evalCondition("zero", "host1"))
|
||||
}
|
||||
|
||||
func TestEvaluateWhen_Good_MultipleConditions(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.vars["enabled"] = true
|
||||
|
||||
// All conditions must be true (AND)
|
||||
assert.True(t, e.evaluateWhen([]any{"true", "True"}, "host1", nil))
|
||||
assert.False(t, e.evaluateWhen([]any{"true", "false"}, "host1", nil))
|
||||
}
|
||||
|
||||
// --- templateString ---
|
||||
|
||||
func TestTemplateString_Good_SimpleVar(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.vars["name"] = "world"
|
||||
|
||||
result := e.templateString("hello {{ name }}", "", nil)
|
||||
assert.Equal(t, "hello world", result)
|
||||
}
|
||||
|
||||
func TestTemplateString_Good_MultVars(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.vars["host"] = "example.com"
|
||||
e.vars["port"] = 8080
|
||||
|
||||
result := e.templateString("http://{{ host }}:{{ port }}", "", nil)
|
||||
assert.Equal(t, "http://example.com:8080", result)
|
||||
}
|
||||
|
||||
func TestTemplateString_Good_Unresolved(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
result := e.templateString("{{ undefined_var }}", "", nil)
|
||||
assert.Equal(t, "{{ undefined_var }}", result)
|
||||
}
|
||||
|
||||
func TestTemplateString_Good_NoTemplate(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
result := e.templateString("plain string", "", nil)
|
||||
assert.Equal(t, "plain string", result)
|
||||
}
|
||||
|
||||
// --- applyFilter ---
|
||||
|
||||
func TestApplyFilter_Good_Default(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
|
||||
assert.Equal(t, "hello", e.applyFilter("hello", "default('fallback')"))
|
||||
assert.Equal(t, "fallback", e.applyFilter("", "default('fallback')"))
|
||||
}
|
||||
|
||||
func TestApplyFilter_Good_Bool(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
|
||||
assert.Equal(t, "true", e.applyFilter("true", "bool"))
|
||||
assert.Equal(t, "true", e.applyFilter("yes", "bool"))
|
||||
assert.Equal(t, "true", e.applyFilter("1", "bool"))
|
||||
assert.Equal(t, "false", e.applyFilter("false", "bool"))
|
||||
assert.Equal(t, "false", e.applyFilter("no", "bool"))
|
||||
assert.Equal(t, "false", e.applyFilter("anything", "bool"))
|
||||
}
|
||||
|
||||
func TestApplyFilter_Good_Trim(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
assert.Equal(t, "hello", e.applyFilter(" hello ", "trim"))
|
||||
}
|
||||
|
||||
// --- resolveLoop ---
|
||||
|
||||
func TestResolveLoop_Good_SliceAny(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
items := e.resolveLoop([]any{"a", "b", "c"}, "host1")
|
||||
assert.Len(t, items, 3)
|
||||
}
|
||||
|
||||
func TestResolveLoop_Good_SliceString(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
items := e.resolveLoop([]string{"a", "b", "c"}, "host1")
|
||||
assert.Len(t, items, 3)
|
||||
}
|
||||
|
||||
func TestResolveLoop_Good_Nil(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
items := e.resolveLoop(nil, "host1")
|
||||
assert.Nil(t, items)
|
||||
}
|
||||
|
||||
// --- templateArgs ---
|
||||
|
||||
func TestTemplateArgs_Good(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.vars["myvar"] = "resolved"
|
||||
|
||||
args := map[string]any{
|
||||
"plain": "no template",
|
||||
"templated": "{{ myvar }}",
|
||||
"number": 42,
|
||||
}
|
||||
|
||||
result := e.templateArgs(args, "host1", nil)
|
||||
assert.Equal(t, "no template", result["plain"])
|
||||
assert.Equal(t, "resolved", result["templated"])
|
||||
assert.Equal(t, 42, result["number"])
|
||||
}
|
||||
|
||||
func TestTemplateArgs_Good_NestedMap(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.vars["port"] = "8080"
|
||||
|
||||
args := map[string]any{
|
||||
"nested": map[string]any{
|
||||
"port": "{{ port }}",
|
||||
},
|
||||
}
|
||||
|
||||
result := e.templateArgs(args, "host1", nil)
|
||||
nested := result["nested"].(map[string]any)
|
||||
assert.Equal(t, "8080", nested["port"])
|
||||
}
|
||||
|
||||
func TestTemplateArgs_Good_ArrayValues(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
e.vars["pkg"] = "nginx"
|
||||
|
||||
args := map[string]any{
|
||||
"packages": []any{"{{ pkg }}", "curl"},
|
||||
}
|
||||
|
||||
result := e.templateArgs(args, "host1", nil)
|
||||
pkgs := result["packages"].([]any)
|
||||
assert.Equal(t, "nginx", pkgs[0])
|
||||
assert.Equal(t, "curl", pkgs[1])
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
func TestGetStringArg_Good(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"name": "value",
|
||||
"number": 42,
|
||||
}
|
||||
|
||||
assert.Equal(t, "value", getStringArg(args, "name", ""))
|
||||
assert.Equal(t, "42", getStringArg(args, "number", ""))
|
||||
assert.Equal(t, "default", getStringArg(args, "missing", "default"))
|
||||
}
|
||||
|
||||
func TestGetBoolArg_Good(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"enabled": true,
|
||||
"disabled": false,
|
||||
"yes_str": "yes",
|
||||
"true_str": "true",
|
||||
"one_str": "1",
|
||||
"no_str": "no",
|
||||
}
|
||||
|
||||
assert.True(t, getBoolArg(args, "enabled", false))
|
||||
assert.False(t, getBoolArg(args, "disabled", true))
|
||||
assert.True(t, getBoolArg(args, "yes_str", false))
|
||||
assert.True(t, getBoolArg(args, "true_str", false))
|
||||
assert.True(t, getBoolArg(args, "one_str", false))
|
||||
assert.False(t, getBoolArg(args, "no_str", true))
|
||||
assert.True(t, getBoolArg(args, "missing", true))
|
||||
assert.False(t, getBoolArg(args, "missing", false))
|
||||
}
|
||||
|
||||
// --- Close ---
|
||||
|
||||
func TestClose_Good_EmptyClients(t *testing.T) {
|
||||
e := NewExecutor("/tmp")
|
||||
// Should not panic with no clients
|
||||
e.Close()
|
||||
assert.Empty(t, e.clients)
|
||||
}
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
module forge.lthn.ai/core/go-ansible
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go-log v0.0.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
)
|
||||
26
go.sum
Normal file
26
go.sum
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
|
||||
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
1416
mock_ssh_test.go
Normal file
1416
mock_ssh_test.go
Normal file
File diff suppressed because it is too large
Load diff
1435
modules.go
Normal file
1435
modules.go
Normal file
File diff suppressed because it is too large
Load diff
1127
modules_adv_test.go
Normal file
1127
modules_adv_test.go
Normal file
File diff suppressed because it is too large
Load diff
722
modules_cmd_test.go
Normal file
722
modules_cmd_test.go
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Step 1.1: command / shell / raw / script module tests
|
||||
// ============================================================
|
||||
|
||||
// --- MockSSHClient basic tests ---
|
||||
|
||||
func TestMockSSHClient_Good_RunRecordsExecution(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommand("echo hello", "hello\n", "", 0)
|
||||
|
||||
stdout, stderr, rc, err := mock.Run(nil, "echo hello")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello\n", stdout)
|
||||
assert.Equal(t, "", stderr)
|
||||
assert.Equal(t, 0, rc)
|
||||
assert.Equal(t, 1, mock.commandCount())
|
||||
assert.Equal(t, "Run", mock.lastCommand().Method)
|
||||
assert.Equal(t, "echo hello", mock.lastCommand().Cmd)
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommand("set -e", "ok", "", 0)
|
||||
|
||||
stdout, _, rc, err := mock.RunScript(nil, "set -e\necho done")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ok", stdout)
|
||||
assert.Equal(t, 0, rc)
|
||||
assert.Equal(t, 1, mock.commandCount())
|
||||
assert.Equal(t, "RunScript", mock.lastCommand().Method)
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
// No expectations registered — should return empty success
|
||||
stdout, stderr, rc, err := mock.Run(nil, "anything")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", stdout)
|
||||
assert.Equal(t, "", stderr)
|
||||
assert.Equal(t, 0, rc)
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_LastMatchWins(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommand("echo", "first", "", 0)
|
||||
mock.expectCommand("echo", "second", "", 0)
|
||||
|
||||
stdout, _, _, _ := mock.Run(nil, "echo hello")
|
||||
|
||||
assert.Equal(t, "second", stdout)
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_FileOperations(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
// File does not exist initially
|
||||
exists, err := mock.FileExists(nil, "/etc/config")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
|
||||
// Add file
|
||||
mock.addFile("/etc/config", []byte("key=value"))
|
||||
|
||||
// Now it exists
|
||||
exists, err = mock.FileExists(nil, "/etc/config")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Download it
|
||||
content, err := mock.Download(nil, "/etc/config")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte("key=value"), content)
|
||||
|
||||
// Download non-existent file
|
||||
_, err = mock.Download(nil, "/nonexistent")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_StatWithExplicit(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.addStat("/var/log", map[string]any{"exists": true, "isdir": true})
|
||||
|
||||
info, err := mock.Stat(nil, "/var/log")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, true, info["exists"])
|
||||
assert.Equal(t, true, info["isdir"])
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_StatFallback(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost"))
|
||||
|
||||
info, err := mock.Stat(nil, "/etc/hosts")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, true, info["exists"])
|
||||
assert.Equal(t, false, info["isdir"])
|
||||
|
||||
info, err = mock.Stat(nil, "/nonexistent")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, false, info["exists"])
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_BecomeTracking(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
assert.False(t, mock.become)
|
||||
assert.Equal(t, "", mock.becomeUser)
|
||||
|
||||
mock.SetBecome(true, "root", "secret")
|
||||
|
||||
assert.True(t, mock.become)
|
||||
assert.Equal(t, "root", mock.becomeUser)
|
||||
assert.Equal(t, "secret", mock.becomePass)
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_HasExecuted(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
_, _, _, _ = mock.Run(nil, "systemctl restart nginx")
|
||||
_, _, _, _ = mock.Run(nil, "apt-get update")
|
||||
|
||||
assert.True(t, mock.hasExecuted("systemctl.*nginx"))
|
||||
assert.True(t, mock.hasExecuted("apt-get"))
|
||||
assert.False(t, mock.hasExecuted("yum"))
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_HasExecutedMethod(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
_, _, _, _ = mock.Run(nil, "echo run")
|
||||
_, _, _, _ = mock.RunScript(nil, "echo script")
|
||||
|
||||
assert.True(t, mock.hasExecutedMethod("Run", "echo run"))
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "echo script"))
|
||||
assert.False(t, mock.hasExecutedMethod("Run", "echo script"))
|
||||
assert.False(t, mock.hasExecutedMethod("RunScript", "echo run"))
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_Reset(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
_, _, _, _ = mock.Run(nil, "echo hello")
|
||||
assert.Equal(t, 1, mock.commandCount())
|
||||
|
||||
mock.reset()
|
||||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestMockSSHClient_Good_ErrorExpectation(t *testing.T) {
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommandError("bad cmd", assert.AnError)
|
||||
|
||||
_, _, _, err := mock.Run(nil, "bad cmd")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- command module ---
|
||||
|
||||
func TestModuleCommand_Good_BasicCommand(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("ls -la /tmp", "total 0\n", "", 0)
|
||||
|
||||
result, err := moduleCommandWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "ls -la /tmp",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.Equal(t, "total 0\n", result.Stdout)
|
||||
assert.Equal(t, 0, result.RC)
|
||||
|
||||
// Verify it used Run (not RunScript)
|
||||
assert.True(t, mock.hasExecutedMethod("Run", "ls -la /tmp"))
|
||||
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
|
||||
}
|
||||
|
||||
func TestModuleCommand_Good_CmdArg(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("whoami", "root\n", "", 0)
|
||||
|
||||
result, err := moduleCommandWithClient(e, mock, map[string]any{
|
||||
"cmd": "whoami",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, "root\n", result.Stdout)
|
||||
assert.True(t, mock.hasExecutedMethod("Run", "whoami"))
|
||||
}
|
||||
|
||||
func TestModuleCommand_Good_WithChdir(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`cd "/var/log" && ls`, "syslog\n", "", 0)
|
||||
|
||||
result, err := moduleCommandWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "ls",
|
||||
"chdir": "/var/log",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
// The command should have been wrapped with cd
|
||||
last := mock.lastCommand()
|
||||
assert.Equal(t, "Run", last.Method)
|
||||
assert.Contains(t, last.Cmd, `cd "/var/log"`)
|
||||
assert.Contains(t, last.Cmd, "ls")
|
||||
}
|
||||
|
||||
func TestModuleCommand_Bad_NoCommand(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
_, err := moduleCommandWithClient(e, mock, map[string]any{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no command specified")
|
||||
}
|
||||
|
||||
func TestModuleCommand_Good_NonZeroRC(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("false", "", "error occurred", 1)
|
||||
|
||||
result, err := moduleCommandWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "false",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Equal(t, 1, result.RC)
|
||||
assert.Equal(t, "error occurred", result.Stderr)
|
||||
}
|
||||
|
||||
func TestModuleCommand_Good_SSHError(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommandError(".*", assert.AnError)
|
||||
|
||||
result, err := moduleCommandWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "any command",
|
||||
})
|
||||
|
||||
require.NoError(t, err) // Module wraps SSH errors into result.Failed
|
||||
assert.True(t, result.Failed)
|
||||
assert.Contains(t, result.Msg, assert.AnError.Error())
|
||||
}
|
||||
|
||||
func TestModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("from_raw", "raw\n", "", 0)
|
||||
|
||||
result, err := moduleCommandWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "from_raw",
|
||||
"cmd": "from_cmd",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "raw\n", result.Stdout)
|
||||
assert.True(t, mock.hasExecuted("from_raw"))
|
||||
}
|
||||
|
||||
// --- shell module ---
|
||||
|
||||
func TestModuleShell_Good_BasicShell(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo hello", "hello\n", "", 0)
|
||||
|
||||
result, err := moduleShellWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "echo hello",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.Equal(t, "hello\n", result.Stdout)
|
||||
|
||||
// Shell must use RunScript (not Run)
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "echo hello"))
|
||||
assert.False(t, mock.hasExecutedMethod("Run", ".*"))
|
||||
}
|
||||
|
||||
func TestModuleShell_Good_CmdArg(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("date", "Thu Feb 20\n", "", 0)
|
||||
|
||||
result, err := moduleShellWithClient(e, mock, map[string]any{
|
||||
"cmd": "date",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "date"))
|
||||
}
|
||||
|
||||
func TestModuleShell_Good_WithChdir(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`cd "/app" && npm install`, "done\n", "", 0)
|
||||
|
||||
result, err := moduleShellWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "npm install",
|
||||
"chdir": "/app",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
last := mock.lastCommand()
|
||||
assert.Equal(t, "RunScript", last.Method)
|
||||
assert.Contains(t, last.Cmd, `cd "/app"`)
|
||||
assert.Contains(t, last.Cmd, "npm install")
|
||||
}
|
||||
|
||||
func TestModuleShell_Bad_NoCommand(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
_, err := moduleShellWithClient(e, mock, map[string]any{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no command specified")
|
||||
}
|
||||
|
||||
func TestModuleShell_Good_NonZeroRC(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("exit 2", "", "failed", 2)
|
||||
|
||||
result, err := moduleShellWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "exit 2",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Equal(t, 2, result.RC)
|
||||
}
|
||||
|
||||
func TestModuleShell_Good_SSHError(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommandError(".*", assert.AnError)
|
||||
|
||||
result, err := moduleShellWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "some command",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
}
|
||||
|
||||
func TestModuleShell_Good_PipelineCommand(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`cat /etc/passwd \| grep root`, "root:x:0:0\n", "", 0)
|
||||
|
||||
result, err := moduleShellWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "cat /etc/passwd | grep root",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
// Shell uses RunScript, so pipes work
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "cat /etc/passwd"))
|
||||
}
|
||||
|
||||
// --- raw module ---
|
||||
|
||||
func TestModuleRaw_Good_BasicRaw(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("uname -a", "Linux host1 5.15\n", "", 0)
|
||||
|
||||
result, err := moduleRawWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "uname -a",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, "Linux host1 5.15\n", result.Stdout)
|
||||
|
||||
// Raw must use Run (not RunScript) — no shell wrapping
|
||||
assert.True(t, mock.hasExecutedMethod("Run", "uname -a"))
|
||||
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
|
||||
}
|
||||
|
||||
func TestModuleRaw_Bad_NoCommand(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
_, err := moduleRawWithClient(e, mock, map[string]any{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no command specified")
|
||||
}
|
||||
|
||||
func TestModuleRaw_Good_NoChdir(t *testing.T) {
|
||||
// Raw module does NOT support chdir — it should ignore it
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
||||
result, err := moduleRawWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "echo test",
|
||||
"chdir": "/should/be/ignored",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
// The chdir should NOT appear in the command
|
||||
last := mock.lastCommand()
|
||||
assert.Equal(t, "echo test", last.Cmd)
|
||||
assert.NotContains(t, last.Cmd, "cd")
|
||||
}
|
||||
|
||||
func TestModuleRaw_Good_NonZeroRC(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("invalid", "", "not found", 127)
|
||||
|
||||
result, err := moduleRawWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "invalid",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
// Note: raw module does NOT set Failed based on RC
|
||||
assert.Equal(t, 127, result.RC)
|
||||
assert.Equal(t, "not found", result.Stderr)
|
||||
}
|
||||
|
||||
func TestModuleRaw_Good_SSHError(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommandError(".*", assert.AnError)
|
||||
|
||||
result, err := moduleRawWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "any",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
}
|
||||
|
||||
func TestModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
|
||||
// Raw should pass the command exactly as given — no wrapping
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
complexCmd := `/usr/bin/python3 -c 'import sys; print(sys.version)'`
|
||||
mock.expectCommand(".*python3.*", "3.10.0\n", "", 0)
|
||||
|
||||
result, err := moduleRawWithClient(e, mock, map[string]any{
|
||||
"_raw_params": complexCmd,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
last := mock.lastCommand()
|
||||
assert.Equal(t, complexCmd, last.Cmd)
|
||||
}
|
||||
|
||||
// --- script module ---
|
||||
|
||||
func TestModuleScript_Good_BasicScript(t *testing.T) {
|
||||
// Create a temporary script file
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "setup.sh")
|
||||
scriptContent := "#!/bin/bash\necho 'setup complete'\nexit 0"
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("setup complete", "setup complete\n", "", 0)
|
||||
|
||||
result, err := moduleScriptWithClient(e, mock, map[string]any{
|
||||
"_raw_params": scriptPath,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
|
||||
// Script must use RunScript (not Run) — it sends the file content
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "setup complete"))
|
||||
assert.False(t, mock.hasExecutedMethod("Run", ".*"))
|
||||
|
||||
// Verify the full script content was sent
|
||||
last := mock.lastCommand()
|
||||
assert.Equal(t, scriptContent, last.Cmd)
|
||||
}
|
||||
|
||||
func TestModuleScript_Bad_NoScript(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
_, err := moduleScriptWithClient(e, mock, map[string]any{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no script specified")
|
||||
}
|
||||
|
||||
func TestModuleScript_Bad_FileNotFound(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
_, err := moduleScriptWithClient(e, mock, map[string]any{
|
||||
"_raw_params": "/nonexistent/script.sh",
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read script")
|
||||
}
|
||||
|
||||
func TestModuleScript_Good_NonZeroRC(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "fail.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("exit 1"), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("exit 1", "", "script failed", 1)
|
||||
|
||||
result, err := moduleScriptWithClient(e, mock, map[string]any{
|
||||
"_raw_params": scriptPath,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Equal(t, 1, result.RC)
|
||||
}
|
||||
|
||||
func TestModuleScript_Good_MultiLineScript(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "multi.sh")
|
||||
scriptContent := "#!/bin/bash\nset -e\napt-get update\napt-get install -y nginx\nsystemctl start nginx"
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("apt-get", "done\n", "", 0)
|
||||
|
||||
result, err := moduleScriptWithClient(e, mock, map[string]any{
|
||||
"_raw_params": scriptPath,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Verify RunScript was called with the full content
|
||||
last := mock.lastCommand()
|
||||
assert.Equal(t, "RunScript", last.Method)
|
||||
assert.Equal(t, scriptContent, last.Cmd)
|
||||
}
|
||||
|
||||
func TestModuleScript_Good_SSHError(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "ok.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("echo ok"), 0755))
|
||||
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
mock.expectCommandError(".*", assert.AnError)
|
||||
|
||||
result, err := moduleScriptWithClient(e, mock, map[string]any{
|
||||
"_raw_params": scriptPath,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
}
|
||||
|
||||
// --- Cross-module differentiation tests ---
|
||||
|
||||
func TestModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
||||
_, _ = moduleCommandWithClient(e, mock, map[string]any{"_raw_params": "echo test"})
|
||||
|
||||
cmds := mock.executedCommands()
|
||||
require.Len(t, cmds, 1)
|
||||
assert.Equal(t, "Run", cmds[0].Method, "command module must use Run()")
|
||||
}
|
||||
|
||||
func TestModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
||||
_, _ = moduleShellWithClient(e, mock, map[string]any{"_raw_params": "echo test"})
|
||||
|
||||
cmds := mock.executedCommands()
|
||||
require.Len(t, cmds, 1)
|
||||
assert.Equal(t, "RunScript", cmds[0].Method, "shell module must use RunScript()")
|
||||
}
|
||||
|
||||
func TestModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
||||
_, _ = moduleRawWithClient(e, mock, map[string]any{"_raw_params": "echo test"})
|
||||
|
||||
cmds := mock.executedCommands()
|
||||
require.Len(t, cmds, 1)
|
||||
assert.Equal(t, "Run", cmds[0].Method, "raw module must use Run()")
|
||||
}
|
||||
|
||||
func TestModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "test.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("echo test"), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("echo test", "test\n", "", 0)
|
||||
|
||||
_, _ = moduleScriptWithClient(e, mock, map[string]any{"_raw_params": scriptPath})
|
||||
|
||||
cmds := mock.executedCommands()
|
||||
require.Len(t, cmds, 1)
|
||||
assert.Equal(t, "RunScript", cmds[0].Method, "script module must use RunScript()")
|
||||
}
|
||||
|
||||
// --- executeModuleWithMock dispatch tests ---
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("uptime", "up 5 days\n", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "command",
|
||||
Args: map[string]any{"_raw_params": "uptime"},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, "up 5 days\n", result.Stdout)
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("ps aux", "root.*bash\n", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "ansible.builtin.shell",
|
||||
Args: map[string]any{"_raw_params": "ps aux | grep bash"},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("cat /etc/hostname", "web01\n", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "raw",
|
||||
Args: map[string]any{"_raw_params": "cat /etc/hostname"},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, "web01\n", result.Stdout)
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "deploy.sh")
|
||||
require.NoError(t, os.WriteFile(scriptPath, []byte("echo deploying"), 0755))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("deploying", "deploying\n", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "script",
|
||||
Args: map[string]any{"_raw_params": scriptPath},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
task := &Task{
|
||||
Module: "ansible.builtin.hostname",
|
||||
Args: map[string]any{},
|
||||
}
|
||||
|
||||
_, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported module")
|
||||
}
|
||||
|
||||
// --- Template integration tests ---
|
||||
|
||||
func TestModuleCommand_Good_TemplatedArgs(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
e.SetVar("service_name", "nginx")
|
||||
mock.expectCommand("systemctl status nginx", "active\n", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "command",
|
||||
Args: map[string]any{"_raw_params": "systemctl status {{ service_name }}"},
|
||||
}
|
||||
|
||||
// Template the args the way the executor does
|
||||
args := e.templateArgs(task.Args, "host1", task)
|
||||
result, err := moduleCommandWithClient(e, mock, args)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted("systemctl status nginx"))
|
||||
}
|
||||
899
modules_file_test.go
Normal file
899
modules_file_test.go
Normal file
|
|
@ -0,0 +1,899 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Step 1.2: copy / template / file / lineinfile / blockinfile / stat module tests
|
||||
// ============================================================
|
||||
|
||||
// --- copy module ---
|
||||
|
||||
func TestModuleCopy_Good_ContentUpload(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleCopyWithClient(e, mock, map[string]any{
|
||||
"content": "server_name=web01",
|
||||
"dest": "/etc/app/config",
|
||||
}, "host1", &Task{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Contains(t, result.Msg, "copied to /etc/app/config")
|
||||
|
||||
// Verify upload was performed
|
||||
assert.Equal(t, 1, mock.uploadCount())
|
||||
up := mock.lastUpload()
|
||||
require.NotNil(t, up)
|
||||
assert.Equal(t, "/etc/app/config", up.Remote)
|
||||
assert.Equal(t, []byte("server_name=web01"), up.Content)
|
||||
assert.Equal(t, os.FileMode(0644), up.Mode)
|
||||
}
|
||||
|
||||
func TestModuleCopy_Good_SrcFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srcPath := filepath.Join(tmpDir, "nginx.conf")
|
||||
require.NoError(t, os.WriteFile(srcPath, []byte("worker_processes auto;"), 0644))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleCopyWithClient(e, mock, map[string]any{
|
||||
"src": srcPath,
|
||||
"dest": "/etc/nginx/nginx.conf",
|
||||
}, "host1", &Task{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
up := mock.lastUpload()
|
||||
require.NotNil(t, up)
|
||||
assert.Equal(t, "/etc/nginx/nginx.conf", up.Remote)
|
||||
assert.Equal(t, []byte("worker_processes auto;"), up.Content)
|
||||
}
|
||||
|
||||
func TestModuleCopy_Good_OwnerGroup(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleCopyWithClient(e, mock, map[string]any{
|
||||
"content": "data",
|
||||
"dest": "/opt/app/data.txt",
|
||||
"owner": "appuser",
|
||||
"group": "appgroup",
|
||||
}, "host1", &Task{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Upload + chown + chgrp = 1 upload + 2 Run calls
|
||||
assert.Equal(t, 1, mock.uploadCount())
|
||||
assert.True(t, mock.hasExecuted(`chown appuser "/opt/app/data.txt"`))
|
||||
assert.True(t, mock.hasExecuted(`chgrp appgroup "/opt/app/data.txt"`))
|
||||
}
|
||||
|
||||
func TestModuleCopy_Good_CustomMode(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleCopyWithClient(e, mock, map[string]any{
|
||||
"content": "#!/bin/bash\necho hello",
|
||||
"dest": "/usr/local/bin/hello.sh",
|
||||
"mode": "0755",
|
||||
}, "host1", &Task{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
up := mock.lastUpload()
|
||||
require.NotNil(t, up)
|
||||
assert.Equal(t, os.FileMode(0755), up.Mode)
|
||||
}
|
||||
|
||||
func TestModuleCopy_Bad_MissingDest(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleCopyWithClient(e, mock, map[string]any{
|
||||
"content": "data",
|
||||
}, "host1", &Task{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "dest required")
|
||||
}
|
||||
|
||||
func TestModuleCopy_Bad_MissingSrcAndContent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleCopyWithClient(e, mock, map[string]any{
|
||||
"dest": "/tmp/out",
|
||||
}, "host1", &Task{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "src or content required")
|
||||
}
|
||||
|
||||
func TestModuleCopy_Bad_SrcFileNotFound(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleCopyWithClient(e, mock, map[string]any{
|
||||
"src": "/nonexistent/file.txt",
|
||||
"dest": "/tmp/out",
|
||||
}, "host1", &Task{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read src")
|
||||
}
|
||||
|
||||
func TestModuleCopy_Good_ContentTakesPrecedenceOverSrc(t *testing.T) {
|
||||
// When both content and src are given, src is checked first in the implementation
|
||||
// but if src is empty string, content is used
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleCopyWithClient(e, mock, map[string]any{
|
||||
"content": "from_content",
|
||||
"dest": "/tmp/out",
|
||||
}, "host1", &Task{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
up := mock.lastUpload()
|
||||
assert.Equal(t, []byte("from_content"), up.Content)
|
||||
}
|
||||
|
||||
// --- file module ---
|
||||
|
||||
func TestModuleFile_Good_StateDirectory(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/var/lib/app",
|
||||
"state": "directory",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should execute mkdir -p with default mode 0755
|
||||
assert.True(t, mock.hasExecuted(`mkdir -p "/var/lib/app"`))
|
||||
assert.True(t, mock.hasExecuted(`chmod 0755`))
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_StateDirectoryCustomMode(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/opt/data",
|
||||
"state": "directory",
|
||||
"mode": "0700",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`mkdir -p "/opt/data" && chmod 0700 "/opt/data"`))
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_StateAbsent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/tmp/old-dir",
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`rm -rf "/tmp/old-dir"`))
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_StateTouch(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/var/log/app.log",
|
||||
"state": "touch",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`touch "/var/log/app.log"`))
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_StateLink(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/usr/local/bin/node",
|
||||
"state": "link",
|
||||
"src": "/opt/node/bin/node",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`ln -sf "/opt/node/bin/node" "/usr/local/bin/node"`))
|
||||
}
|
||||
|
||||
func TestModuleFile_Bad_LinkMissingSrc(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/usr/local/bin/node",
|
||||
"state": "link",
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "src required for link state")
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_OwnerGroupMode(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/var/lib/app/data",
|
||||
"state": "directory",
|
||||
"owner": "www-data",
|
||||
"group": "www-data",
|
||||
"mode": "0775",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should have mkdir, chmod in the directory command, then chown and chgrp
|
||||
assert.True(t, mock.hasExecuted(`mkdir -p "/var/lib/app/data" && chmod 0775 "/var/lib/app/data"`))
|
||||
assert.True(t, mock.hasExecuted(`chown www-data "/var/lib/app/data"`))
|
||||
assert.True(t, mock.hasExecuted(`chgrp www-data "/var/lib/app/data"`))
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_RecurseOwner(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/var/www",
|
||||
"state": "directory",
|
||||
"owner": "www-data",
|
||||
"recurse": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should have both regular chown and recursive chown
|
||||
assert.True(t, mock.hasExecuted(`chown www-data "/var/www"`))
|
||||
assert.True(t, mock.hasExecuted(`chown -R www-data "/var/www"`))
|
||||
}
|
||||
|
||||
func TestModuleFile_Bad_MissingPath(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"state": "directory",
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path required")
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_DestAliasForPath(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"dest": "/opt/myapp",
|
||||
"state": "directory",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`mkdir -p "/opt/myapp"`))
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_StateFileWithMode(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/config.yml",
|
||||
"state": "file",
|
||||
"mode": "0600",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`chmod 0600 "/etc/config.yml"`))
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_DirectoryCommandFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("mkdir", "", "permission denied", 1)
|
||||
|
||||
result, err := moduleFileWithClient(e, mock, map[string]any{
|
||||
"path": "/root/protected",
|
||||
"state": "directory",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Contains(t, result.Msg, "permission denied")
|
||||
}
|
||||
|
||||
// --- lineinfile module ---
|
||||
|
||||
func TestModuleLineinfile_Good_InsertLine(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/hosts",
|
||||
"line": "192.168.1.100 web01",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should use grep -qxF to check and echo to append
|
||||
assert.True(t, mock.hasExecuted(`grep -qxF`))
|
||||
assert.True(t, mock.hasExecuted(`192.168.1.100 web01`))
|
||||
}
|
||||
|
||||
func TestModuleLineinfile_Good_ReplaceRegexp(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/ssh/sshd_config",
|
||||
"regexp": "^#?PermitRootLogin",
|
||||
"line": "PermitRootLogin no",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should use sed to replace
|
||||
assert.True(t, mock.hasExecuted(`sed -i 's/\^#\?PermitRootLogin/PermitRootLogin no/'`))
|
||||
}
|
||||
|
||||
func TestModuleLineinfile_Good_RemoveLine(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/hosts",
|
||||
"regexp": "^192\\.168\\.1\\.100",
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should use sed to delete matching lines
|
||||
assert.True(t, mock.hasExecuted(`sed -i '/\^192`))
|
||||
assert.True(t, mock.hasExecuted(`/d'`))
|
||||
}
|
||||
|
||||
func TestModuleLineinfile_Good_RegexpFallsBackToAppend(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
// Simulate sed returning non-zero (pattern not found)
|
||||
mock.expectCommand("sed -i", "", "", 1)
|
||||
|
||||
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/config",
|
||||
"regexp": "^setting=",
|
||||
"line": "setting=value",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should have attempted sed, then fallen back to echo append
|
||||
cmds := mock.executedCommands()
|
||||
assert.GreaterOrEqual(t, len(cmds), 2)
|
||||
assert.True(t, mock.hasExecuted(`echo`))
|
||||
}
|
||||
|
||||
func TestModuleLineinfile_Bad_MissingPath(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleLineinfileWithClient(e, mock, map[string]any{
|
||||
"line": "test",
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path required")
|
||||
}
|
||||
|
||||
func TestModuleLineinfile_Good_DestAliasForPath(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
|
||||
"dest": "/etc/config",
|
||||
"line": "key=value",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`/etc/config`))
|
||||
}
|
||||
|
||||
func TestModuleLineinfile_Good_AbsentWithNoRegexp(t *testing.T) {
|
||||
// When state=absent but no regexp, nothing happens (no commands)
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/config",
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModuleLineinfile_Good_LineWithSlashes(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleLineinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/nginx/conf.d/default.conf",
|
||||
"regexp": "^root /",
|
||||
"line": "root /var/www/html;",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Slashes in the line should be escaped
|
||||
assert.True(t, mock.hasExecuted(`root \\/var\\/www\\/html;`))
|
||||
}
|
||||
|
||||
// --- blockinfile module ---
|
||||
|
||||
func TestModuleBlockinfile_Good_InsertBlock(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/nginx/conf.d/upstream.conf",
|
||||
"block": "server 10.0.0.1:8080;\nserver 10.0.0.2:8080;",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should use RunScript for the heredoc approach
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "BEGIN ANSIBLE MANAGED BLOCK"))
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "END ANSIBLE MANAGED BLOCK"))
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "10\\.0\\.0\\.1"))
|
||||
}
|
||||
|
||||
func TestModuleBlockinfile_Good_CustomMarkers(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/hosts",
|
||||
"block": "10.0.0.5 db01",
|
||||
"marker": "# {mark} managed by devops",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should use custom markers instead of default
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "# BEGIN managed by devops"))
|
||||
assert.True(t, mock.hasExecutedMethod("RunScript", "# END managed by devops"))
|
||||
}
|
||||
|
||||
func TestModuleBlockinfile_Good_RemoveBlock(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/config",
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should use sed to remove the block between markers
|
||||
assert.True(t, mock.hasExecuted(`sed -i '/.*BEGIN ANSIBLE MANAGED BLOCK/,/.*END ANSIBLE MANAGED BLOCK/d'`))
|
||||
}
|
||||
|
||||
func TestModuleBlockinfile_Good_CreateFile(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/new-config",
|
||||
"block": "setting=value",
|
||||
"create": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
// Should touch the file first when create=true
|
||||
assert.True(t, mock.hasExecuted(`touch "/etc/new-config"`))
|
||||
}
|
||||
|
||||
func TestModuleBlockinfile_Bad_MissingPath(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleBlockinfileWithClient(e, mock, map[string]any{
|
||||
"block": "content",
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path required")
|
||||
}
|
||||
|
||||
func TestModuleBlockinfile_Good_DestAliasForPath(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
|
||||
"dest": "/etc/config",
|
||||
"block": "data",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestModuleBlockinfile_Good_ScriptFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand("BLOCK_EOF", "", "write error", 1)
|
||||
|
||||
result, err := moduleBlockinfileWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/config",
|
||||
"block": "data",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Contains(t, result.Msg, "write error")
|
||||
}
|
||||
|
||||
// --- stat module ---
|
||||
|
||||
func TestModuleStat_Good_ExistingFile(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.addStat("/etc/nginx/nginx.conf", map[string]any{
|
||||
"exists": true,
|
||||
"isdir": false,
|
||||
"mode": "0644",
|
||||
"size": 1234,
|
||||
"uid": 0,
|
||||
"gid": 0,
|
||||
})
|
||||
|
||||
result, err := moduleStatWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/nginx/nginx.conf",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Changed) // stat never changes anything
|
||||
require.NotNil(t, result.Data)
|
||||
|
||||
stat, ok := result.Data["stat"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, true, stat["exists"])
|
||||
assert.Equal(t, false, stat["isdir"])
|
||||
assert.Equal(t, "0644", stat["mode"])
|
||||
assert.Equal(t, 1234, stat["size"])
|
||||
}
|
||||
|
||||
func TestModuleStat_Good_MissingFile(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleStatWithClient(e, mock, map[string]any{
|
||||
"path": "/nonexistent/file.txt",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Changed)
|
||||
require.NotNil(t, result.Data)
|
||||
|
||||
stat, ok := result.Data["stat"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, false, stat["exists"])
|
||||
}
|
||||
|
||||
func TestModuleStat_Good_Directory(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.addStat("/var/log", map[string]any{
|
||||
"exists": true,
|
||||
"isdir": true,
|
||||
"mode": "0755",
|
||||
})
|
||||
|
||||
result, err := moduleStatWithClient(e, mock, map[string]any{
|
||||
"path": "/var/log",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
stat := result.Data["stat"].(map[string]any)
|
||||
assert.Equal(t, true, stat["exists"])
|
||||
assert.Equal(t, true, stat["isdir"])
|
||||
}
|
||||
|
||||
func TestModuleStat_Good_FallbackFromFileSystem(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
// No explicit stat, but add a file — stat falls back to file existence
|
||||
mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost"))
|
||||
|
||||
result, err := moduleStatWithClient(e, mock, map[string]any{
|
||||
"path": "/etc/hosts",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
stat := result.Data["stat"].(map[string]any)
|
||||
assert.Equal(t, true, stat["exists"])
|
||||
assert.Equal(t, false, stat["isdir"])
|
||||
}
|
||||
|
||||
func TestModuleStat_Bad_MissingPath(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleStatWithClient(e, mock, map[string]any{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "path required")
|
||||
}
|
||||
|
||||
// --- template module ---
|
||||
|
||||
func TestModuleTemplate_Good_BasicTemplate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srcPath := filepath.Join(tmpDir, "app.conf.j2")
|
||||
require.NoError(t, os.WriteFile(srcPath, []byte("server_name={{ server_name }};"), 0644))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
e.SetVar("server_name", "web01.example.com")
|
||||
|
||||
result, err := moduleTemplateWithClient(e, mock, map[string]any{
|
||||
"src": srcPath,
|
||||
"dest": "/etc/nginx/conf.d/app.conf",
|
||||
}, "host1", &Task{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Contains(t, result.Msg, "templated to /etc/nginx/conf.d/app.conf")
|
||||
|
||||
// Verify upload was performed with templated content
|
||||
assert.Equal(t, 1, mock.uploadCount())
|
||||
up := mock.lastUpload()
|
||||
require.NotNil(t, up)
|
||||
assert.Equal(t, "/etc/nginx/conf.d/app.conf", up.Remote)
|
||||
// Template replaces {{ var }} — the TemplateFile does Jinja2 to Go conversion
|
||||
assert.Contains(t, string(up.Content), "web01.example.com")
|
||||
}
|
||||
|
||||
func TestModuleTemplate_Good_CustomMode(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srcPath := filepath.Join(tmpDir, "script.sh.j2")
|
||||
require.NoError(t, os.WriteFile(srcPath, []byte("#!/bin/bash\necho done"), 0644))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleTemplateWithClient(e, mock, map[string]any{
|
||||
"src": srcPath,
|
||||
"dest": "/usr/local/bin/run.sh",
|
||||
"mode": "0755",
|
||||
}, "host1", &Task{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
up := mock.lastUpload()
|
||||
require.NotNil(t, up)
|
||||
assert.Equal(t, os.FileMode(0755), up.Mode)
|
||||
}
|
||||
|
||||
func TestModuleTemplate_Bad_MissingSrc(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleTemplateWithClient(e, mock, map[string]any{
|
||||
"dest": "/tmp/out",
|
||||
}, "host1", &Task{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "src and dest required")
|
||||
}
|
||||
|
||||
func TestModuleTemplate_Bad_MissingDest(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleTemplateWithClient(e, mock, map[string]any{
|
||||
"src": "/tmp/in.j2",
|
||||
}, "host1", &Task{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "src and dest required")
|
||||
}
|
||||
|
||||
func TestModuleTemplate_Bad_SrcFileNotFound(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
_, err := moduleTemplateWithClient(e, mock, map[string]any{
|
||||
"src": "/nonexistent/template.j2",
|
||||
"dest": "/tmp/out",
|
||||
}, "host1", &Task{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "template")
|
||||
}
|
||||
|
||||
func TestModuleTemplate_Good_PlainTextNoVars(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srcPath := filepath.Join(tmpDir, "static.conf")
|
||||
content := "listen 80;\nserver_name localhost;"
|
||||
require.NoError(t, os.WriteFile(srcPath, []byte(content), 0644))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleTemplateWithClient(e, mock, map[string]any{
|
||||
"src": srcPath,
|
||||
"dest": "/etc/config",
|
||||
}, "host1", &Task{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
up := mock.lastUpload()
|
||||
require.NotNil(t, up)
|
||||
assert.Equal(t, content, string(up.Content))
|
||||
}
|
||||
|
||||
// --- Cross-module dispatch tests for file modules ---
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchCopy(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
task := &Task{
|
||||
Module: "copy",
|
||||
Args: map[string]any{
|
||||
"content": "hello world",
|
||||
"dest": "/tmp/hello.txt",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, 1, mock.uploadCount())
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchFile(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
task := &Task{
|
||||
Module: "file",
|
||||
Args: map[string]any{
|
||||
"path": "/opt/data",
|
||||
"state": "directory",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted("mkdir"))
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchStat(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.addStat("/etc/hosts", map[string]any{"exists": true, "isdir": false})
|
||||
|
||||
task := &Task{
|
||||
Module: "stat",
|
||||
Args: map[string]any{
|
||||
"path": "/etc/hosts",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Changed)
|
||||
stat := result.Data["stat"].(map[string]any)
|
||||
assert.Equal(t, true, stat["exists"])
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchLineinfile(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
task := &Task{
|
||||
Module: "lineinfile",
|
||||
Args: map[string]any{
|
||||
"path": "/etc/hosts",
|
||||
"line": "10.0.0.1 dbhost",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchBlockinfile(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
task := &Task{
|
||||
Module: "blockinfile",
|
||||
Args: map[string]any{
|
||||
"path": "/etc/config",
|
||||
"block": "key=value",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchTemplate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srcPath := filepath.Join(tmpDir, "test.j2")
|
||||
require.NoError(t, os.WriteFile(srcPath, []byte("static content"), 0644))
|
||||
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
task := &Task{
|
||||
Module: "template",
|
||||
Args: map[string]any{
|
||||
"src": srcPath,
|
||||
"dest": "/etc/out.conf",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, 1, mock.uploadCount())
|
||||
}
|
||||
|
||||
// --- Template variable resolution integration ---
|
||||
|
||||
func TestModuleCopy_Good_TemplatedArgs(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
e.SetVar("deploy_path", "/opt/myapp")
|
||||
|
||||
task := &Task{
|
||||
Module: "copy",
|
||||
Args: map[string]any{
|
||||
"content": "deployed",
|
||||
"dest": "{{ deploy_path }}/config.yml",
|
||||
},
|
||||
}
|
||||
|
||||
// Template the args as the executor does
|
||||
args := e.templateArgs(task.Args, "host1", task)
|
||||
result, err := moduleCopyWithClient(e, mock, args, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
|
||||
up := mock.lastUpload()
|
||||
require.NotNil(t, up)
|
||||
assert.Equal(t, "/opt/myapp/config.yml", up.Remote)
|
||||
}
|
||||
|
||||
func TestModuleFile_Good_TemplatedPath(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
e.SetVar("app_dir", "/var/www/html")
|
||||
|
||||
task := &Task{
|
||||
Module: "file",
|
||||
Args: map[string]any{
|
||||
"path": "{{ app_dir }}/uploads",
|
||||
"state": "directory",
|
||||
"owner": "www-data",
|
||||
},
|
||||
}
|
||||
|
||||
args := e.templateArgs(task.Args, "host1", task)
|
||||
result, err := moduleFileWithClient(e, mock, args)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`mkdir -p "/var/www/html/uploads"`))
|
||||
assert.True(t, mock.hasExecuted(`chown www-data "/var/www/html/uploads"`))
|
||||
}
|
||||
1261
modules_infra_test.go
Normal file
1261
modules_infra_test.go
Normal file
File diff suppressed because it is too large
Load diff
950
modules_svc_test.go
Normal file
950
modules_svc_test.go
Normal file
|
|
@ -0,0 +1,950 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Step 1.3: service / systemd / apt / apt_key / apt_repository / package / pip module tests
|
||||
// ============================================================
|
||||
|
||||
// --- service module ---
|
||||
|
||||
func TestModuleService_Good_Start(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl start nginx`, "Started", "", 0)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "started",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl start nginx`))
|
||||
assert.Equal(t, 1, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModuleService_Good_Stop(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl stop nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "stopped",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl stop nginx`))
|
||||
}
|
||||
|
||||
func TestModuleService_Good_Restart(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl restart docker`, "", "", 0)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "docker",
|
||||
"state": "restarted",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl restart docker`))
|
||||
}
|
||||
|
||||
func TestModuleService_Good_Reload(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl reload nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "reloaded",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl reload nginx`))
|
||||
}
|
||||
|
||||
func TestModuleService_Good_Enable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"enabled": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
|
||||
}
|
||||
|
||||
func TestModuleService_Good_Disable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl disable nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"enabled": false,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl disable nginx`))
|
||||
}
|
||||
|
||||
func TestModuleService_Good_StartAndEnable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl start nginx`, "", "", 0)
|
||||
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "started",
|
||||
"enabled": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.Equal(t, 2, mock.commandCount())
|
||||
assert.True(t, mock.hasExecuted(`systemctl start nginx`))
|
||||
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
|
||||
}
|
||||
|
||||
func TestModuleService_Good_RestartAndDisable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl restart sshd`, "", "", 0)
|
||||
mock.expectCommand(`systemctl disable sshd`, "", "", 0)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "sshd",
|
||||
"state": "restarted",
|
||||
"enabled": false,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.Equal(t, 2, mock.commandCount())
|
||||
assert.True(t, mock.hasExecuted(`systemctl restart sshd`))
|
||||
assert.True(t, mock.hasExecuted(`systemctl disable sshd`))
|
||||
}
|
||||
|
||||
func TestModuleService_Bad_MissingName(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
_, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"state": "started",
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "name required")
|
||||
}
|
||||
|
||||
func TestModuleService_Good_NoStateNoEnabled(t *testing.T) {
|
||||
// When neither state nor enabled is provided, no commands run
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModuleService_Good_CommandFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl start.*`, "", "Failed to start nginx.service", 1)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "started",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Contains(t, result.Msg, "Failed to start nginx.service")
|
||||
assert.Equal(t, 1, result.RC)
|
||||
}
|
||||
|
||||
func TestModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
|
||||
// When state command fails, enable should not run
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl start`, "", "unit not found", 5)
|
||||
|
||||
result, err := moduleServiceWithClient(e, mock, map[string]any{
|
||||
"name": "nonexistent",
|
||||
"state": "started",
|
||||
"enabled": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
// Only the start command should have been attempted
|
||||
assert.Equal(t, 1, mock.commandCount())
|
||||
assert.False(t, mock.hasExecuted(`systemctl enable`))
|
||||
}
|
||||
|
||||
// --- systemd module ---
|
||||
|
||||
func TestModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||
mock.expectCommand(`systemctl start nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleSystemdWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "started",
|
||||
"daemon_reload": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
|
||||
// daemon-reload must run first, then start
|
||||
cmds := mock.executedCommands()
|
||||
require.GreaterOrEqual(t, len(cmds), 2)
|
||||
assert.Contains(t, cmds[0].Cmd, "daemon-reload")
|
||||
assert.Contains(t, cmds[1].Cmd, "systemctl start nginx")
|
||||
}
|
||||
|
||||
func TestModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||
|
||||
result, err := moduleSystemdWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"daemon_reload": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
// daemon-reload runs, but no state/enabled means no further commands
|
||||
// Changed is false because moduleService returns Changed: len(cmds) > 0
|
||||
// and no cmds were built (no state, no enabled)
|
||||
assert.False(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
|
||||
}
|
||||
|
||||
func TestModuleSystemd_Good_DelegationToService(t *testing.T) {
|
||||
// Without daemon_reload, systemd delegates entirely to service
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl restart docker`, "", "", 0)
|
||||
|
||||
result, err := moduleSystemdWithClient(e, mock, map[string]any{
|
||||
"name": "docker",
|
||||
"state": "restarted",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl restart docker`))
|
||||
// No daemon-reload should have run
|
||||
assert.False(t, mock.hasExecuted(`daemon-reload`))
|
||||
}
|
||||
|
||||
func TestModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||
mock.expectCommand(`systemctl enable myapp`, "", "", 0)
|
||||
|
||||
result, err := moduleSystemdWithClient(e, mock, map[string]any{
|
||||
"name": "myapp",
|
||||
"enabled": true,
|
||||
"daemon_reload": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
|
||||
assert.True(t, mock.hasExecuted(`systemctl enable myapp`))
|
||||
}
|
||||
|
||||
// --- apt module ---
|
||||
|
||||
func TestModuleApt_Good_InstallPresent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq nginx`, "installed", "", 0)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "present",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx`))
|
||||
}
|
||||
|
||||
func TestModuleApt_Good_InstallInstalled(t *testing.T) {
|
||||
// state=installed is an alias for present
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq curl`, "", "", 0)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"name": "curl",
|
||||
"state": "installed",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq curl`))
|
||||
}
|
||||
|
||||
func TestModuleApt_Good_RemoveAbsent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq nginx`))
|
||||
}
|
||||
|
||||
func TestModuleApt_Good_RemoveRemoved(t *testing.T) {
|
||||
// state=removed is an alias for absent
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "removed",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nginx`))
|
||||
}
|
||||
|
||||
func TestModuleApt_Good_UpgradeLatest(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq --only-upgrade nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "latest",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade nginx`))
|
||||
}
|
||||
|
||||
func TestModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "present",
|
||||
"update_cache": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
|
||||
// apt-get update must run before install
|
||||
cmds := mock.executedCommands()
|
||||
require.GreaterOrEqual(t, len(cmds), 2)
|
||||
assert.Contains(t, cmds[0].Cmd, "apt-get update")
|
||||
assert.Contains(t, cmds[1].Cmd, "apt-get install")
|
||||
}
|
||||
|
||||
func TestModuleApt_Good_UpdateCacheOnly(t *testing.T) {
|
||||
// update_cache with no name means update only, no install
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"update_cache": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
// No package to install → not changed (cmd is empty)
|
||||
assert.False(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`apt-get update`))
|
||||
}
|
||||
|
||||
func TestModuleApt_Good_CommandFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install`, "", "E: Unable to locate package badpkg", 100)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"name": "badpkg",
|
||||
"state": "present",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Contains(t, result.Msg, "Unable to locate package")
|
||||
assert.Equal(t, 100, result.RC)
|
||||
}
|
||||
|
||||
func TestModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
|
||||
// If no state is given, default is "present" (install)
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq vim`, "", "", 0)
|
||||
|
||||
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||
"name": "vim",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
|
||||
}
|
||||
|
||||
// --- apt_key module ---
|
||||
|
||||
func TestModuleAptKey_Good_AddWithKeyring(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`curl -fsSL.*gpg --dearmor`, "", "", 0)
|
||||
|
||||
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
|
||||
"url": "https://packages.example.com/key.gpg",
|
||||
"keyring": "/etc/apt/keyrings/example.gpg",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`curl -fsSL`))
|
||||
assert.True(t, mock.hasExecuted(`gpg --dearmor -o`))
|
||||
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/example.gpg"))
|
||||
}
|
||||
|
||||
func TestModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`curl -fsSL.*apt-key add -`, "", "", 0)
|
||||
|
||||
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
|
||||
"url": "https://packages.example.com/key.gpg",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`apt-key add -`))
|
||||
}
|
||||
|
||||
func TestModuleAptKey_Good_RemoveKey(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
|
||||
"keyring": "/etc/apt/keyrings/old.gpg",
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`rm -f`))
|
||||
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/old.gpg"))
|
||||
}
|
||||
|
||||
func TestModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
|
||||
// Absent with no keyring — still succeeds, just no rm command
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.Equal(t, 0, mock.commandCount())
|
||||
}
|
||||
|
||||
func TestModuleAptKey_Bad_MissingURL(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
_, err := moduleAptKeyWithClient(e, mock, map[string]any{
|
||||
"state": "present",
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "url required")
|
||||
}
|
||||
|
||||
func TestModuleAptKey_Good_CommandFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`curl`, "", "curl: (22) 404 Not Found", 22)
|
||||
|
||||
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
|
||||
"url": "https://invalid.example.com/key.gpg",
|
||||
"keyring": "/etc/apt/keyrings/bad.gpg",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Contains(t, result.Msg, "404 Not Found")
|
||||
}
|
||||
|
||||
// --- apt_repository module ---
|
||||
|
||||
func TestModuleAptRepository_Good_AddRepository(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo.*sources\.list\.d`, "", "", 0)
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
||||
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||
"repo": "deb https://packages.example.com/apt stable main",
|
||||
"filename": "example",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/example.list"))
|
||||
}
|
||||
|
||||
func TestModuleAptRepository_Good_RemoveRepository(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
|
||||
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||
"repo": "deb https://packages.example.com/apt stable main",
|
||||
"filename": "example",
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`rm -f`))
|
||||
assert.True(t, mock.containsSubstring("example.list"))
|
||||
}
|
||||
|
||||
func TestModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
||||
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||
"repo": "deb https://ppa.example.com/repo main",
|
||||
"filename": "ppa-example",
|
||||
"update_cache": true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
|
||||
// update_cache defaults to true, so apt-get update should run
|
||||
assert.True(t, mock.hasExecuted(`apt-get update`))
|
||||
}
|
||||
|
||||
func TestModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
|
||||
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||
"repo": "deb https://ppa.example.com/repo main",
|
||||
"filename": "no-update",
|
||||
"update_cache": false,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
|
||||
// update_cache=false, so no apt-get update
|
||||
assert.False(t, mock.hasExecuted(`apt-get update`))
|
||||
}
|
||||
|
||||
func TestModuleAptRepository_Good_CustomFilename(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
||||
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||
"repo": "deb http://ppa.launchpad.net/test/ppa/ubuntu jammy main",
|
||||
"filename": "custom-ppa",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/custom-ppa.list"))
|
||||
}
|
||||
|
||||
func TestModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T) {
|
||||
// When no filename is given, it auto-generates from the repo string
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
||||
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||
"repo": "deb https://example.com/repo main",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
// Filename should be derived from repo: spaces→dashes, slashes→dashes, colons removed
|
||||
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/"))
|
||||
}
|
||||
|
||||
func TestModuleAptRepository_Bad_MissingRepo(t *testing.T) {
|
||||
e, _ := newTestExecutorWithMock("host1")
|
||||
mock := NewMockSSHClient()
|
||||
|
||||
_, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||
"filename": "test",
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "repo required")
|
||||
}
|
||||
|
||||
func TestModuleAptRepository_Good_WriteFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "permission denied", 1)
|
||||
|
||||
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||
"repo": "deb https://example.com/repo main",
|
||||
"filename": "test",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Contains(t, result.Msg, "permission denied")
|
||||
}
|
||||
|
||||
// --- package module ---
|
||||
|
||||
func TestModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
// First command: which apt-get returns the path
|
||||
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
||||
// Second command: the actual apt install
|
||||
mock.expectCommand(`apt-get install -y -qq htop`, "", "", 0)
|
||||
|
||||
result, err := modulePackageWithClient(e, mock, map[string]any{
|
||||
"name": "htop",
|
||||
"state": "present",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`which apt-get`))
|
||||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq htop`))
|
||||
}
|
||||
|
||||
func TestModulePackage_Good_FallbackToApt(t *testing.T) {
|
||||
// When which returns nothing (no package manager found), still falls back to apt
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`which apt-get`, "", "", 1)
|
||||
mock.expectCommand(`apt-get install -y -qq vim`, "", "", 0)
|
||||
|
||||
result, err := modulePackageWithClient(e, mock, map[string]any{
|
||||
"name": "vim",
|
||||
"state": "present",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
|
||||
}
|
||||
|
||||
func TestModulePackage_Good_RemovePackage(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
||||
mock.expectCommand(`apt-get remove -y -qq nano`, "", "", 0)
|
||||
|
||||
result, err := modulePackageWithClient(e, mock, map[string]any{
|
||||
"name": "nano",
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nano`))
|
||||
}
|
||||
|
||||
// --- pip module ---
|
||||
|
||||
func TestModulePip_Good_InstallPresent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install flask`, "Successfully installed", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": "flask",
|
||||
"state": "present",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`pip3 install flask`))
|
||||
}
|
||||
|
||||
func TestModulePip_Good_UninstallAbsent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 uninstall -y flask`, "Successfully uninstalled", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": "flask",
|
||||
"state": "absent",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`pip3 uninstall -y flask`))
|
||||
}
|
||||
|
||||
func TestModulePip_Good_UpgradeLatest(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install --upgrade flask`, "Successfully installed", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": "flask",
|
||||
"state": "latest",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`pip3 install --upgrade flask`))
|
||||
}
|
||||
|
||||
func TestModulePip_Good_CustomExecutable(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`/opt/venv/bin/pip install requests`, "", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": "requests",
|
||||
"state": "present",
|
||||
"executable": "/opt/venv/bin/pip",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.True(t, mock.hasExecuted(`/opt/venv/bin/pip install requests`))
|
||||
}
|
||||
|
||||
func TestModulePip_Good_DefaultStateIsPresent(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install django`, "", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": "django",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`pip3 install django`))
|
||||
}
|
||||
|
||||
func TestModulePip_Good_CommandFailure(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install`, "", "ERROR: No matching distribution found", 1)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": "nonexistent-pkg-xyz",
|
||||
"state": "present",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Failed)
|
||||
assert.Contains(t, result.Msg, "No matching distribution found")
|
||||
}
|
||||
|
||||
func TestModulePip_Good_InstalledAlias(t *testing.T) {
|
||||
// state=installed is an alias for present
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install boto3`, "", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": "boto3",
|
||||
"state": "installed",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`pip3 install boto3`))
|
||||
}
|
||||
|
||||
func TestModulePip_Good_RemovedAlias(t *testing.T) {
|
||||
// state=removed is an alias for absent
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 uninstall -y boto3`, "", "", 0)
|
||||
|
||||
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||
"name": "boto3",
|
||||
"state": "removed",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`pip3 uninstall -y boto3`))
|
||||
}
|
||||
|
||||
// --- Cross-module dispatch tests ---
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl restart nginx`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "service",
|
||||
Args: map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "restarted",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl restart nginx`))
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||
mock.expectCommand(`systemctl start myapp`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "ansible.builtin.systemd",
|
||||
Args: map[string]any{
|
||||
"name": "myapp",
|
||||
"state": "started",
|
||||
"daemon_reload": true,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
|
||||
assert.True(t, mock.hasExecuted(`systemctl start myapp`))
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "apt",
|
||||
Args: map[string]any{
|
||||
"name": "nginx",
|
||||
"state": "present",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`apt-get install`))
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`curl.*gpg`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "apt_key",
|
||||
Args: map[string]any{
|
||||
"url": "https://example.com/key.gpg",
|
||||
"keyring": "/etc/apt/keyrings/example.gpg",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`echo`, "", "", 0)
|
||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "apt_repository",
|
||||
Args: map[string]any{
|
||||
"repo": "deb https://example.com/repo main",
|
||||
"filename": "example",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
||||
mock.expectCommand(`apt-get install -y -qq git`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "package",
|
||||
Args: map[string]any{
|
||||
"name": "git",
|
||||
"state": "present",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
}
|
||||
|
||||
func TestExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
|
||||
e, mock := newTestExecutorWithMock("host1")
|
||||
mock.expectCommand(`pip3 install ansible`, "", "", 0)
|
||||
|
||||
task := &Task{
|
||||
Module: "pip",
|
||||
Args: map[string]any{
|
||||
"name": "ansible",
|
||||
"state": "present",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Changed)
|
||||
assert.True(t, mock.hasExecuted(`pip3 install ansible`))
|
||||
}
|
||||
510
parser.go
Normal file
510
parser.go
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go-log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Parser handles Ansible YAML parsing.
|
||||
type Parser struct {
|
||||
basePath string
|
||||
vars map[string]any
|
||||
}
|
||||
|
||||
// NewParser creates a new Ansible parser.
|
||||
func NewParser(basePath string) *Parser {
|
||||
return &Parser{
|
||||
basePath: basePath,
|
||||
vars: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePlaybook parses an Ansible playbook file.
|
||||
func (p *Parser) ParsePlaybook(path string) ([]Play, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read playbook: %w", err)
|
||||
}
|
||||
|
||||
var plays []Play
|
||||
if err := yaml.Unmarshal(data, &plays); err != nil {
|
||||
return nil, fmt.Errorf("parse playbook: %w", err)
|
||||
}
|
||||
|
||||
// Process each play
|
||||
for i := range plays {
|
||||
if err := p.processPlay(&plays[i]); err != nil {
|
||||
return nil, fmt.Errorf("process play %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return plays, nil
|
||||
}
|
||||
|
||||
// ParsePlaybookIter returns an iterator for plays in an Ansible playbook file.
|
||||
func (p *Parser) ParsePlaybookIter(path string) (iter.Seq[Play], error) {
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(yield func(Play) bool) {
|
||||
for _, play := range plays {
|
||||
if !yield(play) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseInventory parses an Ansible inventory file.
|
||||
func (p *Parser) ParseInventory(path string) (*Inventory, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read inventory: %w", err)
|
||||
}
|
||||
|
||||
var inv Inventory
|
||||
if err := yaml.Unmarshal(data, &inv); err != nil {
|
||||
return nil, fmt.Errorf("parse inventory: %w", err)
|
||||
}
|
||||
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
// ParseTasks parses a tasks file (used by include_tasks).
|
||||
func (p *Parser) ParseTasks(path string) ([]Task, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read tasks: %w", err)
|
||||
}
|
||||
|
||||
var tasks []Task
|
||||
if err := yaml.Unmarshal(data, &tasks); err != nil {
|
||||
return nil, fmt.Errorf("parse tasks: %w", err)
|
||||
}
|
||||
|
||||
for i := range tasks {
|
||||
if err := p.extractModule(&tasks[i]); err != nil {
|
||||
return nil, fmt.Errorf("task %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// ParseTasksIter returns an iterator for tasks in a tasks file.
|
||||
func (p *Parser) ParseTasksIter(path string) (iter.Seq[Task], error) {
|
||||
tasks, err := p.ParseTasks(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(yield func(Task) bool) {
|
||||
for _, task := range tasks {
|
||||
if !yield(task) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseRole parses a role and returns its tasks.
|
||||
func (p *Parser) ParseRole(name string, tasksFrom string) ([]Task, error) {
|
||||
if tasksFrom == "" {
|
||||
tasksFrom = "main.yml"
|
||||
}
|
||||
|
||||
// Search paths for roles (in order of precedence)
|
||||
searchPaths := []string{
|
||||
// Relative to playbook
|
||||
filepath.Join(p.basePath, "roles", name, "tasks", tasksFrom),
|
||||
// Parent directory roles
|
||||
filepath.Join(filepath.Dir(p.basePath), "roles", name, "tasks", tasksFrom),
|
||||
// Sibling roles directory
|
||||
filepath.Join(p.basePath, "..", "roles", name, "tasks", tasksFrom),
|
||||
// playbooks/roles pattern
|
||||
filepath.Join(p.basePath, "playbooks", "roles", name, "tasks", tasksFrom),
|
||||
// Common DevOps structure
|
||||
filepath.Join(filepath.Dir(filepath.Dir(p.basePath)), "roles", name, "tasks", tasksFrom),
|
||||
}
|
||||
|
||||
var tasksPath string
|
||||
for _, sp := range searchPaths {
|
||||
// Clean the path to resolve .. segments
|
||||
sp = filepath.Clean(sp)
|
||||
if _, err := os.Stat(sp); err == nil {
|
||||
tasksPath = sp
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tasksPath == "" {
|
||||
return nil, log.E("parser.ParseRole", fmt.Sprintf("role %s not found in search paths: %v", name, searchPaths), nil)
|
||||
}
|
||||
|
||||
// Load role defaults
|
||||
defaultsPath := filepath.Join(filepath.Dir(filepath.Dir(tasksPath)), "defaults", "main.yml")
|
||||
if data, err := os.ReadFile(defaultsPath); err == nil {
|
||||
var defaults map[string]any
|
||||
if yaml.Unmarshal(data, &defaults) == nil {
|
||||
for k, v := range defaults {
|
||||
if _, exists := p.vars[k]; !exists {
|
||||
p.vars[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load role vars
|
||||
varsPath := filepath.Join(filepath.Dir(filepath.Dir(tasksPath)), "vars", "main.yml")
|
||||
if data, err := os.ReadFile(varsPath); err == nil {
|
||||
var roleVars map[string]any
|
||||
if yaml.Unmarshal(data, &roleVars) == nil {
|
||||
for k, v := range roleVars {
|
||||
p.vars[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p.ParseTasks(tasksPath)
|
||||
}
|
||||
|
||||
// processPlay processes a play and extracts modules from tasks.
|
||||
func (p *Parser) processPlay(play *Play) error {
|
||||
// Merge play vars
|
||||
for k, v := range play.Vars {
|
||||
p.vars[k] = v
|
||||
}
|
||||
|
||||
for i := range play.PreTasks {
|
||||
if err := p.extractModule(&play.PreTasks[i]); err != nil {
|
||||
return fmt.Errorf("pre_task %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range play.Tasks {
|
||||
if err := p.extractModule(&play.Tasks[i]); err != nil {
|
||||
return fmt.Errorf("task %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range play.PostTasks {
|
||||
if err := p.extractModule(&play.PostTasks[i]); err != nil {
|
||||
return fmt.Errorf("post_task %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range play.Handlers {
|
||||
if err := p.extractModule(&play.Handlers[i]); err != nil {
|
||||
return fmt.Errorf("handler %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractModule extracts the module name and args from a task.
|
||||
func (p *Parser) extractModule(task *Task) error {
|
||||
// First, unmarshal the raw YAML to get all keys
|
||||
// This is a workaround since we need to find the module key dynamically
|
||||
|
||||
// Handle block tasks
|
||||
for i := range task.Block {
|
||||
if err := p.extractModule(&task.Block[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i := range task.Rescue {
|
||||
if err := p.extractModule(&task.Rescue[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i := range task.Always {
|
||||
if err := p.extractModule(&task.Always[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements custom YAML unmarshaling for Task.
|
||||
func (t *Task) UnmarshalYAML(node *yaml.Node) error {
|
||||
// First decode known fields
|
||||
type rawTask Task
|
||||
var raw rawTask
|
||||
|
||||
// Create a map to capture all fields
|
||||
var m map[string]any
|
||||
if err := node.Decode(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Decode into struct
|
||||
if err := node.Decode(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*t = Task(raw)
|
||||
t.raw = m
|
||||
|
||||
// Find the module key
|
||||
knownKeys := map[string]bool{
|
||||
"name": true, "register": true, "when": true, "loop": true,
|
||||
"loop_control": true, "vars": true, "environment": true,
|
||||
"changed_when": true, "failed_when": true, "ignore_errors": true,
|
||||
"no_log": true, "become": true, "become_user": true,
|
||||
"delegate_to": true, "run_once": true, "tags": true,
|
||||
"block": true, "rescue": true, "always": true, "notify": true,
|
||||
"retries": true, "delay": true, "until": true,
|
||||
"include_tasks": true, "import_tasks": true,
|
||||
"include_role": true, "import_role": true,
|
||||
"with_items": true, "with_dict": true, "with_file": true,
|
||||
}
|
||||
|
||||
for key, val := range m {
|
||||
if knownKeys[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a module
|
||||
if isModule(key) {
|
||||
t.Module = key
|
||||
t.Args = make(map[string]any)
|
||||
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
// Free-form args (e.g., shell: echo hello)
|
||||
t.Args["_raw_params"] = v
|
||||
case map[string]any:
|
||||
t.Args = v
|
||||
case nil:
|
||||
// Module with no args
|
||||
default:
|
||||
t.Args["_raw_params"] = v
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Handle with_items as loop
|
||||
if items, ok := m["with_items"]; ok && t.Loop == nil {
|
||||
t.Loop = items
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isModule checks if a key is a known module.
|
||||
func isModule(key string) bool {
|
||||
for _, m := range KnownModules {
|
||||
if key == m {
|
||||
return true
|
||||
}
|
||||
// Also check without ansible.builtin. prefix
|
||||
if strings.HasPrefix(m, "ansible.builtin.") {
|
||||
if key == strings.TrimPrefix(m, "ansible.builtin.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Accept any key with dots (likely a module)
|
||||
return strings.Contains(key, ".")
|
||||
}
|
||||
|
||||
// NormalizeModule normalizes a module name to its canonical form.
|
||||
func NormalizeModule(name string) string {
|
||||
// Add ansible.builtin. prefix if missing
|
||||
if !strings.Contains(name, ".") {
|
||||
return "ansible.builtin." + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// GetHosts returns hosts matching a pattern from inventory.
|
||||
func GetHosts(inv *Inventory, pattern string) []string {
|
||||
if pattern == "all" {
|
||||
return getAllHosts(inv.All)
|
||||
}
|
||||
if pattern == "localhost" {
|
||||
return []string{"localhost"}
|
||||
}
|
||||
|
||||
// Check if it's a group name
|
||||
hosts := getGroupHosts(inv.All, pattern)
|
||||
if len(hosts) > 0 {
|
||||
return hosts
|
||||
}
|
||||
|
||||
// Check if it's a specific host
|
||||
if hasHost(inv.All, pattern) {
|
||||
return []string{pattern}
|
||||
}
|
||||
|
||||
// Handle patterns with : (intersection/union)
|
||||
// For now, just return empty
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHostsIter returns an iterator for hosts matching a pattern from inventory.
|
||||
func GetHostsIter(inv *Inventory, pattern string) iter.Seq[string] {
|
||||
hosts := GetHosts(inv, pattern)
|
||||
return func(yield func(string) bool) {
|
||||
for _, host := range hosts {
|
||||
if !yield(host) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getAllHosts(group *InventoryGroup) []string {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var hosts []string
|
||||
for name := range group.Hosts {
|
||||
hosts = append(hosts, name)
|
||||
}
|
||||
for _, child := range group.Children {
|
||||
hosts = append(hosts, getAllHosts(child)...)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// AllHostsIter returns an iterator for all hosts in an inventory group.
|
||||
func AllHostsIter(group *InventoryGroup) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
if group == nil {
|
||||
return
|
||||
}
|
||||
// Sort keys for deterministic iteration
|
||||
keys := slices.Sorted(maps.Keys(group.Hosts))
|
||||
for _, name := range keys {
|
||||
if !yield(name) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children keys for deterministic iteration
|
||||
childKeys := slices.Sorted(maps.Keys(group.Children))
|
||||
for _, name := range childKeys {
|
||||
child := group.Children[name]
|
||||
for host := range AllHostsIter(child) {
|
||||
if !yield(host) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getGroupHosts(group *InventoryGroup, name string) []string {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check children for the group name
|
||||
if child, ok := group.Children[name]; ok {
|
||||
return getAllHosts(child)
|
||||
}
|
||||
|
||||
// Recurse
|
||||
for _, child := range group.Children {
|
||||
if hosts := getGroupHosts(child, name); len(hosts) > 0 {
|
||||
return hosts
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasHost(group *InventoryGroup, name string) bool {
|
||||
if group == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := group.Hosts[name]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, child := range group.Children {
|
||||
if hasHost(child, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetHostVars returns variables for a specific host.
|
||||
func GetHostVars(inv *Inventory, hostname string) map[string]any {
|
||||
vars := make(map[string]any)
|
||||
|
||||
// Collect vars from all levels
|
||||
collectHostVars(inv.All, hostname, vars)
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
func collectHostVars(group *InventoryGroup, hostname string, vars map[string]any) bool {
|
||||
if group == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if host is in this group
|
||||
found := false
|
||||
if host, ok := group.Hosts[hostname]; ok {
|
||||
found = true
|
||||
// Apply group vars first
|
||||
for k, v := range group.Vars {
|
||||
vars[k] = v
|
||||
}
|
||||
// Then host vars
|
||||
if host != nil {
|
||||
if host.AnsibleHost != "" {
|
||||
vars["ansible_host"] = host.AnsibleHost
|
||||
}
|
||||
if host.AnsiblePort != 0 {
|
||||
vars["ansible_port"] = host.AnsiblePort
|
||||
}
|
||||
if host.AnsibleUser != "" {
|
||||
vars["ansible_user"] = host.AnsibleUser
|
||||
}
|
||||
if host.AnsiblePassword != "" {
|
||||
vars["ansible_password"] = host.AnsiblePassword
|
||||
}
|
||||
if host.AnsibleSSHPrivateKeyFile != "" {
|
||||
vars["ansible_ssh_private_key_file"] = host.AnsibleSSHPrivateKeyFile
|
||||
}
|
||||
if host.AnsibleConnection != "" {
|
||||
vars["ansible_connection"] = host.AnsibleConnection
|
||||
}
|
||||
for k, v := range host.Vars {
|
||||
vars[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check children
|
||||
for _, child := range group.Children {
|
||||
if collectHostVars(child, hostname, vars) {
|
||||
// Apply this group's vars (parent vars)
|
||||
for k, v := range group.Vars {
|
||||
if _, exists := vars[k]; !exists {
|
||||
vars[k] = v
|
||||
}
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
777
parser_test.go
Normal file
777
parser_test.go
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- ParsePlaybook ---
|
||||
|
||||
func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Configure webserver
|
||||
hosts: webservers
|
||||
become: true
|
||||
tasks:
|
||||
- name: Install nginx
|
||||
apt:
|
||||
name: nginx
|
||||
state: present
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 1)
|
||||
assert.Equal(t, "Configure webserver", plays[0].Name)
|
||||
assert.Equal(t, "webservers", plays[0].Hosts)
|
||||
assert.True(t, plays[0].Become)
|
||||
require.Len(t, plays[0].Tasks, 1)
|
||||
assert.Equal(t, "Install nginx", plays[0].Tasks[0].Name)
|
||||
assert.Equal(t, "apt", plays[0].Tasks[0].Module)
|
||||
assert.Equal(t, "nginx", plays[0].Tasks[0].Args["name"])
|
||||
assert.Equal(t, "present", plays[0].Tasks[0].Args["state"])
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Play one
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Say hello
|
||||
debug:
|
||||
msg: "Hello"
|
||||
|
||||
- name: Play two
|
||||
hosts: localhost
|
||||
connection: local
|
||||
tasks:
|
||||
- name: Say goodbye
|
||||
debug:
|
||||
msg: "Goodbye"
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 2)
|
||||
assert.Equal(t, "Play one", plays[0].Name)
|
||||
assert.Equal(t, "all", plays[0].Hosts)
|
||||
assert.Equal(t, "Play two", plays[1].Name)
|
||||
assert.Equal(t, "localhost", plays[1].Hosts)
|
||||
assert.Equal(t, "local", plays[1].Connection)
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_WithVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: With vars
|
||||
hosts: all
|
||||
vars:
|
||||
http_port: 8080
|
||||
app_name: myapp
|
||||
tasks:
|
||||
- name: Print port
|
||||
debug:
|
||||
msg: "Port is {{ http_port }}"
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 1)
|
||||
assert.Equal(t, 8080, plays[0].Vars["http_port"])
|
||||
assert.Equal(t, "myapp", plays[0].Vars["app_name"])
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Full lifecycle
|
||||
hosts: all
|
||||
pre_tasks:
|
||||
- name: Pre task
|
||||
debug:
|
||||
msg: "pre"
|
||||
tasks:
|
||||
- name: Main task
|
||||
debug:
|
||||
msg: "main"
|
||||
post_tasks:
|
||||
- name: Post task
|
||||
debug:
|
||||
msg: "post"
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 1)
|
||||
assert.Len(t, plays[0].PreTasks, 1)
|
||||
assert.Len(t, plays[0].Tasks, 1)
|
||||
assert.Len(t, plays[0].PostTasks, 1)
|
||||
assert.Equal(t, "Pre task", plays[0].PreTasks[0].Name)
|
||||
assert.Equal(t, "Main task", plays[0].Tasks[0].Name)
|
||||
assert.Equal(t, "Post task", plays[0].PostTasks[0].Name)
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_Handlers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: With handlers
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Install package
|
||||
apt:
|
||||
name: nginx
|
||||
notify: restart nginx
|
||||
handlers:
|
||||
- name: restart nginx
|
||||
service:
|
||||
name: nginx
|
||||
state: restarted
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays, 1)
|
||||
assert.Len(t, plays[0].Handlers, 1)
|
||||
assert.Equal(t, "restart nginx", plays[0].Handlers[0].Name)
|
||||
assert.Equal(t, "service", plays[0].Handlers[0].Module)
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Shell tasks
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Run a command
|
||||
shell: echo hello world
|
||||
- name: Run raw command
|
||||
command: ls -la /tmp
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays[0].Tasks, 2)
|
||||
assert.Equal(t, "shell", plays[0].Tasks[0].Module)
|
||||
assert.Equal(t, "echo hello world", plays[0].Tasks[0].Args["_raw_params"])
|
||||
assert.Equal(t, "command", plays[0].Tasks[1].Module)
|
||||
assert.Equal(t, "ls -la /tmp", plays[0].Tasks[1].Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_WithTags(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Tagged play
|
||||
hosts: all
|
||||
tags:
|
||||
- setup
|
||||
tasks:
|
||||
- name: Tagged task
|
||||
debug:
|
||||
msg: "tagged"
|
||||
tags:
|
||||
- debug
|
||||
- always
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"setup"}, plays[0].Tags)
|
||||
assert.Equal(t, []string{"debug", "always"}, plays[0].Tasks[0].Tags)
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: With blocks
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Protected block
|
||||
block:
|
||||
- name: Try this
|
||||
shell: echo try
|
||||
rescue:
|
||||
- name: Handle error
|
||||
debug:
|
||||
msg: "rescued"
|
||||
always:
|
||||
- name: Always runs
|
||||
debug:
|
||||
msg: "always"
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
task := plays[0].Tasks[0]
|
||||
assert.Len(t, task.Block, 1)
|
||||
assert.Len(t, task.Rescue, 1)
|
||||
assert.Len(t, task.Always, 1)
|
||||
assert.Equal(t, "Try this", task.Block[0].Name)
|
||||
assert.Equal(t, "Handle error", task.Rescue[0].Name)
|
||||
assert.Equal(t, "Always runs", task.Always[0].Name)
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_WithLoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Loop test
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Install packages
|
||||
apt:
|
||||
name: "{{ item }}"
|
||||
state: present
|
||||
loop:
|
||||
- vim
|
||||
- curl
|
||||
- git
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
task := plays[0].Tasks[0]
|
||||
assert.Equal(t, "apt", task.Module)
|
||||
items, ok := task.Loop.([]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, items, 3)
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: With roles
|
||||
hosts: all
|
||||
roles:
|
||||
- common
|
||||
- role: webserver
|
||||
vars:
|
||||
http_port: 80
|
||||
tags:
|
||||
- web
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plays[0].Roles, 2)
|
||||
assert.Equal(t, "common", plays[0].Roles[0].Role)
|
||||
assert.Equal(t, "webserver", plays[0].Roles[1].Role)
|
||||
assert.Equal(t, 80, plays[0].Roles[1].Vars["http_port"])
|
||||
assert.Equal(t, []string{"web"}, plays[0].Roles[1].Tags)
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: FQCN modules
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Copy file
|
||||
ansible.builtin.copy:
|
||||
src: /tmp/foo
|
||||
dest: /tmp/bar
|
||||
- name: Run shell
|
||||
ansible.builtin.shell: echo hello
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ansible.builtin.copy", plays[0].Tasks[0].Module)
|
||||
assert.Equal(t, "/tmp/foo", plays[0].Tasks[0].Args["src"])
|
||||
assert.Equal(t, "ansible.builtin.shell", plays[0].Tasks[1].Module)
|
||||
assert.Equal(t, "echo hello", plays[0].Tasks[1].Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: Conditional play
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Check file
|
||||
stat:
|
||||
path: /etc/nginx/nginx.conf
|
||||
register: nginx_conf
|
||||
- name: Show result
|
||||
debug:
|
||||
msg: "File exists"
|
||||
when: nginx_conf.stat.exists
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "nginx_conf", plays[0].Tasks[0].Register)
|
||||
assert.NotNil(t, plays[0].Tasks[1].When)
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
require.NoError(t, os.WriteFile(path, []byte("---\n[]"), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, plays)
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Bad_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.yml")
|
||||
|
||||
require.NoError(t, os.WriteFile(path, []byte("{{invalid yaml}}"), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
_, err := p.ParsePlaybook(path)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "parse playbook")
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Bad_FileNotFound(t *testing.T) {
|
||||
p := NewParser(t.TempDir())
|
||||
_, err := p.ParsePlaybook("/nonexistent/playbook.yml")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read playbook")
|
||||
}
|
||||
|
||||
func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "playbook.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: No facts
|
||||
hosts: all
|
||||
gather_facts: false
|
||||
tasks: []
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
plays, err := p.ParsePlaybook(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, plays[0].GatherFacts)
|
||||
assert.False(t, *plays[0].GatherFacts)
|
||||
}
|
||||
|
||||
// --- ParseInventory ---
|
||||
|
||||
func TestParseInventory_Good_SimpleInventory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "inventory.yml")
|
||||
|
||||
yaml := `---
|
||||
all:
|
||||
hosts:
|
||||
web1:
|
||||
ansible_host: 192.168.1.10
|
||||
web2:
|
||||
ansible_host: 192.168.1.11
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
inv, err := p.ParseInventory(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inv.All)
|
||||
assert.Len(t, inv.All.Hosts, 2)
|
||||
assert.Equal(t, "192.168.1.10", inv.All.Hosts["web1"].AnsibleHost)
|
||||
assert.Equal(t, "192.168.1.11", inv.All.Hosts["web2"].AnsibleHost)
|
||||
}
|
||||
|
||||
func TestParseInventory_Good_WithGroups(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "inventory.yml")
|
||||
|
||||
yaml := `---
|
||||
all:
|
||||
children:
|
||||
webservers:
|
||||
hosts:
|
||||
web1:
|
||||
ansible_host: 10.0.0.1
|
||||
web2:
|
||||
ansible_host: 10.0.0.2
|
||||
databases:
|
||||
hosts:
|
||||
db1:
|
||||
ansible_host: 10.0.1.1
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
inv, err := p.ParseInventory(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inv.All.Children["webservers"])
|
||||
assert.Len(t, inv.All.Children["webservers"].Hosts, 2)
|
||||
require.NotNil(t, inv.All.Children["databases"])
|
||||
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
|
||||
}
|
||||
|
||||
func TestParseInventory_Good_WithVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "inventory.yml")
|
||||
|
||||
yaml := `---
|
||||
all:
|
||||
vars:
|
||||
ansible_user: admin
|
||||
children:
|
||||
production:
|
||||
vars:
|
||||
env: prod
|
||||
hosts:
|
||||
prod1:
|
||||
ansible_host: 10.0.0.1
|
||||
ansible_port: 2222
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
inv, err := p.ParseInventory(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "admin", inv.All.Vars["ansible_user"])
|
||||
assert.Equal(t, "prod", inv.All.Children["production"].Vars["env"])
|
||||
assert.Equal(t, 2222, inv.All.Children["production"].Hosts["prod1"].AnsiblePort)
|
||||
}
|
||||
|
||||
func TestParseInventory_Bad_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.yml")
|
||||
|
||||
require.NoError(t, os.WriteFile(path, []byte("{{{bad"), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
_, err := p.ParseInventory(path)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "parse inventory")
|
||||
}
|
||||
|
||||
func TestParseInventory_Bad_FileNotFound(t *testing.T) {
|
||||
p := NewParser(t.TempDir())
|
||||
_, err := p.ParseInventory("/nonexistent/inventory.yml")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read inventory")
|
||||
}
|
||||
|
||||
// --- ParseTasks ---
|
||||
|
||||
func TestParseTasks_Good_TaskFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tasks.yml")
|
||||
|
||||
yaml := `---
|
||||
- name: First task
|
||||
shell: echo first
|
||||
- name: Second task
|
||||
copy:
|
||||
src: /tmp/a
|
||||
dest: /tmp/b
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
tasks, err := p.ParseTasks(path)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tasks, 2)
|
||||
assert.Equal(t, "shell", tasks[0].Module)
|
||||
assert.Equal(t, "echo first", tasks[0].Args["_raw_params"])
|
||||
assert.Equal(t, "copy", tasks[1].Module)
|
||||
assert.Equal(t, "/tmp/a", tasks[1].Args["src"])
|
||||
}
|
||||
|
||||
func TestParseTasks_Bad_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.yml")
|
||||
|
||||
require.NoError(t, os.WriteFile(path, []byte("not: [valid: tasks"), 0644))
|
||||
|
||||
p := NewParser(dir)
|
||||
_, err := p.ParseTasks(path)
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- GetHosts ---
|
||||
|
||||
func TestGetHosts_Good_AllPattern(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{
|
||||
"host1": {},
|
||||
"host2": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
hosts := GetHosts(inv, "all")
|
||||
assert.Len(t, hosts, 2)
|
||||
assert.Contains(t, hosts, "host1")
|
||||
assert.Contains(t, hosts, "host2")
|
||||
}
|
||||
|
||||
func TestGetHosts_Good_LocalhostPattern(t *testing.T) {
|
||||
inv := &Inventory{All: &InventoryGroup{}}
|
||||
hosts := GetHosts(inv, "localhost")
|
||||
assert.Equal(t, []string{"localhost"}, hosts)
|
||||
}
|
||||
|
||||
func TestGetHosts_Good_GroupPattern(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Children: map[string]*InventoryGroup{
|
||||
"web": {
|
||||
Hosts: map[string]*Host{
|
||||
"web1": {},
|
||||
"web2": {},
|
||||
},
|
||||
},
|
||||
"db": {
|
||||
Hosts: map[string]*Host{
|
||||
"db1": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
hosts := GetHosts(inv, "web")
|
||||
assert.Len(t, hosts, 2)
|
||||
assert.Contains(t, hosts, "web1")
|
||||
assert.Contains(t, hosts, "web2")
|
||||
}
|
||||
|
||||
func TestGetHosts_Good_SpecificHost(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Children: map[string]*InventoryGroup{
|
||||
"servers": {
|
||||
Hosts: map[string]*Host{
|
||||
"myhost": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
hosts := GetHosts(inv, "myhost")
|
||||
assert.Equal(t, []string{"myhost"}, hosts)
|
||||
}
|
||||
|
||||
func TestGetHosts_Good_AllIncludesChildren(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{"top": {}},
|
||||
Children: map[string]*InventoryGroup{
|
||||
"group1": {
|
||||
Hosts: map[string]*Host{"child1": {}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
hosts := GetHosts(inv, "all")
|
||||
assert.Len(t, hosts, 2)
|
||||
assert.Contains(t, hosts, "top")
|
||||
assert.Contains(t, hosts, "child1")
|
||||
}
|
||||
|
||||
func TestGetHosts_Bad_NoMatch(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{"host1": {}},
|
||||
},
|
||||
}
|
||||
|
||||
hosts := GetHosts(inv, "nonexistent")
|
||||
assert.Empty(t, hosts)
|
||||
}
|
||||
|
||||
func TestGetHosts_Bad_NilGroup(t *testing.T) {
|
||||
inv := &Inventory{All: nil}
|
||||
hosts := GetHosts(inv, "all")
|
||||
assert.Empty(t, hosts)
|
||||
}
|
||||
|
||||
// --- GetHostVars ---
|
||||
|
||||
func TestGetHostVars_Good_DirectHost(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Vars: map[string]any{"global_var": "global"},
|
||||
Hosts: map[string]*Host{
|
||||
"myhost": {
|
||||
AnsibleHost: "10.0.0.1",
|
||||
AnsiblePort: 2222,
|
||||
AnsibleUser: "deploy",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
vars := GetHostVars(inv, "myhost")
|
||||
assert.Equal(t, "10.0.0.1", vars["ansible_host"])
|
||||
assert.Equal(t, 2222, vars["ansible_port"])
|
||||
assert.Equal(t, "deploy", vars["ansible_user"])
|
||||
assert.Equal(t, "global", vars["global_var"])
|
||||
}
|
||||
|
||||
func TestGetHostVars_Good_InheritedGroupVars(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Vars: map[string]any{"level": "all"},
|
||||
Children: map[string]*InventoryGroup{
|
||||
"production": {
|
||||
Vars: map[string]any{"env": "prod", "level": "group"},
|
||||
Hosts: map[string]*Host{
|
||||
"prod1": {
|
||||
AnsibleHost: "10.0.0.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
vars := GetHostVars(inv, "prod1")
|
||||
assert.Equal(t, "10.0.0.1", vars["ansible_host"])
|
||||
assert.Equal(t, "prod", vars["env"])
|
||||
}
|
||||
|
||||
func TestGetHostVars_Good_HostNotFound(t *testing.T) {
|
||||
inv := &Inventory{
|
||||
All: &InventoryGroup{
|
||||
Hosts: map[string]*Host{"other": {}},
|
||||
},
|
||||
}
|
||||
|
||||
vars := GetHostVars(inv, "nonexistent")
|
||||
assert.Empty(t, vars)
|
||||
}
|
||||
|
||||
// --- isModule ---
|
||||
|
||||
func TestIsModule_Good_KnownModules(t *testing.T) {
|
||||
assert.True(t, isModule("shell"))
|
||||
assert.True(t, isModule("command"))
|
||||
assert.True(t, isModule("copy"))
|
||||
assert.True(t, isModule("file"))
|
||||
assert.True(t, isModule("apt"))
|
||||
assert.True(t, isModule("service"))
|
||||
assert.True(t, isModule("systemd"))
|
||||
assert.True(t, isModule("debug"))
|
||||
assert.True(t, isModule("set_fact"))
|
||||
}
|
||||
|
||||
func TestIsModule_Good_FQCN(t *testing.T) {
|
||||
assert.True(t, isModule("ansible.builtin.shell"))
|
||||
assert.True(t, isModule("ansible.builtin.copy"))
|
||||
assert.True(t, isModule("ansible.builtin.apt"))
|
||||
}
|
||||
|
||||
func TestIsModule_Good_DottedUnknown(t *testing.T) {
|
||||
// Any key with dots is considered a module
|
||||
assert.True(t, isModule("community.general.ufw"))
|
||||
assert.True(t, isModule("ansible.posix.authorized_key"))
|
||||
}
|
||||
|
||||
func TestIsModule_Bad_NotAModule(t *testing.T) {
|
||||
assert.False(t, isModule("some_random_key"))
|
||||
assert.False(t, isModule("foobar"))
|
||||
}
|
||||
|
||||
// --- NormalizeModule ---
|
||||
|
||||
func TestNormalizeModule_Good(t *testing.T) {
|
||||
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("shell"))
|
||||
assert.Equal(t, "ansible.builtin.copy", NormalizeModule("copy"))
|
||||
assert.Equal(t, "ansible.builtin.apt", NormalizeModule("apt"))
|
||||
}
|
||||
|
||||
func TestNormalizeModule_Good_AlreadyFQCN(t *testing.T) {
|
||||
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("ansible.builtin.shell"))
|
||||
assert.Equal(t, "community.general.ufw", NormalizeModule("community.general.ufw"))
|
||||
}
|
||||
|
||||
// --- NewParser ---
|
||||
|
||||
func TestNewParser_Good(t *testing.T) {
|
||||
p := NewParser("/some/path")
|
||||
assert.NotNil(t, p)
|
||||
assert.Equal(t, "/some/path", p.basePath)
|
||||
assert.NotNil(t, p.vars)
|
||||
}
|
||||
451
ssh.go
Normal file
451
ssh.go
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-log"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
// SSHClient handles SSH connections to remote hosts.
|
||||
type SSHClient struct {
|
||||
host string
|
||||
port int
|
||||
user string
|
||||
password string
|
||||
keyFile string
|
||||
client *ssh.Client
|
||||
mu sync.Mutex
|
||||
become bool
|
||||
becomeUser string
|
||||
becomePass string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// SSHConfig holds SSH connection configuration.
|
||||
type SSHConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
KeyFile string
|
||||
Become bool
|
||||
BecomeUser string
|
||||
BecomePass string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewSSHClient creates a new SSH client.
|
||||
func NewSSHClient(cfg SSHConfig) (*SSHClient, error) {
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 22
|
||||
}
|
||||
if cfg.User == "" {
|
||||
cfg.User = "root"
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
client := &SSHClient{
|
||||
host: cfg.Host,
|
||||
port: cfg.Port,
|
||||
user: cfg.User,
|
||||
password: cfg.Password,
|
||||
keyFile: cfg.KeyFile,
|
||||
become: cfg.Become,
|
||||
becomeUser: cfg.BecomeUser,
|
||||
becomePass: cfg.BecomePass,
|
||||
timeout: cfg.Timeout,
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Connect establishes the SSH connection.
|
||||
func (c *SSHClient) Connect(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var authMethods []ssh.AuthMethod
|
||||
|
||||
// Try key-based auth first
|
||||
if c.keyFile != "" {
|
||||
keyPath := c.keyFile
|
||||
if strings.HasPrefix(keyPath, "~") {
|
||||
home, _ := os.UserHomeDir()
|
||||
keyPath = filepath.Join(home, keyPath[1:])
|
||||
}
|
||||
|
||||
if key, err := os.ReadFile(keyPath); err == nil {
|
||||
if signer, err := ssh.ParsePrivateKey(key); err == nil {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try default SSH keys
|
||||
if len(authMethods) == 0 {
|
||||
home, _ := os.UserHomeDir()
|
||||
defaultKeys := []string{
|
||||
filepath.Join(home, ".ssh", "id_ed25519"),
|
||||
filepath.Join(home, ".ssh", "id_rsa"),
|
||||
}
|
||||
for _, keyPath := range defaultKeys {
|
||||
if key, err := os.ReadFile(keyPath); err == nil {
|
||||
if signer, err := ssh.ParsePrivateKey(key); err == nil {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to password auth
|
||||
if c.password != "" {
|
||||
authMethods = append(authMethods, ssh.Password(c.password))
|
||||
authMethods = append(authMethods, ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
|
||||
answers := make([]string, len(questions))
|
||||
for i := range questions {
|
||||
answers[i] = c.password
|
||||
}
|
||||
return answers, nil
|
||||
}))
|
||||
}
|
||||
|
||||
if len(authMethods) == 0 {
|
||||
return log.E("ssh.Connect", "no authentication method available", nil)
|
||||
}
|
||||
|
||||
// Host key verification
|
||||
var hostKeyCallback ssh.HostKeyCallback
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return log.E("ssh.Connect", "failed to get user home dir", err)
|
||||
}
|
||||
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
|
||||
|
||||
// Ensure known_hosts file exists
|
||||
if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0700); err != nil {
|
||||
return log.E("ssh.Connect", "failed to create .ssh dir", err)
|
||||
}
|
||||
if err := os.WriteFile(knownHostsPath, nil, 0600); err != nil {
|
||||
return log.E("ssh.Connect", "failed to create known_hosts file", err)
|
||||
}
|
||||
}
|
||||
|
||||
cb, err := knownhosts.New(knownHostsPath)
|
||||
if err != nil {
|
||||
return log.E("ssh.Connect", "failed to load known_hosts", err)
|
||||
}
|
||||
hostKeyCallback = cb
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: c.user,
|
||||
Auth: authMethods,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: c.timeout,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", c.host, c.port)
|
||||
|
||||
// Connect with context timeout
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return log.E("ssh.Connect", fmt.Sprintf("dial %s", addr), err)
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||
if err != nil {
|
||||
// conn is closed by NewClientConn on error
|
||||
return log.E("ssh.Connect", fmt.Sprintf("ssh connect %s", addr), err)
|
||||
}
|
||||
|
||||
c.client = ssh.NewClient(sshConn, chans, reqs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the SSH connection.
|
||||
func (c *SSHClient) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.client != nil {
|
||||
err := c.client.Close()
|
||||
c.client = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run executes a command on the remote host.
|
||||
func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
return "", "", -1, err
|
||||
}
|
||||
|
||||
session, err := c.client.NewSession()
|
||||
if err != nil {
|
||||
return "", "", -1, log.E("ssh.Run", "new session", err)
|
||||
}
|
||||
defer func() { _ = session.Close() }()
|
||||
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
session.Stdout = &stdoutBuf
|
||||
session.Stderr = &stderrBuf
|
||||
|
||||
// Apply become if needed
|
||||
if c.become {
|
||||
becomeUser := c.becomeUser
|
||||
if becomeUser == "" {
|
||||
becomeUser = "root"
|
||||
}
|
||||
// Escape single quotes in the command
|
||||
escapedCmd := strings.ReplaceAll(cmd, "'", "'\\''")
|
||||
if c.becomePass != "" {
|
||||
// Use sudo with password via stdin (-S flag)
|
||||
// We launch a goroutine to write the password to stdin
|
||||
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return "", "", -1, log.E("ssh.Run", "stdin pipe", err)
|
||||
}
|
||||
go func() {
|
||||
defer func() { _ = stdin.Close() }()
|
||||
_, _ = io.WriteString(stdin, c.becomePass+"\n")
|
||||
}()
|
||||
} else if c.password != "" {
|
||||
// Try using connection password for sudo
|
||||
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return "", "", -1, log.E("ssh.Run", "stdin pipe", err)
|
||||
}
|
||||
go func() {
|
||||
defer func() { _ = stdin.Close() }()
|
||||
_, _ = io.WriteString(stdin, c.password+"\n")
|
||||
}()
|
||||
} else {
|
||||
// Try passwordless sudo
|
||||
cmd = fmt.Sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Run with context
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(cmd)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = session.Signal(ssh.SIGKILL)
|
||||
return "", "", -1, ctx.Err()
|
||||
case err := <-done:
|
||||
exitCode = 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||
exitCode = exitErr.ExitStatus()
|
||||
} else {
|
||||
return stdoutBuf.String(), stderrBuf.String(), -1, err
|
||||
}
|
||||
}
|
||||
return stdoutBuf.String(), stderrBuf.String(), exitCode, nil
|
||||
}
|
||||
}
|
||||
|
||||
// RunScript runs a script on the remote host.
|
||||
func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
|
||||
// Escape the script for heredoc
|
||||
cmd := fmt.Sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
|
||||
return c.Run(ctx, cmd)
|
||||
}
|
||||
|
||||
// Upload copies a file to the remote host.
|
||||
func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error {
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read content
|
||||
content, err := io.ReadAll(local)
|
||||
if err != nil {
|
||||
return log.E("ssh.Upload", "read content", err)
|
||||
}
|
||||
|
||||
// Create parent directory
|
||||
dir := filepath.Dir(remote)
|
||||
dirCmd := fmt.Sprintf("mkdir -p %q", dir)
|
||||
if c.become {
|
||||
dirCmd = fmt.Sprintf("sudo mkdir -p %q", dir)
|
||||
}
|
||||
if _, _, _, err := c.Run(ctx, dirCmd); err != nil {
|
||||
return log.E("ssh.Upload", "create parent dir", err)
|
||||
}
|
||||
|
||||
// Use cat to write the file (simpler than SCP)
|
||||
writeCmd := fmt.Sprintf("cat > %q && chmod %o %q", remote, mode, remote)
|
||||
|
||||
// If become is needed, we construct a command that reads password then content from stdin
|
||||
// But we need to be careful with handling stdin for sudo + cat.
|
||||
// We'll use a session with piped stdin.
|
||||
|
||||
session2, err := c.client.NewSession()
|
||||
if err != nil {
|
||||
return log.E("ssh.Upload", "new session for write", err)
|
||||
}
|
||||
defer func() { _ = session2.Close() }()
|
||||
|
||||
stdin, err := session2.StdinPipe()
|
||||
if err != nil {
|
||||
return log.E("ssh.Upload", "stdin pipe", err)
|
||||
}
|
||||
|
||||
var stderrBuf bytes.Buffer
|
||||
session2.Stderr = &stderrBuf
|
||||
|
||||
if c.become {
|
||||
becomeUser := c.becomeUser
|
||||
if becomeUser == "" {
|
||||
becomeUser = "root"
|
||||
}
|
||||
|
||||
pass := c.becomePass
|
||||
if pass == "" {
|
||||
pass = c.password
|
||||
}
|
||||
|
||||
if pass != "" {
|
||||
// Use sudo -S with password from stdin
|
||||
writeCmd = fmt.Sprintf("sudo -S -u %s bash -c 'cat > %q && chmod %o %q'",
|
||||
becomeUser, remote, mode, remote)
|
||||
} else {
|
||||
// Use passwordless sudo (sudo -n) to avoid consuming file content as password
|
||||
writeCmd = fmt.Sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
|
||||
becomeUser, remote, mode, remote)
|
||||
}
|
||||
|
||||
if err := session2.Start(writeCmd); err != nil {
|
||||
return log.E("ssh.Upload", "start write", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = stdin.Close() }()
|
||||
if pass != "" {
|
||||
_, _ = io.WriteString(stdin, pass+"\n")
|
||||
}
|
||||
_, _ = stdin.Write(content)
|
||||
}()
|
||||
} else {
|
||||
// Normal write
|
||||
if err := session2.Start(writeCmd); err != nil {
|
||||
return log.E("ssh.Upload", "start write", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = stdin.Close() }()
|
||||
_, _ = stdin.Write(content)
|
||||
}()
|
||||
}
|
||||
|
||||
if err := session2.Wait(); err != nil {
|
||||
return log.E("ssh.Upload", fmt.Sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download copies a file from the remote host.
|
||||
func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error) {
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("cat %q", remote)
|
||||
|
||||
stdout, stderr, exitCode, err := c.Run(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exitCode != 0 {
|
||||
return nil, log.E("ssh.Download", fmt.Sprintf("cat failed: %s", stderr), nil)
|
||||
}
|
||||
|
||||
return []byte(stdout), nil
|
||||
}
|
||||
|
||||
// FileExists checks if a file exists on the remote host.
|
||||
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
|
||||
cmd := fmt.Sprintf("test -e %q && echo yes || echo no", path)
|
||||
stdout, _, exitCode, err := c.Run(ctx, cmd)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exitCode != 0 {
|
||||
// test command failed but didn't error - file doesn't exist
|
||||
return false, nil
|
||||
}
|
||||
return strings.TrimSpace(stdout) == "yes", nil
|
||||
}
|
||||
|
||||
// Stat returns file info from the remote host.
|
||||
func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error) {
|
||||
// Simple approach - get basic file info
|
||||
cmd := fmt.Sprintf(`
|
||||
if [ -e %q ]; then
|
||||
if [ -d %q ]; then
|
||||
echo "exists=true isdir=true"
|
||||
else
|
||||
echo "exists=true isdir=false"
|
||||
fi
|
||||
else
|
||||
echo "exists=false"
|
||||
fi
|
||||
`, path, path)
|
||||
|
||||
stdout, _, _, err := c.Run(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
parts := strings.Fields(strings.TrimSpace(stdout))
|
||||
for _, part := range parts {
|
||||
kv := strings.SplitN(part, "=", 2)
|
||||
if len(kv) == 2 {
|
||||
result[kv[0]] = kv[1] == "true"
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetBecome enables privilege escalation.
|
||||
func (c *SSHClient) SetBecome(become bool, user, password string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.become = become
|
||||
if user != "" {
|
||||
c.becomeUser = user
|
||||
}
|
||||
if password != "" {
|
||||
c.becomePass = password
|
||||
}
|
||||
}
|
||||
36
ssh_test.go
Normal file
36
ssh_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewSSHClient(t *testing.T) {
|
||||
cfg := SSHConfig{
|
||||
Host: "localhost",
|
||||
Port: 2222,
|
||||
User: "root",
|
||||
}
|
||||
|
||||
client, err := NewSSHClient(cfg)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
assert.Equal(t, "localhost", client.host)
|
||||
assert.Equal(t, 2222, client.port)
|
||||
assert.Equal(t, "root", client.user)
|
||||
assert.Equal(t, 30*time.Second, client.timeout)
|
||||
}
|
||||
|
||||
func TestSSHConfig_Defaults(t *testing.T) {
|
||||
cfg := SSHConfig{
|
||||
Host: "localhost",
|
||||
}
|
||||
|
||||
client, err := NewSSHClient(cfg)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 22, client.port)
|
||||
assert.Equal(t, "root", client.user)
|
||||
assert.Equal(t, 30*time.Second, client.timeout)
|
||||
}
|
||||
258
types.go
Normal file
258
types.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Playbook represents an Ansible playbook.
|
||||
type Playbook struct {
|
||||
Plays []Play `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Play represents a single play in a playbook.
|
||||
type Play struct {
|
||||
Name string `yaml:"name"`
|
||||
Hosts string `yaml:"hosts"`
|
||||
Connection string `yaml:"connection,omitempty"`
|
||||
Become bool `yaml:"become,omitempty"`
|
||||
BecomeUser string `yaml:"become_user,omitempty"`
|
||||
GatherFacts *bool `yaml:"gather_facts,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
PreTasks []Task `yaml:"pre_tasks,omitempty"`
|
||||
Tasks []Task `yaml:"tasks,omitempty"`
|
||||
PostTasks []Task `yaml:"post_tasks,omitempty"`
|
||||
Roles []RoleRef `yaml:"roles,omitempty"`
|
||||
Handlers []Task `yaml:"handlers,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
Serial any `yaml:"serial,omitempty"` // int or string
|
||||
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
|
||||
}
|
||||
|
||||
// RoleRef represents a role reference in a play.
|
||||
type RoleRef struct {
|
||||
Role string `yaml:"role,omitempty"`
|
||||
Name string `yaml:"name,omitempty"` // Alternative to role
|
||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
When any `yaml:"when,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalYAML handles both string and struct role refs.
|
||||
func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
// Try string first
|
||||
var s string
|
||||
if err := unmarshal(&s); err == nil {
|
||||
r.Role = s
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try struct
|
||||
type rawRoleRef RoleRef
|
||||
var raw rawRoleRef
|
||||
if err := unmarshal(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*r = RoleRef(raw)
|
||||
if r.Role == "" && r.Name != "" {
|
||||
r.Role = r.Name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Task represents an Ansible task.
|
||||
type Task struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Module string `yaml:"-"` // Derived from the module key
|
||||
Args map[string]any `yaml:"-"` // Module arguments
|
||||
Register string `yaml:"register,omitempty"`
|
||||
When any `yaml:"when,omitempty"` // string or []string
|
||||
Loop any `yaml:"loop,omitempty"` // string or []any
|
||||
LoopControl *LoopControl `yaml:"loop_control,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
Environment map[string]string `yaml:"environment,omitempty"`
|
||||
ChangedWhen any `yaml:"changed_when,omitempty"`
|
||||
FailedWhen any `yaml:"failed_when,omitempty"`
|
||||
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
|
||||
NoLog bool `yaml:"no_log,omitempty"`
|
||||
Become *bool `yaml:"become,omitempty"`
|
||||
BecomeUser string `yaml:"become_user,omitempty"`
|
||||
Delegate string `yaml:"delegate_to,omitempty"`
|
||||
RunOnce bool `yaml:"run_once,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
Block []Task `yaml:"block,omitempty"`
|
||||
Rescue []Task `yaml:"rescue,omitempty"`
|
||||
Always []Task `yaml:"always,omitempty"`
|
||||
Notify any `yaml:"notify,omitempty"` // string or []string
|
||||
Retries int `yaml:"retries,omitempty"`
|
||||
Delay int `yaml:"delay,omitempty"`
|
||||
Until string `yaml:"until,omitempty"`
|
||||
|
||||
// Include/import directives
|
||||
IncludeTasks string `yaml:"include_tasks,omitempty"`
|
||||
ImportTasks string `yaml:"import_tasks,omitempty"`
|
||||
IncludeRole *struct {
|
||||
Name string `yaml:"name"`
|
||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
} `yaml:"include_role,omitempty"`
|
||||
ImportRole *struct {
|
||||
Name string `yaml:"name"`
|
||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
} `yaml:"import_role,omitempty"`
|
||||
|
||||
// Raw YAML for module extraction
|
||||
raw map[string]any
|
||||
}
|
||||
|
||||
// LoopControl controls loop behavior.
|
||||
type LoopControl struct {
|
||||
LoopVar string `yaml:"loop_var,omitempty"`
|
||||
IndexVar string `yaml:"index_var,omitempty"`
|
||||
Label string `yaml:"label,omitempty"`
|
||||
Pause int `yaml:"pause,omitempty"`
|
||||
Extended bool `yaml:"extended,omitempty"`
|
||||
}
|
||||
|
||||
// TaskResult holds the result of executing a task.
|
||||
type TaskResult struct {
|
||||
Changed bool `json:"changed"`
|
||||
Failed bool `json:"failed"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
RC int `json:"rc,omitempty"`
|
||||
Results []TaskResult `json:"results,omitempty"` // For loops
|
||||
Data map[string]any `json:"data,omitempty"` // Module-specific data
|
||||
Duration time.Duration `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// Inventory represents Ansible inventory.
|
||||
type Inventory struct {
|
||||
All *InventoryGroup `yaml:"all"`
|
||||
}
|
||||
|
||||
// InventoryGroup represents a group in inventory.
|
||||
type InventoryGroup struct {
|
||||
Hosts map[string]*Host `yaml:"hosts,omitempty"`
|
||||
Children map[string]*InventoryGroup `yaml:"children,omitempty"`
|
||||
Vars map[string]any `yaml:"vars,omitempty"`
|
||||
}
|
||||
|
||||
// Host represents a host in inventory.
|
||||
type Host struct {
|
||||
AnsibleHost string `yaml:"ansible_host,omitempty"`
|
||||
AnsiblePort int `yaml:"ansible_port,omitempty"`
|
||||
AnsibleUser string `yaml:"ansible_user,omitempty"`
|
||||
AnsiblePassword string `yaml:"ansible_password,omitempty"`
|
||||
AnsibleSSHPrivateKeyFile string `yaml:"ansible_ssh_private_key_file,omitempty"`
|
||||
AnsibleConnection string `yaml:"ansible_connection,omitempty"`
|
||||
AnsibleBecomePassword string `yaml:"ansible_become_password,omitempty"`
|
||||
|
||||
// Custom vars
|
||||
Vars map[string]any `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Facts holds gathered facts about a host.
|
||||
type Facts struct {
|
||||
Hostname string `json:"ansible_hostname"`
|
||||
FQDN string `json:"ansible_fqdn"`
|
||||
OS string `json:"ansible_os_family"`
|
||||
Distribution string `json:"ansible_distribution"`
|
||||
Version string `json:"ansible_distribution_version"`
|
||||
Architecture string `json:"ansible_architecture"`
|
||||
Kernel string `json:"ansible_kernel"`
|
||||
Memory int64 `json:"ansible_memtotal_mb"`
|
||||
CPUs int `json:"ansible_processor_vcpus"`
|
||||
IPv4 string `json:"ansible_default_ipv4_address"`
|
||||
}
|
||||
|
||||
// Known Ansible modules
|
||||
var KnownModules = []string{
|
||||
// Builtin
|
||||
"ansible.builtin.shell",
|
||||
"ansible.builtin.command",
|
||||
"ansible.builtin.raw",
|
||||
"ansible.builtin.script",
|
||||
"ansible.builtin.copy",
|
||||
"ansible.builtin.template",
|
||||
"ansible.builtin.file",
|
||||
"ansible.builtin.lineinfile",
|
||||
"ansible.builtin.blockinfile",
|
||||
"ansible.builtin.stat",
|
||||
"ansible.builtin.slurp",
|
||||
"ansible.builtin.fetch",
|
||||
"ansible.builtin.get_url",
|
||||
"ansible.builtin.uri",
|
||||
"ansible.builtin.apt",
|
||||
"ansible.builtin.apt_key",
|
||||
"ansible.builtin.apt_repository",
|
||||
"ansible.builtin.yum",
|
||||
"ansible.builtin.dnf",
|
||||
"ansible.builtin.package",
|
||||
"ansible.builtin.pip",
|
||||
"ansible.builtin.service",
|
||||
"ansible.builtin.systemd",
|
||||
"ansible.builtin.user",
|
||||
"ansible.builtin.group",
|
||||
"ansible.builtin.cron",
|
||||
"ansible.builtin.git",
|
||||
"ansible.builtin.unarchive",
|
||||
"ansible.builtin.archive",
|
||||
"ansible.builtin.debug",
|
||||
"ansible.builtin.fail",
|
||||
"ansible.builtin.assert",
|
||||
"ansible.builtin.pause",
|
||||
"ansible.builtin.wait_for",
|
||||
"ansible.builtin.set_fact",
|
||||
"ansible.builtin.include_vars",
|
||||
"ansible.builtin.add_host",
|
||||
"ansible.builtin.group_by",
|
||||
"ansible.builtin.meta",
|
||||
"ansible.builtin.setup",
|
||||
|
||||
// Short forms (legacy)
|
||||
"shell",
|
||||
"command",
|
||||
"raw",
|
||||
"script",
|
||||
"copy",
|
||||
"template",
|
||||
"file",
|
||||
"lineinfile",
|
||||
"blockinfile",
|
||||
"stat",
|
||||
"slurp",
|
||||
"fetch",
|
||||
"get_url",
|
||||
"uri",
|
||||
"apt",
|
||||
"apt_key",
|
||||
"apt_repository",
|
||||
"yum",
|
||||
"dnf",
|
||||
"package",
|
||||
"pip",
|
||||
"service",
|
||||
"systemd",
|
||||
"user",
|
||||
"group",
|
||||
"cron",
|
||||
"git",
|
||||
"unarchive",
|
||||
"archive",
|
||||
"debug",
|
||||
"fail",
|
||||
"assert",
|
||||
"pause",
|
||||
"wait_for",
|
||||
"set_fact",
|
||||
"include_vars",
|
||||
"add_host",
|
||||
"group_by",
|
||||
"meta",
|
||||
"setup",
|
||||
}
|
||||
402
types_test.go
Normal file
402
types_test.go
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
package ansible
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// --- RoleRef UnmarshalYAML ---
|
||||
|
||||
func TestRoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
|
||||
input := `common`
|
||||
var ref RoleRef
|
||||
err := yaml.Unmarshal([]byte(input), &ref)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "common", ref.Role)
|
||||
}
|
||||
|
||||
func TestRoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
|
||||
input := `
|
||||
role: webserver
|
||||
vars:
|
||||
http_port: 80
|
||||
tags:
|
||||
- web
|
||||
`
|
||||
var ref RoleRef
|
||||
err := yaml.Unmarshal([]byte(input), &ref)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "webserver", ref.Role)
|
||||
assert.Equal(t, 80, ref.Vars["http_port"])
|
||||
assert.Equal(t, []string{"web"}, ref.Tags)
|
||||
}
|
||||
|
||||
func TestRoleRef_UnmarshalYAML_Good_NameField(t *testing.T) {
|
||||
// Some playbooks use "name:" instead of "role:"
|
||||
input := `
|
||||
name: myapp
|
||||
tasks_from: install.yml
|
||||
`
|
||||
var ref RoleRef
|
||||
err := yaml.Unmarshal([]byte(input), &ref)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "myapp", ref.Role) // Name is copied to Role
|
||||
assert.Equal(t, "install.yml", ref.TasksFrom)
|
||||
}
|
||||
|
||||
func TestRoleRef_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
||||
input := `
|
||||
role: conditional_role
|
||||
when: ansible_os_family == "Debian"
|
||||
`
|
||||
var ref RoleRef
|
||||
err := yaml.Unmarshal([]byte(input), &ref)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "conditional_role", ref.Role)
|
||||
assert.NotNil(t, ref.When)
|
||||
}
|
||||
|
||||
// --- Task UnmarshalYAML ---
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
|
||||
input := `
|
||||
name: Install nginx
|
||||
apt:
|
||||
name: nginx
|
||||
state: present
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Install nginx", task.Name)
|
||||
assert.Equal(t, "apt", task.Module)
|
||||
assert.Equal(t, "nginx", task.Args["name"])
|
||||
assert.Equal(t, "present", task.Args["state"])
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_FreeFormModule(t *testing.T) {
|
||||
input := `
|
||||
name: Run command
|
||||
shell: echo hello world
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "shell", task.Module)
|
||||
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_ModuleNoArgs(t *testing.T) {
|
||||
input := `
|
||||
name: Gather facts
|
||||
setup:
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "setup", task.Module)
|
||||
assert.NotNil(t, task.Args)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_WithRegister(t *testing.T) {
|
||||
input := `
|
||||
name: Check file
|
||||
stat:
|
||||
path: /etc/hosts
|
||||
register: stat_result
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "stat_result", task.Register)
|
||||
assert.Equal(t, "stat", task.Module)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
||||
input := `
|
||||
name: Conditional task
|
||||
debug:
|
||||
msg: "hello"
|
||||
when: some_var is defined
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, task.When)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_WithLoop(t *testing.T) {
|
||||
input := `
|
||||
name: Install packages
|
||||
apt:
|
||||
name: "{{ item }}"
|
||||
loop:
|
||||
- vim
|
||||
- git
|
||||
- curl
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
items, ok := task.Loop.([]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, items, 3)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_WithItems(t *testing.T) {
|
||||
// with_items should be converted to loop
|
||||
input := `
|
||||
name: Old-style loop
|
||||
apt:
|
||||
name: "{{ item }}"
|
||||
with_items:
|
||||
- vim
|
||||
- git
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
// with_items should have been stored in Loop
|
||||
items, ok := task.Loop.([]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, items, 2)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_WithNotify(t *testing.T) {
|
||||
input := `
|
||||
name: Install package
|
||||
apt:
|
||||
name: nginx
|
||||
notify: restart nginx
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "restart nginx", task.Notify)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_WithNotifyList(t *testing.T) {
|
||||
input := `
|
||||
name: Install package
|
||||
apt:
|
||||
name: nginx
|
||||
notify:
|
||||
- restart nginx
|
||||
- reload config
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
notifyList, ok := task.Notify.([]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, notifyList, 2)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
|
||||
input := `
|
||||
name: Include tasks
|
||||
include_tasks: other-tasks.yml
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_IncludeRole(t *testing.T) {
|
||||
input := `
|
||||
name: Include role
|
||||
include_role:
|
||||
name: common
|
||||
tasks_from: setup.yml
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.IncludeRole)
|
||||
assert.Equal(t, "common", task.IncludeRole.Name)
|
||||
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_BecomeFields(t *testing.T) {
|
||||
input := `
|
||||
name: Privileged task
|
||||
shell: systemctl restart nginx
|
||||
become: true
|
||||
become_user: root
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, task.Become)
|
||||
assert.True(t, *task.Become)
|
||||
assert.Equal(t, "root", task.BecomeUser)
|
||||
}
|
||||
|
||||
func TestTask_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
|
||||
input := `
|
||||
name: Might fail
|
||||
shell: some risky command
|
||||
ignore_errors: true
|
||||
`
|
||||
var task Task
|
||||
err := yaml.Unmarshal([]byte(input), &task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, task.IgnoreErrors)
|
||||
}
|
||||
|
||||
// --- Inventory data structure ---
|
||||
|
||||
func TestInventory_UnmarshalYAML_Good_Complex(t *testing.T) {
|
||||
input := `
|
||||
all:
|
||||
vars:
|
||||
ansible_user: admin
|
||||
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
|
||||
hosts:
|
||||
bastion:
|
||||
ansible_host: 1.2.3.4
|
||||
ansible_port: 4819
|
||||
children:
|
||||
webservers:
|
||||
hosts:
|
||||
web1:
|
||||
ansible_host: 10.0.0.1
|
||||
web2:
|
||||
ansible_host: 10.0.0.2
|
||||
vars:
|
||||
http_port: 80
|
||||
databases:
|
||||
hosts:
|
||||
db1:
|
||||
ansible_host: 10.0.1.1
|
||||
ansible_connection: ssh
|
||||
`
|
||||
var inv Inventory
|
||||
err := yaml.Unmarshal([]byte(input), &inv)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inv.All)
|
||||
|
||||
// Check top-level vars
|
||||
assert.Equal(t, "admin", inv.All.Vars["ansible_user"])
|
||||
|
||||
// Check top-level hosts
|
||||
require.NotNil(t, inv.All.Hosts["bastion"])
|
||||
assert.Equal(t, "1.2.3.4", inv.All.Hosts["bastion"].AnsibleHost)
|
||||
assert.Equal(t, 4819, inv.All.Hosts["bastion"].AnsiblePort)
|
||||
|
||||
// Check children
|
||||
require.NotNil(t, inv.All.Children["webservers"])
|
||||
assert.Len(t, inv.All.Children["webservers"].Hosts, 2)
|
||||
assert.Equal(t, 80, inv.All.Children["webservers"].Vars["http_port"])
|
||||
|
||||
require.NotNil(t, inv.All.Children["databases"])
|
||||
assert.Equal(t, "ssh", inv.All.Children["databases"].Hosts["db1"].AnsibleConnection)
|
||||
}
|
||||
|
||||
// --- Facts ---
|
||||
|
||||
func TestFacts_Struct(t *testing.T) {
|
||||
facts := Facts{
|
||||
Hostname: "web1",
|
||||
FQDN: "web1.example.com",
|
||||
OS: "Debian",
|
||||
Distribution: "ubuntu",
|
||||
Version: "24.04",
|
||||
Architecture: "x86_64",
|
||||
Kernel: "6.8.0",
|
||||
Memory: 16384,
|
||||
CPUs: 4,
|
||||
IPv4: "10.0.0.1",
|
||||
}
|
||||
|
||||
assert.Equal(t, "web1", facts.Hostname)
|
||||
assert.Equal(t, "web1.example.com", facts.FQDN)
|
||||
assert.Equal(t, "ubuntu", facts.Distribution)
|
||||
assert.Equal(t, "x86_64", facts.Architecture)
|
||||
assert.Equal(t, int64(16384), facts.Memory)
|
||||
assert.Equal(t, 4, facts.CPUs)
|
||||
}
|
||||
|
||||
// --- TaskResult ---
|
||||
|
||||
func TestTaskResult_Struct(t *testing.T) {
|
||||
result := TaskResult{
|
||||
Changed: true,
|
||||
Failed: false,
|
||||
Skipped: false,
|
||||
Msg: "task completed",
|
||||
Stdout: "output",
|
||||
Stderr: "",
|
||||
RC: 0,
|
||||
}
|
||||
|
||||
assert.True(t, result.Changed)
|
||||
assert.False(t, result.Failed)
|
||||
assert.Equal(t, "task completed", result.Msg)
|
||||
assert.Equal(t, 0, result.RC)
|
||||
}
|
||||
|
||||
func TestTaskResult_WithLoopResults(t *testing.T) {
|
||||
result := TaskResult{
|
||||
Changed: true,
|
||||
Results: []TaskResult{
|
||||
{Changed: true, RC: 0},
|
||||
{Changed: false, RC: 0},
|
||||
{Changed: true, RC: 0},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Len(t, result.Results, 3)
|
||||
assert.True(t, result.Results[0].Changed)
|
||||
assert.False(t, result.Results[1].Changed)
|
||||
}
|
||||
|
||||
// --- KnownModules ---
|
||||
|
||||
func TestKnownModules_ContainsExpected(t *testing.T) {
|
||||
// Verify both FQCN and short forms are present
|
||||
fqcnModules := []string{
|
||||
"ansible.builtin.shell",
|
||||
"ansible.builtin.command",
|
||||
"ansible.builtin.copy",
|
||||
"ansible.builtin.file",
|
||||
"ansible.builtin.apt",
|
||||
"ansible.builtin.service",
|
||||
"ansible.builtin.systemd",
|
||||
"ansible.builtin.debug",
|
||||
"ansible.builtin.set_fact",
|
||||
}
|
||||
for _, mod := range fqcnModules {
|
||||
assert.Contains(t, KnownModules, mod, "expected FQCN module %s", mod)
|
||||
}
|
||||
|
||||
shortModules := []string{
|
||||
"shell", "command", "copy", "file", "apt", "service",
|
||||
"systemd", "debug", "set_fact", "template", "user", "group",
|
||||
}
|
||||
for _, mod := range shortModules {
|
||||
assert.Contains(t, KnownModules, mod, "expected short-form module %s", mod)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue