Compare commits
260 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0dbdb824 | ||
|
|
9013a33382 | ||
|
|
a80daf5494 | ||
|
|
b91ad5d485 | ||
|
|
56d532885d | ||
|
|
d34aed9feb | ||
|
|
29b433fdbf | ||
|
|
508248a722 | ||
|
|
65917f028c | ||
|
|
79c01ab325 | ||
|
|
83c0bfe52a | ||
|
|
f637f0df98 | ||
|
|
9b7f411763 | ||
|
|
12b0ed95a8 | ||
|
|
37ae077d85 | ||
|
|
bff80d44d6 | ||
|
|
8b1876b615 | ||
|
|
56457c6f42 | ||
|
|
c276c343bc | ||
|
|
28ef1f3d85 | ||
|
|
92eaab75ba | ||
|
|
a2b7cfe228 | ||
|
|
a0a9e832ee | ||
|
|
b5bfe4a875 | ||
|
|
821211e671 | ||
|
|
1e5bdc08dd | ||
|
|
3eca6b15cb | ||
|
|
bae7fa0a39 | ||
|
|
cd0d258768 | ||
|
|
153bf5b863 | ||
|
|
1c637a2199 | ||
|
|
470592e253 | ||
|
|
541d16b5a6 | ||
|
|
2927fb4c78 | ||
|
|
fd6b8b0d2f | ||
|
|
0e3c126723 | ||
|
|
3f601ff7b5 | ||
|
|
80fb75baab | ||
|
|
1b13b33821 | ||
|
|
5609471945 | ||
|
|
cffc35a973 | ||
|
|
dac108cab5 | ||
|
|
65cd1b9e01 | ||
|
|
2dc29d1592 | ||
|
|
472c45ba85 | ||
|
|
031e41be19 | ||
|
|
bbe110c1c0 | ||
|
|
17691b9ff0 | ||
|
|
b75ba32cc2 | ||
|
|
4ccb8fc93b | ||
|
|
9dfd5b3af1 | ||
|
|
1e7deda933 | ||
|
|
d8798ec56f | ||
|
|
7833c872a4 | ||
|
|
71a50b0d2b | ||
|
|
5420321e22 | ||
|
|
1e99665f6e | ||
|
|
7cbb53dbc8 | ||
|
|
dcddb0b510 | ||
|
|
e95c35c097 | ||
|
|
ac45fd9830 | ||
|
|
251206748a | ||
|
|
4b3cfbef8d | ||
|
|
c70f02cb09 | ||
|
|
22e689bbfa | ||
|
|
610294db6b | ||
|
|
82c7c73d50 | ||
|
|
fe0ed9b2ee | ||
|
|
324411bb95 | ||
|
|
e5b891e7d7 | ||
|
|
4d1e46b933 | ||
|
|
909aac859a | ||
|
|
2b32f453db | ||
|
|
8ebfafd6cc | ||
|
|
103b5ed255 | ||
|
|
c65ca1cfd9 | ||
|
|
c52d539d3c | ||
|
|
78eac4e8f2 | ||
|
|
762a47f11f | ||
|
|
dc89e88e00 | ||
|
|
70ec0dbba4 | ||
|
|
8f6bd48cf8 | ||
|
|
7d71ff21a4 | ||
|
|
1d90b93f5b | ||
|
|
2edc43b3fb | ||
|
|
a87899c2d4 | ||
|
|
0cb9cc5b28 | ||
|
|
e8a58e26ba | ||
|
|
dd9ccc777c | ||
|
|
8699d00933 | ||
|
|
6613718d8c | ||
|
|
6f5d1659cd | ||
|
|
0e813a93ca | ||
|
|
9cef8c9a03 | ||
|
|
9db3c62054 | ||
|
|
1a54e98612 | ||
|
|
c8abab3034 | ||
|
|
b04e68fdbf | ||
|
|
8cc2257ac7 | ||
|
|
cb1ffa8b64 | ||
|
|
3e8a150375 | ||
|
|
fa5f2bb5ba | ||
|
|
563eebf40e | ||
|
|
b2d33c5b91 | ||
|
|
e14659dcb0 | ||
|
|
9925b7d2e8 | ||
|
|
0560bccb8b | ||
|
|
7f5c5d05e3 | ||
|
|
772a9c393e | ||
|
|
199cb1d087 | ||
|
|
bced0d3cdc | ||
|
|
1b633d41db | ||
|
|
bde3c18e19 | ||
|
|
ff2a8e7731 | ||
|
|
ab9d9725be | ||
|
|
e8669f6f7c | ||
|
|
e42e0452ad | ||
|
|
d1682f6345 | ||
|
|
2c0b68627d | ||
|
|
f0c2333a75 | ||
|
|
290e9b47b1 | ||
|
|
6ef54d3e56 | ||
|
|
c5712c696d | ||
|
|
1fa2b78fed | ||
|
|
4387cab0cb | ||
|
|
2b12b8f860 | ||
|
|
a95b6ec5d8 | ||
|
|
e4e72bc52a | ||
|
|
5e6cd67400 | ||
|
|
8c1f4af11e | ||
|
|
fbfc2a6c7e | ||
|
|
f4d8ae1851 | ||
|
|
afa8efbdbf | ||
|
|
75bafd10c8 | ||
|
|
f9d8b3bc51 | ||
|
|
84451b2bd8 | ||
|
|
e6be1e5f5a | ||
|
|
35014b52fc | ||
|
|
f5c4f16d42 | ||
|
|
b3f2cc3fc6 | ||
|
|
6c1c7d9bd4 | ||
|
|
1d864ebe41 | ||
|
|
ea048b0fec | ||
|
|
7f7cc55479 | ||
|
|
ac8f7a36b5 | ||
|
|
988c0e53ca | ||
|
|
ce60a583f3 | ||
|
|
a475924e6f | ||
|
|
8e21d5dff8 | ||
|
|
a81e05a078 | ||
|
|
4e0a5f714c | ||
|
|
9f86b5cb95 | ||
|
|
f678b97a74 | ||
|
|
4823bd68f1 | ||
|
|
807751ebe7 | ||
|
|
57bc50002e | ||
|
|
4b884f67d6 | ||
|
|
4bba5ef00e | ||
|
|
e1e2b6402e | ||
|
|
8012570663 | ||
|
|
39fe9d9ca7 | ||
|
|
8005562895 | ||
|
|
bd1932e90e | ||
|
|
c1f0af5d5a | ||
|
|
5bb3a2f636 | ||
|
|
2655775a8f | ||
|
|
0e3a362269 | ||
|
|
bfa9a8d0ba | ||
|
|
efa2ac3ea1 | ||
|
|
5f6205011c | ||
|
|
05df5b5bb8 | ||
|
|
2e54726977 | ||
|
|
187f157435 | ||
|
|
e7a0298db8 | ||
|
|
9e7219782a | ||
|
|
a80c2a2096 | ||
|
|
134ea64e92 | ||
|
|
e793321e1e | ||
|
|
b67d9419a4 | ||
|
|
defcd18f44 | ||
|
|
41d74b0ac6 | ||
|
|
8c73ff922b | ||
|
|
23659c185b | ||
|
|
d28e5a0ac7 | ||
|
|
3c51fd40ad | ||
|
|
a43a447f55 | ||
|
|
c3aa73e065 | ||
|
|
11bb1b6a7f | ||
|
|
92634bf561 | ||
|
|
8130be049a | ||
|
|
d969cc9205 | ||
|
|
3a4118ada8 | ||
|
|
c77a32f24f | ||
|
|
87cd890ea1 | ||
|
|
f97f042a3c | ||
|
|
89a87ad1f4 | ||
|
|
5951f74f27 | ||
|
|
ca6dc7912e | ||
|
|
6805aeb410 | ||
|
|
7e1edf86dc | ||
|
|
6fb90524ce | ||
|
|
695453cfe4 | ||
|
|
23047aaa6f | ||
|
|
89eee7b964 | ||
|
|
f233605542 | ||
|
|
9ba6cdc5a4 | ||
|
|
3596845838 | ||
|
|
093676ff1a | ||
|
|
097aeec0d2 | ||
|
|
df8a400553 | ||
|
|
66af49ec7f | ||
|
|
2cd724614a | ||
|
|
f80825783c | ||
|
|
f71e8642e9 | ||
|
|
4245e1e530 | ||
|
|
ef908a7b35 | ||
|
|
f5e66b556b | ||
|
|
fba61dbc5a | ||
|
|
eb03970129 | ||
|
|
3ae6ada028 | ||
|
|
d99acf3dd1 | ||
|
|
0993a0e851 | ||
|
|
8a98a69efe | ||
|
|
bfbbd31f09 | ||
|
|
2965d93ca8 | ||
|
|
cab43816a0 | ||
|
|
5b66334d44 | ||
|
|
af7c360fbb | ||
|
|
02cb9273c5 | ||
|
|
f1a52e777e | ||
|
|
716ad80951 | ||
|
|
6cc987ea74 | ||
|
|
eb3b9cca07 | ||
|
|
692c2cf58a | ||
|
|
709b1f5dc4 | ||
|
|
d9d16e8092 | ||
|
|
abf27ad7f7 | ||
|
|
1581046a5c | ||
|
|
39d4de9e8f | ||
|
|
df0e79939c | ||
|
|
e1db473011 | ||
|
|
6eee9866e5 | ||
|
|
6fb5ebe920 | ||
|
|
acf0a16349 | ||
|
|
a973604e95 | ||
|
|
8321e16969 | ||
|
|
f27fb19bed | ||
|
|
2739e52d52 | ||
|
|
34229558fb | ||
|
|
35b0cf03d9 | ||
|
|
f127ac2fcb | ||
|
|
4f33c15d6c | ||
| 55a0f4fcfb | |||
| 23dc42835b | |||
|
|
7138ecd8d6 | ||
| 5a913a5414 | |||
|
|
0c9a933b9c | ||
| fb6b706993 | |||
|
|
07f777a280 | ||
| ac0633c60b |
34 changed files with 19733 additions and 1784 deletions
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## 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. 41 module handler implementations (plus 3 community modules), Jinja2-compatible templating, privilege escalation (become), and event-driven callbacks. This is a library — there is no standalone binary. The CLI integration lives in `cmd/ansible/` and is compiled as part of the `core` CLI binary.
|
`core/go-ansible` is a pure Go Ansible playbook engine. It parses YAML playbooks, inventories, and roles, then executes tasks on remote hosts via SSH. 42 module handler implementations (plus 3 community modules), Jinja2-compatible templating, privilege escalation (become), and event-driven callbacks. This is a library — there is no standalone binary. The CLI integration lives in `cmd/ansible/` and is compiled as part of the `core` CLI binary.
|
||||||
|
|
||||||
## Build & Test
|
## Build & Test
|
||||||
|
|
||||||
|
|
@ -29,10 +29,10 @@ Playbook YAML ──► Parser ──► []Play ──► Executor ──► Mod
|
||||||
Inventory YAML ──► Parser ──► Inventory Callbacks (OnPlayStart, OnTaskEnd, ...)
|
Inventory YAML ──► Parser ──► Inventory Callbacks (OnPlayStart, OnTaskEnd, ...)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`types.go`** — Core structs (`Playbook`, `Play`, `Task`, `TaskResult`, `Inventory`, `Host`, `Facts`) and `KnownModules` registry (80 entries: both FQCN `ansible.builtin.*` and short forms).
|
- **`types.go`** — Core structs (`Playbook`, `Play`, `Task`, `TaskResult`, `Inventory`, `Host`, `Facts`) and `KnownModules` registry (96 entries: both FQCN `ansible.builtin.*` and short forms, plus compatibility aliases).
|
||||||
- **`parser.go`** — YAML parsing for playbooks, inventories, tasks, and roles. Custom `Task.UnmarshalYAML` scans map keys against `KnownModules` to extract the module name and args (since Ansible embeds the module name as a YAML key, not a fixed field). Free-form syntax (`shell: echo hello`) is stored as `Args["_raw_params"]`. Iterator variants (`ParsePlaybookIter`, `ParseTasksIter`, etc.) return `iter.Seq` values.
|
- **`parser.go`** — YAML parsing for playbooks, inventories, tasks, and roles. Custom `Task.UnmarshalYAML` scans map keys against `KnownModules` to extract the module name and args (since Ansible embeds the module name as a YAML key, not a fixed field). Free-form syntax (`shell: echo hello`) is stored as `Args["_raw_params"]`. Iterator variants (`ParsePlaybookIter`, `ParseTasksIter`, etc.) return `iter.Seq` values.
|
||||||
- **`executor.go`** — Orchestration engine: host resolution from inventory, play execution order (gather facts → pre_tasks → roles → tasks → post_tasks → notified handlers), `when:` condition evaluation, `{{ }}` Jinja2-style templating with filter support, loop execution, block/rescue/always, handler notification.
|
- **`executor.go`** — Orchestration engine: host resolution from inventory, play execution order (gather facts → pre_tasks → roles → tasks → post_tasks → notified handlers), `when:` condition evaluation, `{{ }}` Jinja2-style templating with filter support, loop execution, block/rescue/always, handler notification.
|
||||||
- **`modules.go`** — 41 module handler implementations dispatched via a `switch` on the normalised module name. Each handler extracts args via `getStringArg`/`getBoolArg`, constructs shell commands, runs them via SSH, and returns a `TaskResult`.
|
- **`modules.go`** — 50 module handler implementations dispatched via a `switch` on the normalised module name. Each handler extracts args via `getStringArg`/`getBoolArg`, constructs shell commands, runs them via SSH, and returns a `TaskResult`.
|
||||||
- **`ssh.go`** — SSH client with lazy connection, auth chain (key file → default keys → password), `known_hosts` verification, become/sudo wrapping, file transfer via `cat >` piped through stdin.
|
- **`ssh.go`** — SSH client with lazy connection, auth chain (key file → default keys → password), `known_hosts` verification, become/sudo wrapping, file transfer via `cat >` piped through stdin.
|
||||||
- **`cmd/ansible/`** — CLI command registration via `core/cli`. Provides `ansible <playbook>` and `ansible test <host>` subcommands with flags for inventory, limit, tags, extra-vars, verbosity, and check mode.
|
- **`cmd/ansible/`** — CLI command registration via `core/cli`. Provides `ansible <playbook>` and `ansible test <host>` subcommands with flags for inventory, limit, tags, extra-vars, verbosity, and check mode.
|
||||||
|
|
||||||
|
|
|
||||||
59
CONVENTION_DRIFT_REPORT.md
Normal file
59
CONVENTION_DRIFT_REPORT.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Convention Drift Report
|
||||||
|
|
||||||
|
Date: 2026-03-23
|
||||||
|
Branch: `agent/convention-drift-check--stdlib-core----u`
|
||||||
|
|
||||||
|
`CODEX.md` is not present in this repository. Conventions were taken from `CLAUDE.md` and `docs/development.md`.
|
||||||
|
|
||||||
|
Commands used for the test-gap pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -coverprofile=/tmp/ansible.cover ./...
|
||||||
|
go tool cover -func=/tmp/ansible.cover
|
||||||
|
```
|
||||||
|
|
||||||
|
## `stdlib→core.*`
|
||||||
|
|
||||||
|
No direct `stdlib`-to-`core.*` wrapper drift was found in the Go implementation. The remaining drift is stale migration residue around the `core.*` move:
|
||||||
|
|
||||||
|
- `go.mod:15`, `go.sum:7`, `go.sum:8`
|
||||||
|
Legacy `forge.lthn.ai/core/go-log` references still remain in the dependency graph.
|
||||||
|
- `CLAUDE.md:37`, `docs/development.md:169`
|
||||||
|
Repository guidance still refers to `core/cli`, while the current command registration lives on the `dappco.re/go/core` API at `cmd/ansible/cmd.go:8`.
|
||||||
|
- `CLAUDE.md:66`, `docs/development.md:86`
|
||||||
|
Guidance still calls the logging package `go-log`, while production code imports `dappco.re/go/core/log` at `cmd/ansible/ansible.go:13`, `executor.go:15`, `modules.go:13`, `parser.go:12`, `ssh.go:16`.
|
||||||
|
|
||||||
|
## UK English
|
||||||
|
|
||||||
|
- `executor.go:248`
|
||||||
|
Comment uses US spelling: `Initialize host results`.
|
||||||
|
- `parser.go:321`
|
||||||
|
Comment uses US spelling: `NormalizeModule normalizes a module name to its canonical form.`
|
||||||
|
- `types.go:110`
|
||||||
|
Comment uses US spelling: `LoopControl controls loop behavior.`
|
||||||
|
|
||||||
|
## Missing Tests
|
||||||
|
|
||||||
|
- `cmd/ansible/ansible.go:17`, `cmd/ansible/ansible.go:29`, `cmd/ansible/ansible.go:163`, `cmd/ansible/cmd.go:8`
|
||||||
|
`go tool cover` reports `0.0%` coverage for the entire `cmd/ansible` package, so argument parsing, command registration, playbook execution wiring, and SSH test wiring have no tests.
|
||||||
|
- `executor.go:81`, `executor.go:97`, `executor.go:172`, `executor.go:210`, `executor.go:241`, `executor.go:307`, `executor.go:382`, `executor.go:420`, `executor.go:444`, `executor.go:499`, `executor.go:565`
|
||||||
|
The main execution path is still uncovered: top-level run flow, play execution, roles, host task scheduling, loops, block handling, includes, SSH client creation, and fact gathering are all `0.0%` in the coverage report.
|
||||||
|
- `parser.go:119`
|
||||||
|
`ParseRole` is `0.0%` covered.
|
||||||
|
- `ssh.go:77`, `ssh.go:187`, `ssh.go:200`, `ssh.go:276`, `ssh.go:283`, `ssh.go:377`, `ssh.go:396`, `ssh.go:410`
|
||||||
|
Only constructor/default behaviour is tested; the real SSH transport methods are all `0.0%` covered.
|
||||||
|
- `modules.go:17`, `modules.go:178`, `modules.go:206`, `modules.go:234`, `modules.go:253`, `modules.go:281`, `modules.go:324`, `modules.go:352`, `modules.go:420`, `modules.go:463`, `modules.go:480`, `modules.go:502`, `modules.go:526`, `modules.go:550`, `modules.go:584`, `modules.go:615`, `modules.go:652`, `modules.go:665`, `modules.go:690`, `modules.go:732`, `modules.go:743`, `modules.go:800`, `modules.go:835`, `modules.go:941`, `modules.go:989`, `modules.go:1013`, `modules.go:1042`, `modules.go:1120`, `modules.go:1139`, `modules.go:1172`, `modules.go:1209`, `modules.go:1283`, `modules.go:1288`, `modules.go:1306`, `modules.go:1357`, `modules.go:1408`
|
||||||
|
The real dispatcher and production module handlers are still `0.0%` covered.
|
||||||
|
- `mock_ssh_test.go:347`, `mock_ssh_test.go:433`
|
||||||
|
Existing module tests bypass `Executor.executeModule` and the production handlers by routing through `executeModuleWithMock` and duplicated shim implementations, so module assertions do not exercise the shipped code paths.
|
||||||
|
- `CLAUDE.md:60`, `docs/index.md:141`, `docs/index.md:142`, `modules.go:941`, `modules.go:989`, `modules.go:1120`, `modules.go:1139`, `modules.go:1283`, `modules.go:1288`
|
||||||
|
Documentation advertises support for `pause`, `wait_for`, `hostname`, `sysctl`, `setup`, and `reboot`, but there are no dedicated tests for those production handlers.
|
||||||
|
|
||||||
|
## Missing SPDX Headers
|
||||||
|
|
||||||
|
No tracked text file currently contains an SPDX header.
|
||||||
|
|
||||||
|
- Repo metadata: `.github/workflows/ci.yml:1`, `.gitignore:1`, `go.mod:1`, `go.sum:1`
|
||||||
|
- Documentation: `CLAUDE.md:1`, `CONSUMERS.md:1`, `docs/architecture.md:1`, `docs/development.md:1`, `docs/index.md:1`, `kb/Executor.md:1`, `kb/Home.md:1`
|
||||||
|
- Go source: `cmd/ansible/ansible.go:1`, `cmd/ansible/cmd.go:1`, `executor.go:1`, `modules.go:1`, `parser.go:1`, `ssh.go:1`, `types.go:1`
|
||||||
|
- Go tests: `executor_extra_test.go:1`, `executor_test.go:1`, `mock_ssh_test.go:1`, `modules_adv_test.go:1`, `modules_cmd_test.go:1`, `modules_file_test.go:1`, `modules_infra_test.go:1`, `modules_svc_test.go:1`, `parser_test.go:1`, `ssh_test.go:1`, `types_test.go:1`
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
package anscmd
|
package ansiblecmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"encoding/json"
|
||||||
"path/filepath"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -11,12 +12,38 @@ import (
|
||||||
"dappco.re/go/core/ansible"
|
"dappco.re/go/core/ansible"
|
||||||
coreio "dappco.re/go/core/io"
|
coreio "dappco.re/go/core/io"
|
||||||
coreerr "dappco.re/go/core/log"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// args extracts all positional arguments from Options.
|
type playbookCommandOptions struct {
|
||||||
func args(opts core.Options) []string {
|
playbookPath string
|
||||||
|
basePath string
|
||||||
|
limit string
|
||||||
|
tags []string
|
||||||
|
skipTags []string
|
||||||
|
extraVars map[string]any
|
||||||
|
verbose int
|
||||||
|
checkMode bool
|
||||||
|
diff bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCommaSeparatedOption(value string) []string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var out []string
|
var out []string
|
||||||
for _, o := range opts {
|
for _, item := range split(value, ",") {
|
||||||
|
if trimmed := trimSpace(item); trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// positionalArgs extracts all positional arguments from Options.
|
||||||
|
func positionalArgs(opts core.Options) []string {
|
||||||
|
var out []string
|
||||||
|
for _, o := range opts.Items() {
|
||||||
if o.Key == "_arg" {
|
if o.Key == "_arg" {
|
||||||
if s, ok := o.Value.(string); ok {
|
if s, ok := o.Value.(string); ok {
|
||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
|
|
@ -26,78 +53,342 @@ func args(opts core.Options) []string {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAnsible(opts core.Options) core.Result {
|
// firstStringOption returns the first non-empty string for any of the provided keys.
|
||||||
positional := args(opts)
|
func firstStringOption(opts core.Options, keys ...string) string {
|
||||||
if len(positional) < 1 {
|
for _, key := range keys {
|
||||||
return core.Result{Value: coreerr.E("runAnsible", "usage: ansible <playbook>", nil)}
|
if value := opts.String(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
playbookPath := positional[0]
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve playbook path
|
// firstBoolOption returns true when any of the provided keys is set to true.
|
||||||
if !filepath.IsAbs(playbookPath) {
|
func firstBoolOption(opts core.Options, keys ...string) bool {
|
||||||
playbookPath, _ = filepath.Abs(playbookPath)
|
for _, key := range keys {
|
||||||
|
if opts.Bool(key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if !coreio.Local.Exists(playbookPath) {
|
// collectStringOptionValues returns every string value for any of the provided
|
||||||
return core.Result{Value: coreerr.E("runAnsible", fmt.Sprintf("playbook not found: %s", playbookPath), nil)}
|
// keys, preserving the original option order.
|
||||||
}
|
func collectStringOptionValues(opts core.Options, keys ...string) []string {
|
||||||
|
var out []string
|
||||||
|
|
||||||
// Create executor
|
for _, o := range opts.Items() {
|
||||||
basePath := filepath.Dir(playbookPath)
|
matched := false
|
||||||
executor := ansible.NewExecutor(basePath)
|
for _, key := range keys {
|
||||||
defer executor.Close()
|
if o.Key == key {
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Set options
|
switch v := o.Value.(type) {
|
||||||
executor.Limit = opts.String("limit")
|
case string:
|
||||||
executor.CheckMode = opts.Bool("check")
|
out = append(out, v)
|
||||||
executor.Verbose = opts.Int("verbose")
|
case []string:
|
||||||
|
out = append(out, v...)
|
||||||
if tags := opts.String("tags"); tags != "" {
|
case []any:
|
||||||
executor.Tags = strings.Split(tags, ",")
|
for _, item := range v {
|
||||||
}
|
if s, ok := item.(string); ok {
|
||||||
if skipTags := opts.String("skip-tags"); skipTags != "" {
|
out = append(out, s)
|
||||||
executor.SkipTags = strings.Split(skipTags, ",")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Parse extra vars
|
|
||||||
if extraVars := opts.String("extra-vars"); extraVars != "" {
|
|
||||||
for _, v := range strings.Split(extraVars, ",") {
|
|
||||||
parts := strings.SplitN(v, "=", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
executor.SetVar(parts[0], parts[1])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinedStringOption joins every non-empty string value for the provided keys.
|
||||||
|
func joinedStringOption(opts core.Options, keys ...string) string {
|
||||||
|
values := collectStringOptionValues(opts, keys...)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []string
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := trimSpace(value); trimmed != "" {
|
||||||
|
filtered = append(filtered, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(filtered, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// verbosityLevel resolves the effective verbosity from parsed options and the
|
||||||
|
// raw command line arguments. The core CLI parser does not preserve repeated
|
||||||
|
// `-v` tokens, so we count them from os.Args as a fallback.
|
||||||
|
func verbosityLevel(opts core.Options, rawArgs []string) int {
|
||||||
|
level := opts.Int("verbose")
|
||||||
|
if firstBoolOption(opts, "v") && level < 1 {
|
||||||
|
level = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, arg := range rawArgs {
|
||||||
|
switch {
|
||||||
|
case arg == "-v" || arg == "--verbose":
|
||||||
|
level++
|
||||||
|
case strings.HasPrefix(arg, "--verbose="):
|
||||||
|
if n, err := strconv.Atoi(strings.TrimPrefix(arg, "--verbose=")); err == nil && n > level {
|
||||||
|
level = n
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--"):
|
||||||
|
short := strings.TrimPrefix(arg, "-")
|
||||||
|
if short != "" && strings.Trim(short, "v") == "" {
|
||||||
|
if n := len([]rune(short)); n > level {
|
||||||
|
level = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
// extraVars collects all repeated extra-vars values from Options.
|
||||||
|
func extraVars(opts core.Options) (map[string]any, error) {
|
||||||
|
vars := make(map[string]any)
|
||||||
|
|
||||||
|
for _, o := range opts.Items() {
|
||||||
|
if o.Key != "extra-vars" && o.Key != "e" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
switch v := o.Value.(type) {
|
||||||
|
case string:
|
||||||
|
values = append(values, v)
|
||||||
|
case []string:
|
||||||
|
values = append(values, v...)
|
||||||
|
case []any:
|
||||||
|
for _, item := range v {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
values = append(values, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, value := range values {
|
||||||
|
parsed, err := parseExtraVarsValue(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for key, parsedValue := range parsed {
|
||||||
|
vars[key] = parsedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vars, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExtraVarsValue(value string) (map[string]any, error) {
|
||||||
|
trimmed := trimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "@") {
|
||||||
|
filePath := trimSpace(strings.TrimPrefix(trimmed, "@"))
|
||||||
|
if filePath == "" {
|
||||||
|
return nil, coreerr.E("parseExtraVarsValue", "extra vars file path required", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := coreio.Local.Read(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, coreerr.E("parseExtraVarsValue", "read extra vars file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseExtraVarsValue(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
if structured, ok := parseStructuredExtraVars(trimmed); ok {
|
||||||
|
return structured, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(trimmed, "=") {
|
||||||
|
return parseKeyValueExtraVars(trimmed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStructuredExtraVars(value string) (map[string]any, bool) {
|
||||||
|
var parsed map[string]any
|
||||||
|
if json.Valid([]byte(value)) {
|
||||||
|
if err := yaml.Unmarshal([]byte(value), &parsed); err == nil && len(parsed) > 0 {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal([]byte(value), &parsed); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if len(parsed) == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKeyValueExtraVars(value string) map[string]any {
|
||||||
|
vars := make(map[string]any)
|
||||||
|
|
||||||
|
for _, pair := range split(value, ",") {
|
||||||
|
pair = trimSpace(pair)
|
||||||
|
if pair == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := splitN(pair, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := trimSpace(parts[0])
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
vars[key] = parseExtraVarsScalar(trimSpace(parts[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return vars
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExtraVarsScalar(value string) any {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed any
|
||||||
|
if err := yaml.Unmarshal([]byte(value), &parsed); err == nil {
|
||||||
|
switch parsed.(type) {
|
||||||
|
case map[string]any, []any:
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// core ansible test server.example.com -i ~/.ssh/id_ed25519
|
||||||
|
func resolveSSHTestKeyFile(opts core.Options) string {
|
||||||
|
if key := opts.String("key"); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return opts.String("i")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPlaybookCommandSettings(opts core.Options, rawArgs []string) (playbookCommandOptions, error) {
|
||||||
|
positional := positionalArgs(opts)
|
||||||
|
if len(positional) < 1 {
|
||||||
|
return playbookCommandOptions{}, coreerr.E("buildPlaybookCommandSettings", "usage: ansible <playbook>", nil)
|
||||||
|
}
|
||||||
|
playbookPath := positional[0]
|
||||||
|
|
||||||
|
if !pathIsAbs(playbookPath) {
|
||||||
|
playbookPath = absPath(playbookPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !coreio.Local.Exists(playbookPath) {
|
||||||
|
return playbookCommandOptions{}, coreerr.E("buildPlaybookCommandSettings", sprintf("playbook not found: %s", playbookPath), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
vars, err := extraVars(opts)
|
||||||
|
if err != nil {
|
||||||
|
return playbookCommandOptions{}, coreerr.E("buildPlaybookCommandSettings", "parse extra vars", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return playbookCommandOptions{
|
||||||
|
playbookPath: playbookPath,
|
||||||
|
basePath: pathDir(playbookPath),
|
||||||
|
limit: joinedStringOption(opts, "limit", "l"),
|
||||||
|
tags: splitCommaSeparatedOption(joinedStringOption(opts, "tags", "t")),
|
||||||
|
skipTags: splitCommaSeparatedOption(joinedStringOption(opts, "skip-tags")),
|
||||||
|
extraVars: vars,
|
||||||
|
verbose: verbosityLevel(opts, rawArgs),
|
||||||
|
checkMode: opts.Bool("check"),
|
||||||
|
diff: opts.Bool("diff"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffOutputLines(diff map[string]any) []string {
|
||||||
|
lines := []string{"diff:"}
|
||||||
|
|
||||||
|
if path, ok := diff["path"].(string); ok && path != "" {
|
||||||
|
lines = append(lines, sprintf("path: %s", path))
|
||||||
|
}
|
||||||
|
if before, ok := diff["before"].(string); ok && before != "" {
|
||||||
|
lines = append(lines, sprintf("- %s", before))
|
||||||
|
}
|
||||||
|
if after, ok := diff["after"].(string); ok && after != "" {
|
||||||
|
lines = append(lines, sprintf("+ %s", after))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPlaybookCommand(opts core.Options) core.Result {
|
||||||
|
settings, err := buildPlaybookCommandSettings(opts, os.Args[1:])
|
||||||
|
if err != nil {
|
||||||
|
return core.Result{Value: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
executor := ansible.NewExecutor(settings.basePath)
|
||||||
|
defer executor.Close()
|
||||||
|
|
||||||
|
executor.Limit = settings.limit
|
||||||
|
executor.CheckMode = settings.checkMode
|
||||||
|
executor.Diff = settings.diff
|
||||||
|
executor.Verbose = settings.verbose
|
||||||
|
executor.Tags = settings.tags
|
||||||
|
executor.SkipTags = settings.skipTags
|
||||||
|
|
||||||
|
for key, value := range settings.extraVars {
|
||||||
|
executor.SetVar(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
// Load inventory
|
// Load inventory
|
||||||
if invPath := opts.String("inventory"); invPath != "" {
|
if inventoryPath := firstStringOption(opts, "inventory", "i"); inventoryPath != "" {
|
||||||
if !filepath.IsAbs(invPath) {
|
if !pathIsAbs(inventoryPath) {
|
||||||
invPath, _ = filepath.Abs(invPath)
|
inventoryPath = absPath(inventoryPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !coreio.Local.Exists(invPath) {
|
if !coreio.Local.Exists(inventoryPath) {
|
||||||
return core.Result{Value: coreerr.E("runAnsible", fmt.Sprintf("inventory not found: %s", invPath), nil)}
|
return core.Result{Value: coreerr.E("runPlaybookCommand", sprintf("inventory not found: %s", inventoryPath), nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
if coreio.Local.IsDir(invPath) {
|
if coreio.Local.IsDir(inventoryPath) {
|
||||||
for _, name := range []string{"inventory.yml", "hosts.yml", "inventory.yaml", "hosts.yaml"} {
|
for _, name := range []string{"inventory.yml", "hosts.yml", "inventory.yaml", "hosts.yaml"} {
|
||||||
p := filepath.Join(invPath, name)
|
candidatePath := joinPath(inventoryPath, name)
|
||||||
if coreio.Local.Exists(p) {
|
if coreio.Local.Exists(candidatePath) {
|
||||||
invPath = p
|
inventoryPath = candidatePath
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := executor.SetInventory(invPath); err != nil {
|
if err := executor.SetInventory(inventoryPath); err != nil {
|
||||||
return core.Result{Value: coreerr.E("runAnsible", "load inventory", err)}
|
return core.Result{Value: coreerr.E("runPlaybookCommand", "load inventory", err)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up callbacks
|
// Set up callbacks
|
||||||
executor.OnPlayStart = func(play *ansible.Play) {
|
executor.OnPlayStart = func(play *ansible.Play) {
|
||||||
fmt.Printf("\nPLAY [%s]\n", play.Name)
|
print("")
|
||||||
fmt.Println(strings.Repeat("*", 70))
|
print("PLAY [%s]", play.Name)
|
||||||
|
print("%s", repeat("*", 70))
|
||||||
}
|
}
|
||||||
|
|
||||||
executor.OnTaskStart = func(host string, task *ansible.Task) {
|
executor.OnTaskStart = func(host string, task *ansible.Task) {
|
||||||
|
|
@ -105,9 +396,10 @@ func runAnsible(opts core.Options) core.Result {
|
||||||
if taskName == "" {
|
if taskName == "" {
|
||||||
taskName = task.Module
|
taskName = task.Module
|
||||||
}
|
}
|
||||||
fmt.Printf("\nTASK [%s]\n", taskName)
|
print("")
|
||||||
|
print("TASK [%s]", taskName)
|
||||||
if executor.Verbose > 0 {
|
if executor.Verbose > 0 {
|
||||||
fmt.Printf("host: %s\n", host)
|
print("host: %s", host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,66 +413,75 @@ func runAnsible(opts core.Options) core.Result {
|
||||||
status = "changed"
|
status = "changed"
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s: [%s]", status, host)
|
line := sprintf("%s: [%s]", status, host)
|
||||||
if result.Msg != "" && executor.Verbose > 0 {
|
if result.Msg != "" && executor.Verbose > 0 {
|
||||||
fmt.Printf(" => %s", result.Msg)
|
line = sprintf("%s => %s", line, result.Msg)
|
||||||
}
|
}
|
||||||
if result.Duration > 0 && executor.Verbose > 1 {
|
if result.Duration > 0 && executor.Verbose > 1 {
|
||||||
fmt.Printf(" (%s)", result.Duration.Round(time.Millisecond))
|
line = sprintf("%s (%s)", line, result.Duration.Round(time.Millisecond))
|
||||||
}
|
}
|
||||||
fmt.Println()
|
print("%s", line)
|
||||||
|
|
||||||
if result.Failed && result.Stderr != "" {
|
if result.Failed && result.Stderr != "" {
|
||||||
fmt.Printf("%s\n", result.Stderr)
|
print("%s", result.Stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if executor.Verbose > 1 {
|
if executor.Verbose > 1 {
|
||||||
if result.Stdout != "" {
|
if result.Stdout != "" {
|
||||||
fmt.Printf("stdout: %s\n", strings.TrimSpace(result.Stdout))
|
print("stdout: %s", trimSpace(result.Stdout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if executor.Diff {
|
||||||
|
if diff, ok := result.Data["diff"].(map[string]any); ok {
|
||||||
|
for _, line := range diffOutputLines(diff) {
|
||||||
|
print("%s", line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
executor.OnPlayEnd = func(play *ansible.Play) {
|
executor.OnPlayEnd = func(play *ansible.Play) {
|
||||||
fmt.Println()
|
print("")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run playbook
|
// Run playbook
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
fmt.Printf("Running playbook: %s\n", playbookPath)
|
print("Running playbook: %s", settings.playbookPath)
|
||||||
|
|
||||||
if err := executor.Run(ctx, playbookPath); err != nil {
|
if err := executor.Run(ctx, settings.playbookPath); err != nil {
|
||||||
return core.Result{Value: coreerr.E("runAnsible", "playbook failed", err)}
|
return core.Result{Value: coreerr.E("runPlaybookCommand", "playbook failed", err)}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nPlaybook completed in %s\n", time.Since(start).Round(time.Millisecond))
|
print("")
|
||||||
|
print("Playbook completed in %s", time.Since(start).Round(time.Millisecond))
|
||||||
|
|
||||||
return core.Result{OK: true}
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAnsibleTest(opts core.Options) core.Result {
|
func runSSHTestCommand(opts core.Options) core.Result {
|
||||||
positional := args(opts)
|
positional := positionalArgs(opts)
|
||||||
if len(positional) < 1 {
|
if len(positional) < 1 {
|
||||||
return core.Result{Value: coreerr.E("runAnsibleTest", "usage: ansible test <host>", nil)}
|
return core.Result{Value: coreerr.E("runSSHTestCommand", "usage: ansible test <host>", nil)}
|
||||||
}
|
}
|
||||||
host := positional[0]
|
host := positional[0]
|
||||||
|
|
||||||
fmt.Printf("Testing SSH connection to %s...\n", host)
|
print("Testing SSH connection to %s...", host)
|
||||||
|
|
||||||
cfg := ansible.SSHConfig{
|
config := ansible.SSHConfig{
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: opts.Int("port"),
|
Port: opts.Int("port"),
|
||||||
User: opts.String("user"),
|
User: firstStringOption(opts, "user", "u"),
|
||||||
Password: opts.String("password"),
|
Password: opts.String("password"),
|
||||||
KeyFile: opts.String("key"),
|
KeyFile: resolveSSHTestKeyFile(opts),
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := ansible.NewSSHClient(cfg)
|
client, err := ansible.NewSSHClient(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return core.Result{Value: coreerr.E("runAnsibleTest", "create client", err)}
|
return core.Result{Value: coreerr.E("runSSHTestCommand", "create client", err)}
|
||||||
}
|
}
|
||||||
defer func() { _ = client.Close() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
|
|
@ -190,50 +491,52 @@ func runAnsibleTest(opts core.Options) core.Result {
|
||||||
// Test connection
|
// Test connection
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
if err := client.Connect(ctx); err != nil {
|
if err := client.Connect(ctx); err != nil {
|
||||||
return core.Result{Value: coreerr.E("runAnsibleTest", "connect failed", err)}
|
return core.Result{Value: coreerr.E("runSSHTestCommand", "connect failed", err)}
|
||||||
}
|
}
|
||||||
connectTime := time.Since(start)
|
connectTime := time.Since(start)
|
||||||
|
|
||||||
fmt.Printf("Connected in %s\n", connectTime.Round(time.Millisecond))
|
print("Connected in %s", connectTime.Round(time.Millisecond))
|
||||||
|
|
||||||
// Gather facts
|
// Gather facts
|
||||||
fmt.Println("\nGathering facts...")
|
print("")
|
||||||
|
print("Gathering facts...")
|
||||||
|
|
||||||
stdout, _, _, _ := client.Run(ctx, "hostname -f 2>/dev/null || hostname")
|
stdout, _, _, _ := client.Run(ctx, "hostname -f 2>/dev/null || hostname")
|
||||||
fmt.Printf(" Hostname: %s\n", strings.TrimSpace(stdout))
|
print(" Hostname: %s", trimSpace(stdout))
|
||||||
|
|
||||||
stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
|
stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
|
||||||
if stdout != "" {
|
if stdout != "" {
|
||||||
fmt.Printf(" OS: %s\n", strings.TrimSpace(stdout))
|
print(" OS: %s", trimSpace(stdout))
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout, _, _, _ = client.Run(ctx, "uname -r")
|
stdout, _, _, _ = client.Run(ctx, "uname -r")
|
||||||
fmt.Printf(" Kernel: %s\n", strings.TrimSpace(stdout))
|
print(" Kernel: %s", trimSpace(stdout))
|
||||||
|
|
||||||
stdout, _, _, _ = client.Run(ctx, "uname -m")
|
stdout, _, _, _ = client.Run(ctx, "uname -m")
|
||||||
fmt.Printf(" Architecture: %s\n", strings.TrimSpace(stdout))
|
print(" Architecture: %s", trimSpace(stdout))
|
||||||
|
|
||||||
stdout, _, _, _ = client.Run(ctx, "free -h | grep Mem | awk '{print $2}'")
|
stdout, _, _, _ = client.Run(ctx, "free -h | grep Mem | awk '{print $2}'")
|
||||||
fmt.Printf(" Memory: %s\n", strings.TrimSpace(stdout))
|
print(" Memory: %s", trimSpace(stdout))
|
||||||
|
|
||||||
stdout, _, _, _ = client.Run(ctx, "df -h / | tail -1 | awk '{print $2 \" total, \" $4 \" available\"}'")
|
stdout, _, _, _ = client.Run(ctx, "df -h / | tail -1 | awk '{print $2 \" total, \" $4 \" available\"}'")
|
||||||
fmt.Printf(" Disk: %s\n", strings.TrimSpace(stdout))
|
print(" Disk: %s", trimSpace(stdout))
|
||||||
|
|
||||||
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null")
|
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Printf(" Docker: %s\n", strings.TrimSpace(stdout))
|
print(" Docker: %s", trimSpace(stdout))
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" Docker: not installed\n")
|
print(" Docker: not installed")
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout, _, _, _ = client.Run(ctx, "docker ps 2>/dev/null | grep -q coolify && echo 'running' || echo 'not running'")
|
stdout, _, _, _ = client.Run(ctx, "docker ps 2>/dev/null | grep -q coolify && echo 'running' || echo 'not running'")
|
||||||
if strings.TrimSpace(stdout) == "running" {
|
if trimSpace(stdout) == "running" {
|
||||||
fmt.Printf(" Coolify: running\n")
|
print(" Coolify: running")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" Coolify: not installed\n")
|
print(" Coolify: not installed")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\nSSH test passed\n")
|
print("")
|
||||||
|
print("SSH test passed")
|
||||||
|
|
||||||
return core.Result{OK: true}
|
return core.Result{OK: true}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
371
cmd/ansible/ansible_test.go
Normal file
371
cmd/ansible/ansible_test.go
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
package ansiblecmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtraVars_Good_RepeatableAndCommaSeparated(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "extra-vars", Value: "version=1.2.3,env=prod"},
|
||||||
|
core.Option{Key: "extra-vars", Value: "region=us-east-1"},
|
||||||
|
core.Option{Key: "extra-vars", Value: []string{"build=42"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
vars, err := extraVars(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"version": "1.2.3",
|
||||||
|
"env": "prod",
|
||||||
|
"region": "us-east-1",
|
||||||
|
"build": 42,
|
||||||
|
}, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraVars_Good_UsesShortAlias(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "e", Value: "version=1.2.3,env=prod"},
|
||||||
|
)
|
||||||
|
|
||||||
|
vars, err := extraVars(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"version": "1.2.3",
|
||||||
|
"env": "prod",
|
||||||
|
}, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraVars_Good_TrimsWhitespaceAroundPairs(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "extra-vars", Value: " version = 1.2.3 , env = prod , empty = "},
|
||||||
|
)
|
||||||
|
|
||||||
|
vars, err := extraVars(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"version": "1.2.3",
|
||||||
|
"env": "prod",
|
||||||
|
"empty": "",
|
||||||
|
}, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraVars_Good_IgnoresMalformedPairs(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "extra-vars", Value: "missing_equals,keep=this"},
|
||||||
|
core.Option{Key: "extra-vars", Value: "also_bad="},
|
||||||
|
)
|
||||||
|
|
||||||
|
vars, err := extraVars(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"keep": "this",
|
||||||
|
"also_bad": "",
|
||||||
|
}, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraVars_Good_ParsesYAMLScalarsInKeyValuePairs(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "extra-vars", Value: "enabled=true,count=42,threshold=3.5"},
|
||||||
|
)
|
||||||
|
|
||||||
|
vars, err := extraVars(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"count": 42,
|
||||||
|
"threshold": 3.5,
|
||||||
|
}, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitCommaSeparatedOption_Good_TrimsWhitespace(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"deploy", "setup", "smoke"}, splitCommaSeparatedOption(" deploy, setup ,smoke "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraVars_Good_SupportsStructuredYAMLAndJSON(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "extra-vars", Value: "app:\n port: 8080\n debug: true"},
|
||||||
|
core.Option{Key: "extra-vars", Value: `{"image":"nginx:latest","replicas":3}`},
|
||||||
|
)
|
||||||
|
|
||||||
|
vars, err := extraVars(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"app": map[string]any{
|
||||||
|
"port": int(8080),
|
||||||
|
"debug": true,
|
||||||
|
},
|
||||||
|
"image": "nginx:latest",
|
||||||
|
"replicas": int(3),
|
||||||
|
}, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraVars_Good_LoadsFileReferences(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "vars.yml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("deploy_env: prod\nrelease: 42\n"), 0644))
|
||||||
|
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "extra-vars", Value: "@" + path},
|
||||||
|
)
|
||||||
|
|
||||||
|
vars, err := extraVars(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]any{
|
||||||
|
"deploy_env": "prod",
|
||||||
|
"release": int(42),
|
||||||
|
}, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraVars_Bad_MissingFile(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "extra-vars", Value: "@/definitely/missing/vars.yml"},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := extraVars(opts)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "read extra vars file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstString_Good_PrefersFirstNonEmptyKey(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "inventory", Value: ""},
|
||||||
|
core.Option{Key: "i", Value: "/tmp/inventory.yml"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "/tmp/inventory.yml", firstStringOption(opts, "inventory", "i"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstBool_Good_UsesAlias(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "v", Value: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.True(t, firstBoolOption(opts, "verbose", "v"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerbosityLevel_Good_CountsStackedShortFlags(t *testing.T) {
|
||||||
|
opts := core.NewOptions()
|
||||||
|
|
||||||
|
assert.Equal(t, 3, verbosityLevel(opts, []string{"-vvv"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerbosityLevel_Good_CountsLongForm(t *testing.T) {
|
||||||
|
opts := core.NewOptions()
|
||||||
|
|
||||||
|
assert.Equal(t, 1, verbosityLevel(opts, []string{"--verbose"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerbosityLevel_Good_PreservesExplicitNumericLevel(t *testing.T) {
|
||||||
|
opts := core.NewOptions(core.Option{Key: "verbose", Value: 2})
|
||||||
|
|
||||||
|
assert.Equal(t, 2, verbosityLevel(opts, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlaybookCommandSettings_Good_AppliesFlags(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
playbookPath := filepath.Join(dir, "site.yml")
|
||||||
|
require.NoError(t, os.WriteFile(playbookPath, []byte("- hosts: all\n tasks: []\n"), 0644))
|
||||||
|
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "_arg", Value: playbookPath},
|
||||||
|
core.Option{Key: "limit", Value: "web1"},
|
||||||
|
core.Option{Key: "tags", Value: "deploy,setup"},
|
||||||
|
core.Option{Key: "skip-tags", Value: "slow"},
|
||||||
|
core.Option{Key: "extra-vars", Value: "version=1.2.3"},
|
||||||
|
core.Option{Key: "check", Value: true},
|
||||||
|
core.Option{Key: "diff", Value: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
settings, err := buildPlaybookCommandSettings(opts, []string{"-vvv"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, playbookPath, settings.playbookPath)
|
||||||
|
assert.Equal(t, dir, settings.basePath)
|
||||||
|
assert.Equal(t, "web1", settings.limit)
|
||||||
|
assert.Equal(t, []string{"deploy", "setup"}, settings.tags)
|
||||||
|
assert.Equal(t, []string{"slow"}, settings.skipTags)
|
||||||
|
assert.Equal(t, 3, settings.verbose)
|
||||||
|
assert.True(t, settings.checkMode)
|
||||||
|
assert.True(t, settings.diff)
|
||||||
|
assert.Equal(t, map[string]any{"version": "1.2.3"}, settings.extraVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlaybookCommandSettings_Good_MergesRepeatedListFlags(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
playbookPath := filepath.Join(dir, "site.yml")
|
||||||
|
require.NoError(t, os.WriteFile(playbookPath, []byte("- hosts: all\n tasks: []\n"), 0644))
|
||||||
|
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "_arg", Value: playbookPath},
|
||||||
|
core.Option{Key: "limit", Value: "web1"},
|
||||||
|
core.Option{Key: "limit", Value: []string{"web2"}},
|
||||||
|
core.Option{Key: "tags", Value: "deploy,setup"},
|
||||||
|
core.Option{Key: "tags", Value: []string{"smoke"}},
|
||||||
|
core.Option{Key: "skip-tags", Value: "slow"},
|
||||||
|
core.Option{Key: "skip-tags", Value: []string{"flaky,experimental"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
settings, err := buildPlaybookCommandSettings(opts, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "web1,web2", settings.limit)
|
||||||
|
assert.Equal(t, []string{"deploy", "setup", "smoke"}, settings.tags)
|
||||||
|
assert.Equal(t, []string{"slow", "flaky", "experimental"}, settings.skipTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlaybookCommandSettings_Bad_MissingPlaybook(t *testing.T) {
|
||||||
|
_, err := buildPlaybookCommandSettings(core.NewOptions(), nil)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "usage: ansible <playbook>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffOutputLines_Good_IncludesPathAndBeforeAfter(t *testing.T) {
|
||||||
|
lines := diffOutputLines(map[string]any{
|
||||||
|
"path": "/etc/nginx/conf.d/app.conf",
|
||||||
|
"before": "server_name=old.example.com;",
|
||||||
|
"after": "server_name=web01.example.com;",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"diff:",
|
||||||
|
"path: /etc/nginx/conf.d/app.conf",
|
||||||
|
"- server_name=old.example.com;",
|
||||||
|
"+ server_name=web01.example.com;",
|
||||||
|
}, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestKeyFile_Good_PrefersExplicitKey(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "key", Value: "/tmp/id_ed25519"},
|
||||||
|
core.Option{Key: "i", Value: "/tmp/ignored"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "/tmp/id_ed25519", resolveSSHTestKeyFile(opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestKeyFile_Good_FallsBackToShortAlias(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "i", Value: "/tmp/id_ed25519"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, "/tmp/id_ed25519", resolveSSHTestKeyFile(opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstString_Good_ResolvesShortUserAlias(t *testing.T) {
|
||||||
|
opts := core.NewOptions(
|
||||||
|
core.Option{Key: "u", Value: "deploy"},
|
||||||
|
)
|
||||||
|
|
||||||
|
cfgUser := firstStringOption(opts, "user", "u")
|
||||||
|
|
||||||
|
assert.Equal(t, "deploy", cfgUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_Good_RegistersAnsibleCommands(t *testing.T) {
|
||||||
|
app := core.New()
|
||||||
|
|
||||||
|
Register(app)
|
||||||
|
|
||||||
|
ansible := app.Command("ansible")
|
||||||
|
require.True(t, ansible.OK)
|
||||||
|
ansibleCmd := ansible.Value.(*core.Command)
|
||||||
|
|
||||||
|
assert.Equal(t, "ansible", ansibleCmd.Path)
|
||||||
|
assert.Equal(t, "ansible", ansibleCmd.Name)
|
||||||
|
assert.Equal(t, "Run Ansible playbooks natively (no Python required)", ansibleCmd.Description)
|
||||||
|
require.NotNil(t, ansibleCmd.Action)
|
||||||
|
|
||||||
|
test := app.Command("ansible/test")
|
||||||
|
require.True(t, test.OK)
|
||||||
|
testCmd := test.Value.(*core.Command)
|
||||||
|
|
||||||
|
assert.Equal(t, "ansible/test", testCmd.Path)
|
||||||
|
assert.Equal(t, "test", testCmd.Name)
|
||||||
|
assert.Equal(t, "Test SSH connectivity to a host", testCmd.Description)
|
||||||
|
require.NotNil(t, testCmd.Action)
|
||||||
|
|
||||||
|
paths := app.Commands()
|
||||||
|
assert.Contains(t, paths, "ansible")
|
||||||
|
assert.Contains(t, paths, "ansible/test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_Good_ExposesExpectedFlags(t *testing.T) {
|
||||||
|
app := core.New()
|
||||||
|
|
||||||
|
Register(app)
|
||||||
|
|
||||||
|
ansibleCmd := app.Command("ansible").Value.(*core.Command)
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("inventory"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("i"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("limit"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("l"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("tags"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("t"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("skip-tags"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("extra-vars"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("e"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("verbose"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("v"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("check"))
|
||||||
|
assert.True(t, ansibleCmd.Flags.Has("diff"))
|
||||||
|
|
||||||
|
assert.Equal(t, "", ansibleCmd.Flags.String("inventory"))
|
||||||
|
assert.Equal(t, "", ansibleCmd.Flags.String("i"))
|
||||||
|
assert.Equal(t, "", ansibleCmd.Flags.String("limit"))
|
||||||
|
assert.Equal(t, "", ansibleCmd.Flags.String("l"))
|
||||||
|
assert.Equal(t, "", ansibleCmd.Flags.String("tags"))
|
||||||
|
assert.Equal(t, "", ansibleCmd.Flags.String("t"))
|
||||||
|
assert.Equal(t, "", ansibleCmd.Flags.String("skip-tags"))
|
||||||
|
assert.Equal(t, "", ansibleCmd.Flags.String("extra-vars"))
|
||||||
|
assert.Equal(t, "", ansibleCmd.Flags.String("e"))
|
||||||
|
assert.Equal(t, 0, ansibleCmd.Flags.Int("verbose"))
|
||||||
|
assert.False(t, ansibleCmd.Flags.Bool("v"))
|
||||||
|
assert.False(t, ansibleCmd.Flags.Bool("check"))
|
||||||
|
assert.False(t, ansibleCmd.Flags.Bool("diff"))
|
||||||
|
|
||||||
|
testCmd := app.Command("ansible/test").Value.(*core.Command)
|
||||||
|
assert.True(t, testCmd.Flags.Has("user"))
|
||||||
|
assert.True(t, testCmd.Flags.Has("u"))
|
||||||
|
assert.True(t, testCmd.Flags.Has("password"))
|
||||||
|
assert.True(t, testCmd.Flags.Has("key"))
|
||||||
|
assert.True(t, testCmd.Flags.Has("i"))
|
||||||
|
assert.True(t, testCmd.Flags.Has("port"))
|
||||||
|
|
||||||
|
assert.Equal(t, "root", testCmd.Flags.String("user"))
|
||||||
|
assert.Equal(t, "root", testCmd.Flags.String("u"))
|
||||||
|
assert.Equal(t, "", testCmd.Flags.String("password"))
|
||||||
|
assert.Equal(t, "", testCmd.Flags.String("key"))
|
||||||
|
assert.Equal(t, "", testCmd.Flags.String("i"))
|
||||||
|
assert.Equal(t, 22, testCmd.Flags.Int("port"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAnsible_Bad_MissingPlaybook(t *testing.T) {
|
||||||
|
result := runPlaybookCommand(core.NewOptions())
|
||||||
|
|
||||||
|
require.False(t, result.OK)
|
||||||
|
err, ok := result.Value.(error)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, err.Error(), "usage: ansible <playbook>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAnsibleTest_Bad_MissingHost(t *testing.T) {
|
||||||
|
result := runSSHTestCommand(core.NewOptions())
|
||||||
|
|
||||||
|
require.False(t, result.OK)
|
||||||
|
err, ok := result.Value.(error)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, err.Error(), "usage: ansible test <host>")
|
||||||
|
}
|
||||||
|
|
@ -1,33 +1,46 @@
|
||||||
package anscmd
|
package ansiblecmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"dappco.re/go/core"
|
"dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register registers the 'ansible' command and all subcommands on the given Core instance.
|
// Register registers the `ansible` command and its `ansible/test` subcommand.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// var app core.Core
|
||||||
|
// Register(&app)
|
||||||
func Register(c *core.Core) {
|
func Register(c *core.Core) {
|
||||||
c.Command("ansible", core.Command{
|
c.Command("ansible", core.Command{
|
||||||
Description: "Run Ansible playbooks natively (no Python required)",
|
Description: "Run Ansible playbooks natively (no Python required)",
|
||||||
Action: runAnsible,
|
Action: runPlaybookCommand,
|
||||||
Flags: core.Options{
|
Flags: core.NewOptions(
|
||||||
{Key: "inventory", Value: ""},
|
core.Option{Key: "inventory", Value: ""},
|
||||||
{Key: "limit", Value: ""},
|
core.Option{Key: "i", Value: ""},
|
||||||
{Key: "tags", Value: ""},
|
core.Option{Key: "limit", Value: ""},
|
||||||
{Key: "skip-tags", Value: ""},
|
core.Option{Key: "l", Value: ""},
|
||||||
{Key: "extra-vars", Value: ""},
|
core.Option{Key: "tags", Value: ""},
|
||||||
{Key: "verbose", Value: 0},
|
core.Option{Key: "t", Value: ""},
|
||||||
{Key: "check", Value: false},
|
core.Option{Key: "skip-tags", Value: ""},
|
||||||
},
|
core.Option{Key: "extra-vars", Value: ""},
|
||||||
|
core.Option{Key: "e", Value: ""},
|
||||||
|
core.Option{Key: "verbose", Value: 0},
|
||||||
|
core.Option{Key: "v", Value: false},
|
||||||
|
core.Option{Key: "check", Value: false},
|
||||||
|
core.Option{Key: "diff", Value: false},
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
c.Command("ansible/test", core.Command{
|
c.Command("ansible/test", core.Command{
|
||||||
Description: "Test SSH connectivity to a host",
|
Description: "Test SSH connectivity to a host",
|
||||||
Action: runAnsibleTest,
|
Action: runSSHTestCommand,
|
||||||
Flags: core.Options{
|
Flags: core.NewOptions(
|
||||||
{Key: "user", Value: "root"},
|
core.Option{Key: "user", Value: "root"},
|
||||||
{Key: "password", Value: ""},
|
core.Option{Key: "u", Value: "root"},
|
||||||
{Key: "key", Value: ""},
|
core.Option{Key: "password", Value: ""},
|
||||||
{Key: "port", Value: 22},
|
core.Option{Key: "key", Value: ""},
|
||||||
},
|
core.Option{Key: "i", Value: ""},
|
||||||
|
core.Option{Key: "port", Value: 22},
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
160
cmd/ansible/core_primitives.go
Normal file
160
cmd/ansible/core_primitives.go
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
package ansiblecmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func absPath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return core.Env("DIR_CWD")
|
||||||
|
}
|
||||||
|
if core.PathIsAbs(path) {
|
||||||
|
return cleanPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd := core.Env("DIR_CWD")
|
||||||
|
if cwd == "" {
|
||||||
|
cwd = "."
|
||||||
|
}
|
||||||
|
return joinPath(cwd, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPath(parts ...string) string {
|
||||||
|
ds := dirSep()
|
||||||
|
path := ""
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
path = part
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path = core.TrimSuffix(path, ds)
|
||||||
|
part = core.TrimPrefix(part, ds)
|
||||||
|
path = core.Concat(path, ds, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return core.CleanPath(path, ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanPath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return core.CleanPath(path, dirSep())
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathDir(path string) string {
|
||||||
|
return core.PathDir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathIsAbs(path string) bool {
|
||||||
|
return core.PathIsAbs(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sprintf(format string, args ...any) string {
|
||||||
|
return core.Sprintf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func split(s, sep string) []string {
|
||||||
|
return core.Split(s, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitN(s, sep string, n int) []string {
|
||||||
|
return core.SplitN(s, sep, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSpace(s string) string {
|
||||||
|
return core.Trim(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func repeat(s string, count int) string {
|
||||||
|
if count <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := core.NewBuilder()
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
buf.WriteString(s)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func print(format string, args ...any) {
|
||||||
|
core.Print(nil, format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func println(args ...any) {
|
||||||
|
core.Println(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirSep() string {
|
||||||
|
ds := core.Env("DS")
|
||||||
|
if ds == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return ds
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsRune(cutset string, target rune) bool {
|
||||||
|
for _, candidate := range cutset {
|
||||||
|
if candidate == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimCutset(s, cutset string) string {
|
||||||
|
start := 0
|
||||||
|
end := len(s)
|
||||||
|
|
||||||
|
for start < end {
|
||||||
|
r, size := utf8.DecodeRuneInString(s[start:end])
|
||||||
|
if !containsRune(cutset, r) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
start += size
|
||||||
|
}
|
||||||
|
|
||||||
|
for start < end {
|
||||||
|
r, size := utf8.DecodeLastRuneInString(s[start:end])
|
||||||
|
if !containsRune(cutset, r) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end -= size
|
||||||
|
}
|
||||||
|
|
||||||
|
return s[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func fields(s string) []string {
|
||||||
|
var out []string
|
||||||
|
start := -1
|
||||||
|
|
||||||
|
for i, r := range s {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
if start >= 0 {
|
||||||
|
out = append(out, s[start:i])
|
||||||
|
start = -1
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if start < 0 {
|
||||||
|
start = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if start >= 0 {
|
||||||
|
out = append(out, s[start:])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
292
core_primitives.go
Normal file
292
core_primitives.go
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
package ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stringBuffer interface {
|
||||||
|
Write([]byte) (int, error)
|
||||||
|
WriteString(string) (int, error)
|
||||||
|
String() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirSep() string {
|
||||||
|
ds := core.Env("DS")
|
||||||
|
if ds == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return ds
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexAbsPath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return core.Env("DIR_CWD")
|
||||||
|
}
|
||||||
|
if core.PathIsAbs(path) {
|
||||||
|
return corexCleanPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd := core.Env("DIR_CWD")
|
||||||
|
if cwd == "" {
|
||||||
|
cwd = "."
|
||||||
|
}
|
||||||
|
return corexJoinPath(cwd, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexJoinPath(parts ...string) string {
|
||||||
|
ds := dirSep()
|
||||||
|
path := ""
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
path = part
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path = core.TrimSuffix(path, ds)
|
||||||
|
part = core.TrimPrefix(part, ds)
|
||||||
|
path = core.Concat(path, ds, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return core.CleanPath(path, ds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexCleanPath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return core.CleanPath(path, dirSep())
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexPathDir(path string) string {
|
||||||
|
return core.PathDir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexPathBase(path string) string {
|
||||||
|
return core.PathBase(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexPathIsAbs(path string) bool {
|
||||||
|
return core.PathIsAbs(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexEnv(key string) string {
|
||||||
|
return core.Env(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexSprintf(format string, args ...any) string {
|
||||||
|
return core.Sprintf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexSprint(args ...any) string {
|
||||||
|
return core.Sprint(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexContains(s, substr string) bool {
|
||||||
|
return core.Contains(s, substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexHasPrefix(s, prefix string) bool {
|
||||||
|
return core.HasPrefix(s, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexHasSuffix(s, suffix string) bool {
|
||||||
|
return core.HasSuffix(s, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexSplit(s, sep string) []string {
|
||||||
|
return core.Split(s, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexSplitN(s, sep string, n int) []string {
|
||||||
|
return core.SplitN(s, sep, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexJoin(sep string, parts []string) string {
|
||||||
|
return core.Join(sep, parts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexLower(s string) string {
|
||||||
|
return core.Lower(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexReplaceAll(s, old, new string) string {
|
||||||
|
return core.Replace(s, old, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexReplaceN(s, old, new string, n int) string {
|
||||||
|
if n == 0 || old == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return corexReplaceAll(s, old, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := s
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
index := corexStringIndex(result, old)
|
||||||
|
if index < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = core.Concat(result[:index], new, result[index+len(old):])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexTrimSpace(s string) string {
|
||||||
|
return core.Trim(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexTrimPrefix(s, prefix string) string {
|
||||||
|
return core.TrimPrefix(s, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexTrimCutset(s, cutset string) string {
|
||||||
|
start := 0
|
||||||
|
end := len(s)
|
||||||
|
|
||||||
|
for start < end {
|
||||||
|
r, size := utf8.DecodeRuneInString(s[start:end])
|
||||||
|
if !corexContainsRune(cutset, r) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
start += size
|
||||||
|
}
|
||||||
|
|
||||||
|
for start < end {
|
||||||
|
r, size := utf8.DecodeLastRuneInString(s[start:end])
|
||||||
|
if !corexContainsRune(cutset, r) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end -= size
|
||||||
|
}
|
||||||
|
|
||||||
|
return s[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexRepeat(s string, count int) string {
|
||||||
|
if count <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := core.NewBuilder()
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
buf.WriteString(s)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexFields(s string) []string {
|
||||||
|
var out []string
|
||||||
|
start := -1
|
||||||
|
|
||||||
|
for i, r := range s {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
if start >= 0 {
|
||||||
|
out = append(out, s[start:i])
|
||||||
|
start = -1
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if start < 0 {
|
||||||
|
start = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if start >= 0 {
|
||||||
|
out = append(out, s[start:])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexNewBuilder() stringBuffer {
|
||||||
|
return core.NewBuilder()
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexNewReader(s string) interface {
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
} {
|
||||||
|
return core.NewReader(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexReadAllString(reader any) (string, error) {
|
||||||
|
result := core.ReadAll(reader)
|
||||||
|
if !result.OK {
|
||||||
|
if err, ok := result.Value.(error); ok {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "", core.NewError("read content")
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, ok := result.Value.(string); ok {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
return corexSprint(result.Value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexWriteString(writer interface {
|
||||||
|
Write([]byte) (int, error)
|
||||||
|
}, value string) {
|
||||||
|
_, _ = writer.Write([]byte(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexContainsRune(cutset string, target rune) bool {
|
||||||
|
for _, candidate := range cutset {
|
||||||
|
if candidate == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func corexStringIndex(s, needle string) int {
|
||||||
|
if needle == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if len(needle) > len(s) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i+len(needle) <= len(s); i++ {
|
||||||
|
if s[i:i+len(needle)] == needle {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func absPath(path string) string { return corexAbsPath(path) }
|
||||||
|
func joinPath(parts ...string) string { return corexJoinPath(parts...) }
|
||||||
|
func cleanPath(path string) string { return corexCleanPath(path) }
|
||||||
|
func pathDir(path string) string { return corexPathDir(path) }
|
||||||
|
func pathBase(path string) string { return corexPathBase(path) }
|
||||||
|
func pathIsAbs(path string) bool { return corexPathIsAbs(path) }
|
||||||
|
func env(key string) string { return corexEnv(key) }
|
||||||
|
func sprintf(format string, args ...any) string { return corexSprintf(format, args...) }
|
||||||
|
func sprint(args ...any) string { return corexSprint(args...) }
|
||||||
|
func contains(s, substr string) bool { return corexContains(s, substr) }
|
||||||
|
func hasSuffix(s, suffix string) bool { return corexHasSuffix(s, suffix) }
|
||||||
|
func split(s, sep string) []string { return corexSplit(s, sep) }
|
||||||
|
func splitN(s, sep string, n int) []string { return corexSplitN(s, sep, n) }
|
||||||
|
func join(sep string, parts []string) string { return corexJoin(sep, parts) }
|
||||||
|
func lower(s string) string { return corexLower(s) }
|
||||||
|
func replaceAll(s, old, new string) string { return corexReplaceAll(s, old, new) }
|
||||||
|
func replaceN(s, old, new string, n int) string { return corexReplaceN(s, old, new, n) }
|
||||||
|
func trimSpace(s string) string { return corexTrimSpace(s) }
|
||||||
|
func trimCutset(s, cutset string) string { return corexTrimCutset(s, cutset) }
|
||||||
|
func repeat(s string, count int) string { return corexRepeat(s, count) }
|
||||||
|
func fields(s string) []string { return corexFields(s) }
|
||||||
|
func newBuilder() stringBuffer { return corexNewBuilder() }
|
||||||
|
func newReader(s string) interface{ Read([]byte) (int, error) } { return corexNewReader(s) }
|
||||||
|
func readAllString(reader any) (string, error) { return corexReadAllString(reader) }
|
||||||
|
func writeString(writer interface{ Write([]byte) (int, error) }, value string) {
|
||||||
|
corexWriteString(writer, value)
|
||||||
|
}
|
||||||
76
docs/api-contract.md
Normal file
76
docs/api-contract.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# API Contract
|
||||||
|
|
||||||
|
`CODEX.md` is not present in this repository. This extraction follows the repo conventions documented in `CLAUDE.md`.
|
||||||
|
|
||||||
|
Function and method coverage percentages below come from `go test -coverprofile=/tmp/ansible.cover ./...` run on 2026-03-23. Type rows list the tests that exercise or validate each type because Go coverage does not report percentages for type declarations. Exported variables are intentionally excluded because the task requested exported types, functions, and methods only.
|
||||||
|
|
||||||
|
## Package `ansible`: Types
|
||||||
|
|
||||||
|
| Name | Signature | Description | Test coverage |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `Playbook` | `type Playbook struct` | Top-level playbook container for an inline list of plays. | No direct tests; parser tests work with `[]Play` rather than `Playbook`. |
|
||||||
|
| `Play` | `type Play struct` | Declares play metadata, host targeting, vars, lifecycle task lists, roles, handlers, tags, and execution settings. | Exercised by `parser_test.go` playbook cases and `executor_extra_test.go: TestParsePlaybookIter_Good`. |
|
||||||
|
| `RoleRef` | `type RoleRef struct` | Represents a role reference, including string and map forms plus role-scoped vars, tags, and `when`. | Directly validated by `types_test.go: TestRoleRef_UnmarshalYAML_*`; also parsed in `parser_test.go: TestParsePlaybook_Good_RoleRefs`. |
|
||||||
|
| `Task` | `type Task struct` | Represents a task, including module selection, args, conditions, loops, block/rescue/always sections, includes, notify, and privilege controls. | Directly validated by `types_test.go: TestTask_UnmarshalYAML_*`; also exercised throughout `parser_test.go` and module dispatch tests. |
|
||||||
|
| `LoopControl` | `type LoopControl struct` | Configures loop variable names, labels, pauses, and extended loop metadata. | No dedicated tests; only reachable through `Task.loop_control` parsing. |
|
||||||
|
| `TaskResult` | `type TaskResult struct` | Standard task execution result with change/failure state, output streams, loop subresults, module data, and duration. | Directly validated by `types_test.go: TestTaskResult_*`; also used across executor and module tests. |
|
||||||
|
| `Inventory` | `type Inventory struct` | Root Ansible inventory object keyed under `all`. | Directly validated by `types_test.go: TestInventory_UnmarshalYAML_Good_Complex`; also exercised by parser and host-resolution tests. |
|
||||||
|
| `InventoryGroup` | `type InventoryGroup struct` | Inventory group containing hosts, child groups, and inherited vars. | Exercised by `parser_test.go` inventory and host-matching cases plus `executor_extra_test.go: TestAllHostsIter_Good`. |
|
||||||
|
| `Host` | `type Host struct` | Host-level connection settings plus inline custom vars. | Exercised by inventory parsing and host-var inheritance tests in `types_test.go` and `parser_test.go`. |
|
||||||
|
| `Facts` | `type Facts struct` | Captures gathered host facts such as identity, OS, kernel, memory, CPUs, and primary IPv4. | Directly validated by `types_test.go: TestFacts_Struct`; also exercised by fact resolution tests in `modules_infra_test.go` and `executor_extra_test.go`. |
|
||||||
|
| `Parser` | `type Parser struct` | Stateful YAML parser for playbooks, inventories, task files, and roles. | Directly exercised by `parser_test.go: TestNewParser_Good` and all parser method tests. |
|
||||||
|
| `Executor` | `type Executor struct` | Playbook execution engine holding parser/inventory state, callbacks, vars/results/facts, SSH clients, and run options. | Directly exercised by `executor_test.go`, `executor_extra_test.go`, and module tests; the public `Run` entrypoint itself is untested. |
|
||||||
|
| `SSHClient` | `type SSHClient struct` | Lazy SSH client that tracks connection, auth, privilege-escalation state, and timeout. | Constructor and become state are covered in `ssh_test.go` and `modules_infra_test.go`; network and file-transfer methods are untested. |
|
||||||
|
| `SSHConfig` | `type SSHConfig struct` | Public configuration for SSH connection, auth, become, and timeout defaults. | Directly exercised by `ssh_test.go: TestNewSSHClient`, `TestSSHConfig_Defaults`, and become-state tests in `modules_infra_test.go`. |
|
||||||
|
|
||||||
|
## Package `ansible`: Parser and Inventory API
|
||||||
|
|
||||||
|
| Name | Signature | Description | Test coverage |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `(*RoleRef).UnmarshalYAML` | `func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error` | Accepts either a scalar role name or a structured role reference and normalises `name` into `Role`. | `91.7%`; covered by `types_test.go: TestRoleRef_UnmarshalYAML_*`. |
|
||||||
|
| `(*Task).UnmarshalYAML` | `func (t *Task) UnmarshalYAML(node *yaml.Node) error` | Decodes known task fields, extracts the module key dynamically, and converts `with_items` into `Loop`. | `87.5%`; covered by `types_test.go: TestTask_UnmarshalYAML_*`. |
|
||||||
|
| `NewParser` | `func NewParser(basePath string) *Parser` | Constructs a parser rooted at `basePath` with an empty variable map. | `100.0%`; covered by `parser_test.go: TestNewParser_Good`. |
|
||||||
|
| `(*Parser).ParsePlaybook` | `func (p *Parser) ParsePlaybook(path string) ([]Play, error)` | Reads a playbook YAML file into plays and post-processes each play's tasks. | `90.0%`; covered by `parser_test.go: TestParsePlaybook_*`. |
|
||||||
|
| `(*Parser).ParsePlaybookIter` | `func (p *Parser) ParsePlaybookIter(path string) (iter.Seq[Play], error)` | Wraps `ParsePlaybook` with an iterator over plays. | `85.7%`; covered by `executor_extra_test.go: TestParsePlaybookIter_*`. |
|
||||||
|
| `(*Parser).ParseInventory` | `func (p *Parser) ParseInventory(path string) (*Inventory, error)` | Reads an inventory YAML file into the public inventory model. | `100.0%`; covered by `parser_test.go: TestParseInventory_*`. |
|
||||||
|
| `(*Parser).ParseTasks` | `func (p *Parser) ParseTasks(path string) ([]Task, error)` | Reads a task file and extracts module metadata for each task. | `90.0%`; covered by `parser_test.go: TestParseTasks_*`. |
|
||||||
|
| `(*Parser).ParseTasksIter` | `func (p *Parser) ParseTasksIter(path string) (iter.Seq[Task], error)` | Wraps `ParseTasks` with an iterator over tasks. | `85.7%`; covered by `executor_extra_test.go: TestParseTasksIter_*`. |
|
||||||
|
| `(*Parser).ParseRole` | `func (p *Parser) ParseRole(name string, tasksFrom string) ([]Task, error)` | Resolves a role across several search paths, loads role defaults and vars, then parses the requested task file. | `0.0%`; no automated tests found. |
|
||||||
|
| `NormalizeModule` | `func NormalizeModule(name string) string` | Canonicalises short module names to `ansible.builtin.*` while leaving dotted names unchanged. | `100.0%`; covered by `parser_test.go: TestNormalizeModule_*`. |
|
||||||
|
| `GetHosts` | `func GetHosts(inv *Inventory, pattern string) []string` | Resolves `all`, `localhost`, group names, or explicit host names from inventory. | `100.0%`; covered by `parser_test.go: TestGetHosts_*`. |
|
||||||
|
| `GetHostsIter` | `func GetHostsIter(inv *Inventory, pattern string) iter.Seq[string]` | Iterator wrapper over `GetHosts`. | `80.0%`; covered by `executor_extra_test.go: TestGetHostsIter_Good`. |
|
||||||
|
| `AllHostsIter` | `func AllHostsIter(group *InventoryGroup) iter.Seq[string]` | Iterates every host in a group tree with deterministic key ordering. | `84.6%`; covered by `executor_extra_test.go: TestAllHostsIter_*`. |
|
||||||
|
| `GetHostVars` | `func GetHostVars(inv *Inventory, hostname string) map[string]any` | Collects effective variables for a host by merging group ancestry with host-specific connection vars and inline vars. | `100.0%`; covered by `parser_test.go: TestGetHostVars_*`. |
|
||||||
|
|
||||||
|
## Package `ansible`: Executor API
|
||||||
|
|
||||||
|
| Name | Signature | Description | Test coverage |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `NewExecutor` | `func NewExecutor(basePath string) *Executor` | Constructs an executor with parser state, variable stores, handler tracking, and SSH client cache. | `100.0%`; covered by `executor_test.go: TestNewExecutor_Good`. |
|
||||||
|
| `(*Executor).SetInventory` | `func (e *Executor) SetInventory(path string) error` | Loads inventory from disk via the embedded parser and stores it on the executor. | `100.0%`; covered by `executor_extra_test.go: TestSetInventory_*`. |
|
||||||
|
| `(*Executor).SetInventoryDirect` | `func (e *Executor) SetInventoryDirect(inv *Inventory)` | Replaces the executor inventory with a caller-supplied value. | `100.0%`; covered by `executor_test.go: TestSetInventoryDirect_Good`. |
|
||||||
|
| `(*Executor).SetVar` | `func (e *Executor) SetVar(key string, value any)` | Stores a variable in the executor-scoped variable map under lock. | `100.0%`; covered by `executor_test.go: TestSetVar_Good`. |
|
||||||
|
| `(*Executor).Run` | `func (e *Executor) Run(ctx context.Context, playbookPath string) error` | Parses a playbook and executes each play in order. | `0.0%`; no automated tests found for the public run path. |
|
||||||
|
| `(*Executor).Close` | `func (e *Executor) Close()` | Closes all cached SSH clients and resets the client cache. | `80.0%`; covered by `executor_test.go: TestClose_Good_EmptyClients`. |
|
||||||
|
| `(*Executor).TemplateFile` | `func (e *Executor) TemplateFile(src, host string, task *Task) (string, error)` | Reads a template, performs a basic Jinja2-to-Go-template conversion, and falls back to string substitution if parsing or execution fails. | `75.0%`; exercised indirectly by `modules_file_test.go: TestModuleTemplate_*`. |
|
||||||
|
|
||||||
|
## Package `ansible`: SSH API
|
||||||
|
|
||||||
|
| Name | Signature | Description | Test coverage |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `NewSSHClient` | `func NewSSHClient(cfg SSHConfig) (*SSHClient, error)` | Applies SSH defaults and constructs a client from the supplied config. | `100.0%`; covered by `ssh_test.go: TestNewSSHClient`, `TestSSHConfig_Defaults`, plus become-state tests in `modules_infra_test.go`. |
|
||||||
|
| `(*SSHClient).Connect` | `func (c *SSHClient) Connect(ctx context.Context) error` | Lazily establishes an SSH connection using key, password, and `known_hosts` handling. | `0.0%`; no automated tests found. |
|
||||||
|
| `(*SSHClient).Close` | `func (c *SSHClient) Close() error` | Closes the active SSH connection if one exists. | `0.0%`; no automated tests found. |
|
||||||
|
| `(*SSHClient).Run` | `func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error)` | Executes a remote command, optionally wrapped with `sudo`, and returns streams plus exit code. | `0.0%`; no automated tests found for the concrete SSH implementation. |
|
||||||
|
| `(*SSHClient).RunScript` | `func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error)` | Executes a remote shell script through a heredoc wrapper. | `0.0%`; no automated tests found. |
|
||||||
|
| `(*SSHClient).Upload` | `func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error` | Uploads content to a remote file, creating parent directories and handling `sudo` writes when needed. | `0.0%`; no automated tests found. |
|
||||||
|
| `(*SSHClient).Download` | `func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error)` | Downloads a remote file by reading it with `cat`. | `0.0%`; no automated tests found. |
|
||||||
|
| `(*SSHClient).FileExists` | `func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error)` | Checks remote path existence with `test -e`. | `0.0%`; no automated tests found. |
|
||||||
|
| `(*SSHClient).Stat` | `func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error)` | Returns a minimal remote stat map describing existence and directory state. | `0.0%`; no automated tests found. |
|
||||||
|
| `(*SSHClient).SetBecome` | `func (c *SSHClient) SetBecome(become bool, user, password string)` | Updates the privilege-escalation settings stored on the client. | `100.0%`; covered by `modules_infra_test.go: TestBecome_Infra_Good_*`. |
|
||||||
|
|
||||||
|
## Package `ansiblecmd`: CLI API
|
||||||
|
|
||||||
|
| Name | Signature | Description | Test coverage |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `Register` | `func Register(c *core.Core)` | Registers the `ansible` and `ansible/test` CLI commands and their flags on a `core.Core` instance. | `0.0%`; `cmd/ansible` has no test files. |
|
||||||
|
|
@ -222,6 +222,7 @@ The `evaluateWhen` method processes `when:` clauses. It supports:
|
||||||
|
|
||||||
- Boolean literals: `true`, `false`, `True`, `False`
|
- Boolean literals: `true`, `false`, `True`, `False`
|
||||||
- Negation: `not <condition>`
|
- Negation: `not <condition>`
|
||||||
|
- Inline boolean expressions with `and`, `or`, and parentheses
|
||||||
- Registered variable checks: `result is defined`, `result is success`, `result is failed`, `result is changed`, `result is skipped`
|
- Registered variable checks: `result is defined`, `result is success`, `result is failed`, `result is changed`, `result is skipped`
|
||||||
- Variable truthiness: checks `vars` map for the condition as a key, evaluating booleans, non-empty strings, and non-zero integers
|
- Variable truthiness: checks `vars` map for the condition as a key, evaluating booleans, non-empty strings, and non-zero integers
|
||||||
- Default filter handling: `var | default(value)` always evaluates to true (permissive)
|
- Default filter handling: `var | default(value)` always evaluates to true (permissive)
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ go-ansible/
|
||||||
types.go Core data types and KnownModules registry
|
types.go Core data types and KnownModules registry
|
||||||
parser.go YAML parsing (playbooks, inventories, roles)
|
parser.go YAML parsing (playbooks, inventories, roles)
|
||||||
executor.go Execution engine (orchestration, templating, conditions)
|
executor.go Execution engine (orchestration, templating, conditions)
|
||||||
modules.go 41 module handler implementations
|
modules.go 49 module handler implementations
|
||||||
ssh.go SSH client (auth, commands, file transfer, become)
|
ssh.go SSH client (auth, commands, file transfer, become)
|
||||||
*_test.go Test files (see table above)
|
*_test.go Test files (see table above)
|
||||||
cmd/
|
cmd/
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ go-ansible/
|
||||||
types.go Core data types: Playbook, Play, Task, Inventory, Host, Facts
|
types.go Core data types: Playbook, Play, Task, Inventory, Host, Facts
|
||||||
parser.go YAML parser for playbooks, inventories, tasks, roles
|
parser.go YAML parser for playbooks, inventories, tasks, roles
|
||||||
executor.go Execution engine: module dispatch, templating, conditions, loops
|
executor.go Execution engine: module dispatch, templating, conditions, loops
|
||||||
modules.go 41 module implementations (shell, apt, docker-compose, etc.)
|
modules.go 50 module implementations (shell, apt, replace, docker-compose, setup, etc.)
|
||||||
ssh.go SSH client with key/password auth, become/sudo, file transfer
|
ssh.go SSH client with key/password auth, become/sudo, file transfer
|
||||||
types_test.go Tests for data types and YAML unmarshalling
|
types_test.go Tests for data types and YAML unmarshalling
|
||||||
parser_test.go Tests for the YAML parser
|
parser_test.go Tests for the YAML parser
|
||||||
|
|
@ -126,20 +126,20 @@ go-ansible/
|
||||||
|
|
||||||
## Supported Modules
|
## Supported Modules
|
||||||
|
|
||||||
41 module handlers are implemented, covering the most commonly used Ansible modules:
|
50 module handlers are implemented, covering the most commonly used Ansible modules:
|
||||||
|
|
||||||
| Category | Modules |
|
| Category | Modules |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| **Command execution** | `shell`, `command`, `raw`, `script` |
|
| **Command execution** | `shell`, `command`, `raw`, `script` |
|
||||||
| **File operations** | `copy`, `template`, `file`, `lineinfile`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` |
|
| **File operations** | `copy`, `template`, `file`, `lineinfile`, `replace`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` |
|
||||||
| **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip` |
|
| **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip`, `rpm` |
|
||||||
| **Service management** | `service`, `systemd` |
|
| **Service management** | `service`, `systemd` |
|
||||||
| **User and group** | `user`, `group` |
|
| **User and group** | `user`, `group` |
|
||||||
| **HTTP** | `uri` |
|
| **HTTP** | `uri` |
|
||||||
| **Source control** | `git` |
|
| **Source control** | `git` |
|
||||||
| **Archive** | `unarchive` |
|
| **Archive** | `unarchive` |
|
||||||
| **System** | `hostname`, `sysctl`, `cron`, `reboot`, `setup` |
|
| **System** | `hostname`, `sysctl`, `cron`, `reboot`, `setup` |
|
||||||
| **Flow control** | `debug`, `fail`, `assert`, `set_fact`, `pause`, `wait_for`, `meta`, `include_vars` |
|
| **Flow control** | `debug`, `fail`, `assert`, `set_fact`, `add_host`, `pause`, `wait_for`, `meta`, `include_vars` |
|
||||||
| **Community** | `community.general.ufw`, `ansible.posix.authorized_key`, `community.docker.docker_compose` |
|
| **Community** | `community.general.ufw`, `ansible.posix.authorized_key`, `community.docker.docker_compose` |
|
||||||
|
|
||||||
Both fully-qualified collection names (e.g. `ansible.builtin.shell`) and short-form names (e.g. `shell`) are accepted.
|
Both fully-qualified collection names (e.g. `ansible.builtin.shell`) and short-form names (e.g. `shell`) are accepted.
|
||||||
|
|
|
||||||
3986
executor.go
3986
executor.go
File diff suppressed because it is too large
Load diff
137
executor_become_test.go
Normal file
137
executor_become_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type becomeRecordingClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
become bool
|
||||||
|
becomeUser string
|
||||||
|
becomePass string
|
||||||
|
runBecomeSeen []bool
|
||||||
|
runBecomePass []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *becomeRecordingClient) Run(_ context.Context, _ string) (string, string, int, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.runBecomeSeen = append(c.runBecomeSeen, c.become)
|
||||||
|
c.runBecomePass = append(c.runBecomePass, c.becomePass)
|
||||||
|
return "", "", 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *becomeRecordingClient) RunScript(_ context.Context, _ string) (string, string, int, error) {
|
||||||
|
return c.Run(context.Background(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *becomeRecordingClient) Upload(_ context.Context, _ io.Reader, _ string, _ fs.FileMode) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *becomeRecordingClient) Download(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *becomeRecordingClient) FileExists(_ context.Context, _ string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *becomeRecordingClient) Stat(_ context.Context, _ string) (map[string]any, error) {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *becomeRecordingClient) BecomeState() (bool, string, string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.become, c.becomeUser, c.becomePass
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *becomeRecordingClient) SetBecome(become bool, user, password string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.become = become
|
||||||
|
if !become {
|
||||||
|
c.becomeUser = ""
|
||||||
|
c.becomePass = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user != "" {
|
||||||
|
c.becomeUser = user
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
c.becomePass = password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *becomeRecordingClient) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_RunTaskOnHost_Good_TaskBecomeFalseOverridesPlayBecome(t *testing.T) {
|
||||||
|
e := NewExecutor("/tmp")
|
||||||
|
e.SetInventoryDirect(&Inventory{
|
||||||
|
All: &InventoryGroup{
|
||||||
|
Hosts: map[string]*Host{
|
||||||
|
"host1": {AnsibleHost: "127.0.0.1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &becomeRecordingClient{}
|
||||||
|
client.SetBecome(true, "root", "secret")
|
||||||
|
e.clients["host1"] = client
|
||||||
|
|
||||||
|
play := &Play{Become: true, BecomeUser: "admin"}
|
||||||
|
task := &Task{
|
||||||
|
Name: "Disable become for this task",
|
||||||
|
Module: "command",
|
||||||
|
Args: map[string]any{"cmd": "echo ok"},
|
||||||
|
Become: func() *bool { v := false; return &v }(),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, e.runTaskOnHost(context.Background(), "host1", []string{"host1"}, task, play))
|
||||||
|
require.Len(t, client.runBecomeSeen, 1)
|
||||||
|
assert.False(t, client.runBecomeSeen[0])
|
||||||
|
assert.True(t, client.become)
|
||||||
|
assert.Equal(t, "admin", client.becomeUser)
|
||||||
|
assert.Equal(t, "secret", client.becomePass)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_RunTaskOnHost_Good_TaskBecomeUsesInventoryPassword(t *testing.T) {
|
||||||
|
e := NewExecutor("/tmp")
|
||||||
|
e.SetInventoryDirect(&Inventory{
|
||||||
|
All: &InventoryGroup{
|
||||||
|
Hosts: map[string]*Host{
|
||||||
|
"host1": {
|
||||||
|
AnsibleHost: "127.0.0.1",
|
||||||
|
AnsibleBecomePassword: "secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &becomeRecordingClient{}
|
||||||
|
e.clients["host1"] = client
|
||||||
|
|
||||||
|
play := &Play{}
|
||||||
|
task := &Task{
|
||||||
|
Name: "Enable become for this task",
|
||||||
|
Module: "command",
|
||||||
|
Args: map[string]any{"cmd": "echo ok"},
|
||||||
|
Become: func() *bool { v := true; return &v }(),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, e.runTaskOnHost(context.Background(), "host1", []string{"host1"}, task, play))
|
||||||
|
require.Len(t, client.runBecomeSeen, 1)
|
||||||
|
assert.True(t, client.runBecomeSeen[0])
|
||||||
|
require.Len(t, client.runBecomePass, 1)
|
||||||
|
assert.Equal(t, "secret", client.runBecomePass[0])
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
2486
executor_test.go
2486
executor_test.go
File diff suppressed because it is too large
Load diff
2
go.mod
2
go.mod
|
|
@ -3,7 +3,7 @@ module dappco.re/go/core/ansible
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core v0.5.0
|
dappco.re/go/core v0.8.0-alpha.1
|
||||||
dappco.re/go/core/io v0.2.0
|
dappco.re/go/core/io v0.2.0
|
||||||
dappco.re/go/core/log v0.1.0
|
dappco.re/go/core/log v0.1.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -1,5 +1,5 @@
|
||||||
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
|
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||||
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ Jinja2-like `{{ var }}` syntax is supported:
|
||||||
`when` supports:
|
`when` supports:
|
||||||
|
|
||||||
- Boolean literals: `true`, `false`
|
- Boolean literals: `true`, `false`
|
||||||
|
- Inline boolean expressions with `and`, `or`, and parentheses
|
||||||
- Registered variable checks: `result is success`, `result is failed`, `result is changed`, `result is defined`
|
- Registered variable checks: `result is success`, `result is failed`, `result is changed`, `result is defined`
|
||||||
- Negation: `not condition`
|
- Negation: `not condition`
|
||||||
- Variable truthiness checks
|
- Variable truthiness checks
|
||||||
|
|
|
||||||
171
local_client.go
Normal file
171
local_client.go
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
package ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// localClient executes commands and file operations on the controller host.
|
||||||
|
// It satisfies sshExecutorClient so the executor can reuse the same module
|
||||||
|
// handlers for `connection: local` playbooks.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// client := newLocalClient()
|
||||||
|
type localClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
become bool
|
||||||
|
becomeUser string
|
||||||
|
becomePass string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLocalClient creates a controller-side client for `connection: local`.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// client := newLocalClient()
|
||||||
|
func newLocalClient() *localClient {
|
||||||
|
return &localClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) BecomeState() (bool, string, string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.become, c.becomeUser, c.becomePass
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) SetBecome(become bool, user, password string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.become = become
|
||||||
|
if !become {
|
||||||
|
c.becomeUser = ""
|
||||||
|
c.becomePass = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user != "" {
|
||||||
|
c.becomeUser = user
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
c.becomePass = password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
become, becomeUser, becomePass := c.becomeStateLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
command := cmd
|
||||||
|
if become {
|
||||||
|
command = wrapLocalBecomeCommand(command, becomeUser, becomePass)
|
||||||
|
}
|
||||||
|
|
||||||
|
if become {
|
||||||
|
return runLocalShell(ctx, command, becomePass)
|
||||||
|
}
|
||||||
|
return runLocalShell(ctx, command, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
|
||||||
|
return c.Run(ctx, "bash <<'ANSIBLE_SCRIPT_EOF'\n"+script+"\nANSIBLE_SCRIPT_EOF")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) Upload(_ context.Context, localReader io.Reader, remote string, mode os.FileMode) error {
|
||||||
|
content, err := io.ReadAll(localReader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(remote), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(remote, content, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) Download(_ context.Context, remote string) ([]byte, error) {
|
||||||
|
return os.ReadFile(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) FileExists(_ context.Context, path string) (bool, error) {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) Stat(_ context.Context, path string) (map[string]any, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]any{"exists": false}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"exists": true,
|
||||||
|
"isdir": info.IsDir(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *localClient) becomeStateLocked() (bool, string, string) {
|
||||||
|
return c.become, c.becomeUser, c.becomePass
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLocalShell(ctx context.Context, command, password string) (stdout, stderr string, exitCode int, err error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "bash", "-lc", command)
|
||||||
|
|
||||||
|
var stdoutBuf, stderrBuf bytes.Buffer
|
||||||
|
cmd.Stdout = &stdoutBuf
|
||||||
|
cmd.Stderr = &stderrBuf
|
||||||
|
|
||||||
|
if password != "" {
|
||||||
|
stdin, stdinErr := cmd.StdinPipe()
|
||||||
|
if stdinErr != nil {
|
||||||
|
return "", "", -1, stdinErr
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer func() { _ = stdin.Close() }()
|
||||||
|
_, _ = io.WriteString(stdin, password+"\n")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
stdout = stdoutBuf.String()
|
||||||
|
stderr = stderrBuf.String()
|
||||||
|
if err == nil {
|
||||||
|
return stdout, stderr, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return stdout, stderr, exitErr.ExitCode(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout, stderr, -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapLocalBecomeCommand(command, user, password string) string {
|
||||||
|
if user == "" {
|
||||||
|
user = "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
escaped := strings.ReplaceAll(command, "'", "'\\''")
|
||||||
|
if password != "" {
|
||||||
|
return "sudo -S -u " + user + " bash -lc '" + escaped + "'"
|
||||||
|
}
|
||||||
|
return "sudo -n -u " + user + " bash -lc '" + escaped + "'"
|
||||||
|
}
|
||||||
96
local_client_test.go
Normal file
96
local_client_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocalClient_Good_RunAndRunScript(t *testing.T) {
|
||||||
|
client := newLocalClient()
|
||||||
|
|
||||||
|
stdout, stderr, rc, err := client.Run(context.Background(), "printf 'hello\\n'")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello\n", stdout)
|
||||||
|
assert.Equal(t, "", stderr)
|
||||||
|
assert.Equal(t, 0, rc)
|
||||||
|
|
||||||
|
stdout, stderr, rc, err = client.RunScript(context.Background(), "printf 'script\\n'")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "script\n", stdout)
|
||||||
|
assert.Equal(t, "", stderr)
|
||||||
|
assert.Equal(t, 0, rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalClient_Good_FileOperations(t *testing.T) {
|
||||||
|
client := newLocalClient()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "nested", "file.txt")
|
||||||
|
|
||||||
|
require.NoError(t, client.Upload(context.Background(), newReader("content"), path, 0o644))
|
||||||
|
|
||||||
|
exists, err := client.FileExists(context.Background(), path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists)
|
||||||
|
|
||||||
|
info, err := client.Stat(context.Background(), path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, true, info["exists"])
|
||||||
|
assert.Equal(t, false, info["isdir"])
|
||||||
|
|
||||||
|
content, err := client.Download(context.Background(), path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("content"), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_RunTaskOnHost_Good_LocalConnection(t *testing.T) {
|
||||||
|
e := NewExecutor("/tmp")
|
||||||
|
|
||||||
|
task := &Task{
|
||||||
|
Name: "Local shell",
|
||||||
|
Module: "shell",
|
||||||
|
Args: map[string]any{"_raw_params": "printf 'local ok\\n'"},
|
||||||
|
Register: "local_result",
|
||||||
|
}
|
||||||
|
play := &Play{Connection: "local"}
|
||||||
|
|
||||||
|
require.NoError(t, e.runTaskOnHosts(context.Background(), []string{"host1"}, task, play))
|
||||||
|
|
||||||
|
result := e.results["host1"]["local_result"]
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.Equal(t, "local ok\n", result.Stdout)
|
||||||
|
assert.False(t, result.Failed)
|
||||||
|
|
||||||
|
_, ok := e.clients["host1"].(*localClient)
|
||||||
|
assert.True(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_GatherFacts_Good_LocalConnection(t *testing.T) {
|
||||||
|
e := NewExecutor("/tmp")
|
||||||
|
|
||||||
|
require.NoError(t, e.gatherFacts(context.Background(), "host1", &Play{Connection: "local"}))
|
||||||
|
|
||||||
|
facts := e.facts["host1"]
|
||||||
|
require.NotNil(t, facts)
|
||||||
|
assert.NotEmpty(t, facts.Hostname)
|
||||||
|
assert.NotEmpty(t, facts.Kernel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalClient_Good_SetBecomeResetsStateWhenDisabled(t *testing.T) {
|
||||||
|
client := newLocalClient()
|
||||||
|
|
||||||
|
client.SetBecome(true, "admin", "secret")
|
||||||
|
become, user, password := client.BecomeState()
|
||||||
|
assert.True(t, become)
|
||||||
|
assert.Equal(t, "admin", user)
|
||||||
|
assert.Equal(t, "secret", password)
|
||||||
|
|
||||||
|
client.SetBecome(false, "", "")
|
||||||
|
become, user, password = client.BecomeState()
|
||||||
|
assert.False(t, become)
|
||||||
|
assert.Empty(t, user)
|
||||||
|
assert.Empty(t, password)
|
||||||
|
}
|
||||||
1294
mock_ssh_test.go
1294
mock_ssh_test.go
File diff suppressed because it is too large
Load diff
3903
modules.go
3903
modules.go
File diff suppressed because it is too large
Load diff
1583
modules_adv_test.go
1583
modules_adv_test.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,7 @@
|
||||||
package ansible
|
package ansible
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"context"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -15,7 +14,7 @@ import (
|
||||||
|
|
||||||
// --- MockSSHClient basic tests ---
|
// --- MockSSHClient basic tests ---
|
||||||
|
|
||||||
func TestMockSSHClient_Good_RunRecordsExecution(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_RunRecordsExecution(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
mock.expectCommand("echo hello", "hello\n", "", 0)
|
mock.expectCommand("echo hello", "hello\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -30,7 +29,7 @@ func TestMockSSHClient_Good_RunRecordsExecution(t *testing.T) {
|
||||||
assert.Equal(t, "echo hello", mock.lastCommand().Cmd)
|
assert.Equal(t, "echo hello", mock.lastCommand().Cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
mock.expectCommand("set -e", "ok", "", 0)
|
mock.expectCommand("set -e", "ok", "", 0)
|
||||||
|
|
||||||
|
|
@ -43,7 +42,7 @@ func TestMockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
|
||||||
assert.Equal(t, "RunScript", mock.lastCommand().Method)
|
assert.Equal(t, "RunScript", mock.lastCommand().Method)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
// No expectations registered — should return empty success
|
// No expectations registered — should return empty success
|
||||||
|
|
@ -55,7 +54,7 @@ func TestMockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
|
||||||
assert.Equal(t, 0, rc)
|
assert.Equal(t, 0, rc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_LastMatchWins(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_LastMatchWins(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
mock.expectCommand("echo", "first", "", 0)
|
mock.expectCommand("echo", "first", "", 0)
|
||||||
mock.expectCommand("echo", "second", "", 0)
|
mock.expectCommand("echo", "second", "", 0)
|
||||||
|
|
@ -65,7 +64,7 @@ func TestMockSSHClient_Good_LastMatchWins(t *testing.T) {
|
||||||
assert.Equal(t, "second", stdout)
|
assert.Equal(t, "second", stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_FileOperations(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_FileOperations(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
// File does not exist initially
|
// File does not exist initially
|
||||||
|
|
@ -91,7 +90,7 @@ func TestMockSSHClient_Good_FileOperations(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_StatWithExplicit(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_StatWithExplicit(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
mock.addStat("/var/log", map[string]any{"exists": true, "isdir": true})
|
mock.addStat("/var/log", map[string]any{"exists": true, "isdir": true})
|
||||||
|
|
||||||
|
|
@ -101,7 +100,7 @@ func TestMockSSHClient_Good_StatWithExplicit(t *testing.T) {
|
||||||
assert.Equal(t, true, info["isdir"])
|
assert.Equal(t, true, info["isdir"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_StatFallback(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_StatFallback(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost"))
|
mock.addFile("/etc/hosts", []byte("127.0.0.1 localhost"))
|
||||||
|
|
||||||
|
|
@ -115,7 +114,7 @@ func TestMockSSHClient_Good_StatFallback(t *testing.T) {
|
||||||
assert.Equal(t, false, info["exists"])
|
assert.Equal(t, false, info["exists"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_BecomeTracking(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_BecomeTracking(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
assert.False(t, mock.become)
|
assert.False(t, mock.become)
|
||||||
|
|
@ -128,7 +127,26 @@ func TestMockSSHClient_Good_BecomeTracking(t *testing.T) {
|
||||||
assert.Equal(t, "secret", mock.becomePass)
|
assert.Equal(t, "secret", mock.becomePass)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_HasExecuted(t *testing.T) {
|
func TestModulesCmd_ModuleScript_Good_RelativePathResolvedAgainstBasePath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
scriptPath := joinPath(dir, "scripts", "deploy.sh")
|
||||||
|
require.NoError(t, writeTestFile(scriptPath, []byte("echo deploy"), 0755))
|
||||||
|
|
||||||
|
e := NewExecutor(dir)
|
||||||
|
mock := NewMockSSHClient()
|
||||||
|
mock.expectCommand("echo deploy", "deploy\n", "", 0)
|
||||||
|
|
||||||
|
result, err := e.moduleScript(context.Background(), mock, map[string]any{
|
||||||
|
"_raw_params": "scripts/deploy.sh",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.Equal(t, "deploy\n", result.Stdout)
|
||||||
|
assert.True(t, mock.hasExecuted("echo deploy"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_MockSSHClient_Good_HasExecuted(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
_, _, _, _ = mock.Run(nil, "systemctl restart nginx")
|
_, _, _, _ = mock.Run(nil, "systemctl restart nginx")
|
||||||
_, _, _, _ = mock.Run(nil, "apt-get update")
|
_, _, _, _ = mock.Run(nil, "apt-get update")
|
||||||
|
|
@ -138,7 +156,7 @@ func TestMockSSHClient_Good_HasExecuted(t *testing.T) {
|
||||||
assert.False(t, mock.hasExecuted("yum"))
|
assert.False(t, mock.hasExecuted("yum"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_HasExecutedMethod(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_HasExecutedMethod(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
_, _, _, _ = mock.Run(nil, "echo run")
|
_, _, _, _ = mock.Run(nil, "echo run")
|
||||||
_, _, _, _ = mock.RunScript(nil, "echo script")
|
_, _, _, _ = mock.RunScript(nil, "echo script")
|
||||||
|
|
@ -149,7 +167,7 @@ func TestMockSSHClient_Good_HasExecutedMethod(t *testing.T) {
|
||||||
assert.False(t, mock.hasExecutedMethod("RunScript", "echo run"))
|
assert.False(t, mock.hasExecutedMethod("RunScript", "echo run"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_Reset(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_Reset(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
_, _, _, _ = mock.Run(nil, "echo hello")
|
_, _, _, _ = mock.Run(nil, "echo hello")
|
||||||
assert.Equal(t, 1, mock.commandCount())
|
assert.Equal(t, 1, mock.commandCount())
|
||||||
|
|
@ -158,7 +176,7 @@ func TestMockSSHClient_Good_Reset(t *testing.T) {
|
||||||
assert.Equal(t, 0, mock.commandCount())
|
assert.Equal(t, 0, mock.commandCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMockSSHClient_Good_ErrorExpectation(t *testing.T) {
|
func TestModulesCmd_MockSSHClient_Good_ErrorExpectation(t *testing.T) {
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
mock.expectCommandError("bad cmd", assert.AnError)
|
mock.expectCommandError("bad cmd", assert.AnError)
|
||||||
|
|
||||||
|
|
@ -168,7 +186,7 @@ func TestMockSSHClient_Good_ErrorExpectation(t *testing.T) {
|
||||||
|
|
||||||
// --- command module ---
|
// --- command module ---
|
||||||
|
|
||||||
func TestModuleCommand_Good_BasicCommand(t *testing.T) {
|
func TestModulesCmd_ModuleCommand_Good_BasicCommand(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("ls -la /tmp", "total 0\n", "", 0)
|
mock.expectCommand("ls -la /tmp", "total 0\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -187,7 +205,7 @@ func TestModuleCommand_Good_BasicCommand(t *testing.T) {
|
||||||
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
|
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleCommand_Good_CmdArg(t *testing.T) {
|
func TestModulesCmd_ModuleCommand_Good_CmdArg(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("whoami", "root\n", "", 0)
|
mock.expectCommand("whoami", "root\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -201,7 +219,26 @@ func TestModuleCommand_Good_CmdArg(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecutedMethod("Run", "whoami"))
|
assert.True(t, mock.hasExecutedMethod("Run", "whoami"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleCommand_Good_WithChdir(t *testing.T) {
|
func TestModulesCmd_ModuleCommand_Good_Argv(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`"echo".*"hello world"`, "hello world\n", "", 0)
|
||||||
|
|
||||||
|
task := &Task{
|
||||||
|
Module: "command",
|
||||||
|
Args: map[string]any{
|
||||||
|
"argv": []any{"echo", "hello world"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.Equal(t, "hello world\n", result.Stdout)
|
||||||
|
assert.True(t, mock.hasExecuted(`hello world`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleCommand_Good_WithChdir(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`cd "/var/log" && ls`, "syslog\n", "", 0)
|
mock.expectCommand(`cd "/var/log" && ls`, "syslog\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -219,7 +256,26 @@ func TestModuleCommand_Good_WithChdir(t *testing.T) {
|
||||||
assert.Contains(t, last.Cmd, "ls")
|
assert.Contains(t, last.Cmd, "ls")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleCommand_Bad_NoCommand(t *testing.T) {
|
func TestModulesCmd_ModuleCommand_Good_WithStdin(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand("cat", "input\n", "", 0)
|
||||||
|
|
||||||
|
result, err := moduleCommandWithClient(e, mock, map[string]any{
|
||||||
|
"_raw_params": "cat",
|
||||||
|
"stdin": "payload",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.Equal(t, "input\n", result.Stdout)
|
||||||
|
last := mock.lastCommand()
|
||||||
|
assert.Equal(t, "Run", last.Method)
|
||||||
|
assert.Contains(t, last.Cmd, "printf %s")
|
||||||
|
assert.Contains(t, last.Cmd, "| cat")
|
||||||
|
assert.Contains(t, last.Cmd, "payload\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleCommand_Bad_NoCommand(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
|
|
@ -229,7 +285,7 @@ func TestModuleCommand_Bad_NoCommand(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "no command specified")
|
assert.Contains(t, err.Error(), "no command specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleCommand_Good_NonZeroRC(t *testing.T) {
|
func TestModulesCmd_ModuleCommand_Good_NonZeroRC(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("false", "", "error occurred", 1)
|
mock.expectCommand("false", "", "error occurred", 1)
|
||||||
|
|
||||||
|
|
@ -243,7 +299,7 @@ func TestModuleCommand_Good_NonZeroRC(t *testing.T) {
|
||||||
assert.Equal(t, "error occurred", result.Stderr)
|
assert.Equal(t, "error occurred", result.Stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleCommand_Good_SSHError(t *testing.T) {
|
func TestModulesCmd_ModuleCommand_Good_SSHError(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
mock.expectCommandError(".*", assert.AnError)
|
mock.expectCommandError(".*", assert.AnError)
|
||||||
|
|
@ -257,7 +313,7 @@ func TestModuleCommand_Good_SSHError(t *testing.T) {
|
||||||
assert.Contains(t, result.Msg, assert.AnError.Error())
|
assert.Contains(t, result.Msg, assert.AnError.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
|
func TestModulesCmd_ModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("from_raw", "raw\n", "", 0)
|
mock.expectCommand("from_raw", "raw\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -271,9 +327,47 @@ func TestModuleCommand_Good_RawParamsTakesPrecedence(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted("from_raw"))
|
assert.True(t, mock.hasExecuted("from_raw"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleCommand_Good_SkipsWhenCreatesExists(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.addFile("/tmp/output.txt", []byte("done"))
|
||||||
|
|
||||||
|
task := &Task{
|
||||||
|
Module: "ansible.builtin.command",
|
||||||
|
Args: map[string]any{
|
||||||
|
"_raw_params": "echo should-not-run",
|
||||||
|
"creates": "/tmp/output.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.Changed)
|
||||||
|
assert.Equal(t, 0, mock.commandCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleCommand_Good_SkipsWhenCreatesExistsUnderChdir(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.addFile("/app/build/output.txt", []byte("done"))
|
||||||
|
|
||||||
|
task := &Task{
|
||||||
|
Module: "ansible.builtin.command",
|
||||||
|
Args: map[string]any{
|
||||||
|
"_raw_params": "echo should-not-run",
|
||||||
|
"creates": "build/output.txt",
|
||||||
|
"chdir": "/app",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.Changed)
|
||||||
|
assert.Equal(t, 0, mock.commandCount())
|
||||||
|
}
|
||||||
|
|
||||||
// --- shell module ---
|
// --- shell module ---
|
||||||
|
|
||||||
func TestModuleShell_Good_BasicShell(t *testing.T) {
|
func TestModulesCmd_ModuleShell_Good_BasicShell(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("echo hello", "hello\n", "", 0)
|
mock.expectCommand("echo hello", "hello\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -291,7 +385,7 @@ func TestModuleShell_Good_BasicShell(t *testing.T) {
|
||||||
assert.False(t, mock.hasExecutedMethod("Run", ".*"))
|
assert.False(t, mock.hasExecutedMethod("Run", ".*"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleShell_Good_CmdArg(t *testing.T) {
|
func TestModulesCmd_ModuleShell_Good_CmdArg(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("date", "Thu Feb 20\n", "", 0)
|
mock.expectCommand("date", "Thu Feb 20\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -304,7 +398,7 @@ func TestModuleShell_Good_CmdArg(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecutedMethod("RunScript", "date"))
|
assert.True(t, mock.hasExecutedMethod("RunScript", "date"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleShell_Good_WithChdir(t *testing.T) {
|
func TestModulesCmd_ModuleShell_Good_WithChdir(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`cd "/app" && npm install`, "done\n", "", 0)
|
mock.expectCommand(`cd "/app" && npm install`, "done\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -321,7 +415,27 @@ func TestModuleShell_Good_WithChdir(t *testing.T) {
|
||||||
assert.Contains(t, last.Cmd, "npm install")
|
assert.Contains(t, last.Cmd, "npm install")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleShell_Bad_NoCommand(t *testing.T) {
|
func TestModulesCmd_ModuleShell_Good_ExecutableUsesRun(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`/bin/dash.*echo test`, "test\n", "", 0)
|
||||||
|
|
||||||
|
result, err := e.moduleShell(context.Background(), mock, map[string]any{
|
||||||
|
"_raw_params": "echo test",
|
||||||
|
"executable": "/bin/dash",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
|
||||||
|
last := mock.lastCommand()
|
||||||
|
require.NotNil(t, last)
|
||||||
|
assert.Equal(t, "Run", last.Method)
|
||||||
|
assert.Contains(t, last.Cmd, "/bin/dash")
|
||||||
|
assert.Contains(t, last.Cmd, "-c")
|
||||||
|
assert.Contains(t, last.Cmd, "echo test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleShell_Bad_NoCommand(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
|
|
@ -331,7 +445,7 @@ func TestModuleShell_Bad_NoCommand(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "no command specified")
|
assert.Contains(t, err.Error(), "no command specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleShell_Good_NonZeroRC(t *testing.T) {
|
func TestModulesCmd_ModuleShell_Good_NonZeroRC(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("exit 2", "", "failed", 2)
|
mock.expectCommand("exit 2", "", "failed", 2)
|
||||||
|
|
||||||
|
|
@ -344,7 +458,24 @@ func TestModuleShell_Good_NonZeroRC(t *testing.T) {
|
||||||
assert.Equal(t, 2, result.RC)
|
assert.Equal(t, 2, result.RC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleShell_Good_SSHError(t *testing.T) {
|
func TestModulesCmd_ModuleShell_Good_SkipsWhenRemovesMissing(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
|
||||||
|
task := &Task{
|
||||||
|
Module: "ansible.builtin.shell",
|
||||||
|
Args: map[string]any{
|
||||||
|
"_raw_params": "echo should-not-run",
|
||||||
|
"removes": "/tmp/missing.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := e.executeModule(context.Background(), "host1", mock, task, &Play{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, result.Changed)
|
||||||
|
assert.Equal(t, 0, mock.commandCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleShell_Good_SSHError(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
mock.expectCommandError(".*", assert.AnError)
|
mock.expectCommandError(".*", assert.AnError)
|
||||||
|
|
@ -357,7 +488,7 @@ func TestModuleShell_Good_SSHError(t *testing.T) {
|
||||||
assert.True(t, result.Failed)
|
assert.True(t, result.Failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleShell_Good_PipelineCommand(t *testing.T) {
|
func TestModulesCmd_ModuleShell_Good_PipelineCommand(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`cat /etc/passwd \| grep root`, "root:x:0:0\n", "", 0)
|
mock.expectCommand(`cat /etc/passwd \| grep root`, "root:x:0:0\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -373,7 +504,7 @@ func TestModuleShell_Good_PipelineCommand(t *testing.T) {
|
||||||
|
|
||||||
// --- raw module ---
|
// --- raw module ---
|
||||||
|
|
||||||
func TestModuleRaw_Good_BasicRaw(t *testing.T) {
|
func TestModulesCmd_ModuleRaw_Good_BasicRaw(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("uname -a", "Linux host1 5.15\n", "", 0)
|
mock.expectCommand("uname -a", "Linux host1 5.15\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -390,7 +521,7 @@ func TestModuleRaw_Good_BasicRaw(t *testing.T) {
|
||||||
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
|
assert.False(t, mock.hasExecutedMethod("RunScript", ".*"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleRaw_Bad_NoCommand(t *testing.T) {
|
func TestModulesCmd_ModuleRaw_Bad_NoCommand(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
|
|
@ -400,7 +531,7 @@ func TestModuleRaw_Bad_NoCommand(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "no command specified")
|
assert.Contains(t, err.Error(), "no command specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleRaw_Good_NoChdir(t *testing.T) {
|
func TestModulesCmd_ModuleRaw_Good_NoChdir(t *testing.T) {
|
||||||
// Raw module does NOT support chdir — it should ignore it
|
// Raw module does NOT support chdir — it should ignore it
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("echo test", "test\n", "", 0)
|
mock.expectCommand("echo test", "test\n", "", 0)
|
||||||
|
|
@ -418,7 +549,7 @@ func TestModuleRaw_Good_NoChdir(t *testing.T) {
|
||||||
assert.NotContains(t, last.Cmd, "cd")
|
assert.NotContains(t, last.Cmd, "cd")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleRaw_Good_NonZeroRC(t *testing.T) {
|
func TestModulesCmd_ModuleRaw_Good_NonZeroRC(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("invalid", "", "not found", 127)
|
mock.expectCommand("invalid", "", "not found", 127)
|
||||||
|
|
||||||
|
|
@ -432,7 +563,7 @@ func TestModuleRaw_Good_NonZeroRC(t *testing.T) {
|
||||||
assert.Equal(t, "not found", result.Stderr)
|
assert.Equal(t, "not found", result.Stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleRaw_Good_SSHError(t *testing.T) {
|
func TestModulesCmd_ModuleRaw_Good_SSHError(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
mock.expectCommandError(".*", assert.AnError)
|
mock.expectCommandError(".*", assert.AnError)
|
||||||
|
|
@ -445,7 +576,7 @@ func TestModuleRaw_Good_SSHError(t *testing.T) {
|
||||||
assert.True(t, result.Failed)
|
assert.True(t, result.Failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
|
func TestModulesCmd_ModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
|
||||||
// Raw should pass the command exactly as given — no wrapping
|
// Raw should pass the command exactly as given — no wrapping
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
complexCmd := `/usr/bin/python3 -c 'import sys; print(sys.version)'`
|
complexCmd := `/usr/bin/python3 -c 'import sys; print(sys.version)'`
|
||||||
|
|
@ -463,12 +594,12 @@ func TestModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
|
||||||
|
|
||||||
// --- script module ---
|
// --- script module ---
|
||||||
|
|
||||||
func TestModuleScript_Good_BasicScript(t *testing.T) {
|
func TestModulesCmd_ModuleScript_Good_BasicScript(t *testing.T) {
|
||||||
// Create a temporary script file
|
// Create a temporary script file
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
scriptPath := filepath.Join(tmpDir, "setup.sh")
|
scriptPath := joinPath(tmpDir, "setup.sh")
|
||||||
scriptContent := "#!/bin/bash\necho 'setup complete'\nexit 0"
|
scriptContent := "#!/bin/bash\necho 'setup complete'\nexit 0"
|
||||||
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
|
require.NoError(t, writeTestFile(scriptPath, []byte(scriptContent), 0755))
|
||||||
|
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("setup complete", "setup complete\n", "", 0)
|
mock.expectCommand("setup complete", "setup complete\n", "", 0)
|
||||||
|
|
@ -490,7 +621,73 @@ func TestModuleScript_Good_BasicScript(t *testing.T) {
|
||||||
assert.Equal(t, scriptContent, last.Cmd)
|
assert.Equal(t, scriptContent, last.Cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleScript_Bad_NoScript(t *testing.T) {
|
func TestModulesCmd_ModuleScript_Good_CreatesSkipsExecution(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
scriptPath := joinPath(tmpDir, "setup.sh")
|
||||||
|
require.NoError(t, writeTestFile(scriptPath, []byte("echo should-not-run"), 0755))
|
||||||
|
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.addFile("/tmp/already-there", []byte("present"))
|
||||||
|
|
||||||
|
result, err := e.moduleScript(context.Background(), mock, map[string]any{
|
||||||
|
"_raw_params": scriptPath,
|
||||||
|
"creates": "/tmp/already-there",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.False(t, result.Changed)
|
||||||
|
assert.Equal(t, 0, mock.commandCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleScript_Good_ChdirPrefixesScript(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
scriptPath := joinPath(tmpDir, "work.sh")
|
||||||
|
require.NoError(t, writeTestFile(scriptPath, []byte("pwd"), 0755))
|
||||||
|
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
|
||||||
|
result, err := e.moduleScript(context.Background(), mock, map[string]any{
|
||||||
|
"_raw_params": scriptPath,
|
||||||
|
"chdir": "/opt/app",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
|
||||||
|
last := mock.lastCommand()
|
||||||
|
require.NotNil(t, last)
|
||||||
|
assert.Equal(t, "RunScript", last.Method)
|
||||||
|
assert.Equal(t, `cd "/opt/app" && pwd`, last.Cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleScript_Good_ExecutableUsesRun(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
scriptPath := joinPath(tmpDir, "dash.sh")
|
||||||
|
require.NoError(t, writeTestFile(scriptPath, []byte("echo script works"), 0755))
|
||||||
|
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`/bin/dash.*echo script works`, "script works\n", "", 0)
|
||||||
|
|
||||||
|
result, err := e.moduleScript(context.Background(), mock, map[string]any{
|
||||||
|
"_raw_params": scriptPath,
|
||||||
|
"executable": "/bin/dash",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
|
||||||
|
last := mock.lastCommand()
|
||||||
|
require.NotNil(t, last)
|
||||||
|
assert.Equal(t, "Run", last.Method)
|
||||||
|
assert.Contains(t, last.Cmd, "/bin/dash")
|
||||||
|
assert.Contains(t, last.Cmd, "-c")
|
||||||
|
assert.Contains(t, last.Cmd, "echo script works")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleScript_Bad_NoScript(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
|
|
@ -500,7 +697,7 @@ func TestModuleScript_Bad_NoScript(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "no script specified")
|
assert.Contains(t, err.Error(), "no script specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleScript_Bad_FileNotFound(t *testing.T) {
|
func TestModulesCmd_ModuleScript_Bad_FileNotFound(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
|
|
@ -512,10 +709,10 @@ func TestModuleScript_Bad_FileNotFound(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "read script")
|
assert.Contains(t, err.Error(), "read script")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleScript_Good_NonZeroRC(t *testing.T) {
|
func TestModulesCmd_ModuleScript_Good_NonZeroRC(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
scriptPath := filepath.Join(tmpDir, "fail.sh")
|
scriptPath := joinPath(tmpDir, "fail.sh")
|
||||||
require.NoError(t, os.WriteFile(scriptPath, []byte("exit 1"), 0755))
|
require.NoError(t, writeTestFile(scriptPath, []byte("exit 1"), 0755))
|
||||||
|
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("exit 1", "", "script failed", 1)
|
mock.expectCommand("exit 1", "", "script failed", 1)
|
||||||
|
|
@ -529,11 +726,11 @@ func TestModuleScript_Good_NonZeroRC(t *testing.T) {
|
||||||
assert.Equal(t, 1, result.RC)
|
assert.Equal(t, 1, result.RC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleScript_Good_MultiLineScript(t *testing.T) {
|
func TestModulesCmd_ModuleScript_Good_MultiLineScript(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
scriptPath := filepath.Join(tmpDir, "multi.sh")
|
scriptPath := joinPath(tmpDir, "multi.sh")
|
||||||
scriptContent := "#!/bin/bash\nset -e\napt-get update\napt-get install -y nginx\nsystemctl start nginx"
|
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))
|
require.NoError(t, writeTestFile(scriptPath, []byte(scriptContent), 0755))
|
||||||
|
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("apt-get", "done\n", "", 0)
|
mock.expectCommand("apt-get", "done\n", "", 0)
|
||||||
|
|
@ -551,10 +748,10 @@ func TestModuleScript_Good_MultiLineScript(t *testing.T) {
|
||||||
assert.Equal(t, scriptContent, last.Cmd)
|
assert.Equal(t, scriptContent, last.Cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleScript_Good_SSHError(t *testing.T) {
|
func TestModulesCmd_ModuleScript_Good_SSHError(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
scriptPath := filepath.Join(tmpDir, "ok.sh")
|
scriptPath := joinPath(tmpDir, "ok.sh")
|
||||||
require.NoError(t, os.WriteFile(scriptPath, []byte("echo ok"), 0755))
|
require.NoError(t, writeTestFile(scriptPath, []byte("echo ok"), 0755))
|
||||||
|
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
@ -570,7 +767,7 @@ func TestModuleScript_Good_SSHError(t *testing.T) {
|
||||||
|
|
||||||
// --- Cross-module differentiation tests ---
|
// --- Cross-module differentiation tests ---
|
||||||
|
|
||||||
func TestModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
|
func TestModulesCmd_ModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("echo test", "test\n", "", 0)
|
mock.expectCommand("echo test", "test\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -581,7 +778,7 @@ func TestModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
|
||||||
assert.Equal(t, "Run", cmds[0].Method, "command module must use Run()")
|
assert.Equal(t, "Run", cmds[0].Method, "command module must use Run()")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
|
func TestModulesCmd_ModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("echo test", "test\n", "", 0)
|
mock.expectCommand("echo test", "test\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -592,7 +789,23 @@ func TestModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
|
||||||
assert.Equal(t, "RunScript", cmds[0].Method, "shell module must use RunScript()")
|
assert.Equal(t, "RunScript", cmds[0].Method, "shell module must use RunScript()")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
|
func TestModulesCmd_ModuleDifferentiation_Good_ShellWithStdinStillUsesRunScript(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand("echo test", "test\n", "", 0)
|
||||||
|
|
||||||
|
_, _ = moduleShellWithClient(e, mock, map[string]any{
|
||||||
|
"_raw_params": "echo test",
|
||||||
|
"stdin": "payload",
|
||||||
|
})
|
||||||
|
|
||||||
|
cmds := mock.executedCommands()
|
||||||
|
require.Len(t, cmds, 1)
|
||||||
|
assert.Equal(t, "RunScript", cmds[0].Method, "shell module must still use RunScript()")
|
||||||
|
assert.Contains(t, cmds[0].Cmd, "printf %s")
|
||||||
|
assert.Contains(t, cmds[0].Cmd, "| echo test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesCmd_ModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("echo test", "test\n", "", 0)
|
mock.expectCommand("echo test", "test\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -603,10 +816,10 @@ func TestModuleDifferentiation_Good_RawUsesRun(t *testing.T) {
|
||||||
assert.Equal(t, "Run", cmds[0].Method, "raw module must use Run()")
|
assert.Equal(t, "Run", cmds[0].Method, "raw module must use Run()")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
|
func TestModulesCmd_ModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
scriptPath := filepath.Join(tmpDir, "test.sh")
|
scriptPath := joinPath(tmpDir, "test.sh")
|
||||||
require.NoError(t, os.WriteFile(scriptPath, []byte("echo test"), 0755))
|
require.NoError(t, writeTestFile(scriptPath, []byte("echo test"), 0755))
|
||||||
|
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("echo test", "test\n", "", 0)
|
mock.expectCommand("echo test", "test\n", "", 0)
|
||||||
|
|
@ -620,7 +833,7 @@ func TestModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
|
||||||
|
|
||||||
// --- executeModuleWithMock dispatch tests ---
|
// --- executeModuleWithMock dispatch tests ---
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
|
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("uptime", "up 5 days\n", "", 0)
|
mock.expectCommand("uptime", "up 5 days\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -636,7 +849,7 @@ func TestExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
|
||||||
assert.Equal(t, "up 5 days\n", result.Stdout)
|
assert.Equal(t, "up 5 days\n", result.Stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
|
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("ps aux", "root.*bash\n", "", 0)
|
mock.expectCommand("ps aux", "root.*bash\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -651,7 +864,7 @@ func TestExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
|
||||||
assert.True(t, result.Changed)
|
assert.True(t, result.Changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
|
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("cat /etc/hostname", "web01\n", "", 0)
|
mock.expectCommand("cat /etc/hostname", "web01\n", "", 0)
|
||||||
|
|
||||||
|
|
@ -667,10 +880,10 @@ func TestExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
|
||||||
assert.Equal(t, "web01\n", result.Stdout)
|
assert.Equal(t, "web01\n", result.Stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
|
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
scriptPath := filepath.Join(tmpDir, "deploy.sh")
|
scriptPath := joinPath(tmpDir, "deploy.sh")
|
||||||
require.NoError(t, os.WriteFile(scriptPath, []byte("echo deploying"), 0755))
|
require.NoError(t, writeTestFile(scriptPath, []byte("echo deploying"), 0755))
|
||||||
|
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand("deploying", "deploying\n", "", 0)
|
mock.expectCommand("deploying", "deploying\n", "", 0)
|
||||||
|
|
@ -686,7 +899,7 @@ func TestExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
|
||||||
assert.True(t, result.Changed)
|
assert.True(t, result.Changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
|
func TestModulesCmd_ExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
|
||||||
task := &Task{
|
task := &Task{
|
||||||
|
|
@ -698,11 +911,12 @@ func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "unsupported module")
|
assert.Contains(t, err.Error(), "unsupported module")
|
||||||
|
assert.Contains(t, err.Error(), "ansible.builtin.hostname")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Template integration tests ---
|
// --- Template integration tests ---
|
||||||
|
|
||||||
func TestModuleCommand_Good_TemplatedArgs(t *testing.T) {
|
func TestModulesCmd_ModuleCommand_Good_TemplatedArgs(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
e.SetVar("service_name", "nginx")
|
e.SetVar("service_name", "nginx")
|
||||||
mock.expectCommand("systemctl status nginx", "active\n", "", 0)
|
mock.expectCommand("systemctl status nginx", "active\n", "", 0)
|
||||||
|
|
|
||||||
1020
modules_file_test.go
1020
modules_file_test.go
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,7 @@ import (
|
||||||
|
|
||||||
// --- service module ---
|
// --- service module ---
|
||||||
|
|
||||||
func TestModuleService_Good_Start(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_Start(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl start nginx`, "Started", "", 0)
|
mock.expectCommand(`systemctl start nginx`, "Started", "", 0)
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ func TestModuleService_Good_Start(t *testing.T) {
|
||||||
assert.Equal(t, 1, mock.commandCount())
|
assert.Equal(t, 1, mock.commandCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_Stop(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_Stop(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl stop nginx`, "", "", 0)
|
mock.expectCommand(`systemctl stop nginx`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ func TestModuleService_Good_Stop(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl stop nginx`))
|
assert.True(t, mock.hasExecuted(`systemctl stop nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_Restart(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_Restart(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl restart docker`, "", "", 0)
|
mock.expectCommand(`systemctl restart docker`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ func TestModuleService_Good_Restart(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl restart docker`))
|
assert.True(t, mock.hasExecuted(`systemctl restart docker`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_Reload(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_Reload(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl reload nginx`, "", "", 0)
|
mock.expectCommand(`systemctl reload nginx`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ func TestModuleService_Good_Reload(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl reload nginx`))
|
assert.True(t, mock.hasExecuted(`systemctl reload nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_Enable(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_Enable(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
|
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@ func TestModuleService_Good_Enable(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
|
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_Disable(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_Disable(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl disable nginx`, "", "", 0)
|
mock.expectCommand(`systemctl disable nginx`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ func TestModuleService_Good_Disable(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl disable nginx`))
|
assert.True(t, mock.hasExecuted(`systemctl disable nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_StartAndEnable(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_StartAndEnable(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl start nginx`, "", "", 0)
|
mock.expectCommand(`systemctl start nginx`, "", "", 0)
|
||||||
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
|
mock.expectCommand(`systemctl enable nginx`, "", "", 0)
|
||||||
|
|
@ -123,7 +123,7 @@ func TestModuleService_Good_StartAndEnable(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
|
assert.True(t, mock.hasExecuted(`systemctl enable nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_RestartAndDisable(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_RestartAndDisable(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl restart sshd`, "", "", 0)
|
mock.expectCommand(`systemctl restart sshd`, "", "", 0)
|
||||||
mock.expectCommand(`systemctl disable sshd`, "", "", 0)
|
mock.expectCommand(`systemctl disable sshd`, "", "", 0)
|
||||||
|
|
@ -142,7 +142,7 @@ func TestModuleService_Good_RestartAndDisable(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl disable sshd`))
|
assert.True(t, mock.hasExecuted(`systemctl disable sshd`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Bad_MissingName(t *testing.T) {
|
func TestModulesSvc_ModuleService_Bad_MissingName(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ func TestModuleService_Bad_MissingName(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "name required")
|
assert.Contains(t, err.Error(), "name required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_NoStateNoEnabled(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_NoStateNoEnabled(t *testing.T) {
|
||||||
// When neither state nor enabled is provided, no commands run
|
// When neither state nor enabled is provided, no commands run
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
|
||||||
|
|
@ -168,7 +168,7 @@ func TestModuleService_Good_NoStateNoEnabled(t *testing.T) {
|
||||||
assert.Equal(t, 0, mock.commandCount())
|
assert.Equal(t, 0, mock.commandCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_CommandFailure(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_CommandFailure(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl start.*`, "", "Failed to start nginx.service", 1)
|
mock.expectCommand(`systemctl start.*`, "", "Failed to start nginx.service", 1)
|
||||||
|
|
||||||
|
|
@ -183,7 +183,7 @@ func TestModuleService_Good_CommandFailure(t *testing.T) {
|
||||||
assert.Equal(t, 1, result.RC)
|
assert.Equal(t, 1, result.RC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
|
func TestModulesSvc_ModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
|
||||||
// When state command fails, enable should not run
|
// When state command fails, enable should not run
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl start`, "", "unit not found", 5)
|
mock.expectCommand(`systemctl start`, "", "unit not found", 5)
|
||||||
|
|
@ -203,7 +203,7 @@ func TestModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
|
||||||
|
|
||||||
// --- systemd module ---
|
// --- systemd module ---
|
||||||
|
|
||||||
func TestModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
|
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||||
mock.expectCommand(`systemctl start nginx`, "", "", 0)
|
mock.expectCommand(`systemctl start nginx`, "", "", 0)
|
||||||
|
|
@ -225,7 +225,7 @@ func TestModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
|
||||||
assert.Contains(t, cmds[1].Cmd, "systemctl start nginx")
|
assert.Contains(t, cmds[1].Cmd, "systemctl start nginx")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
|
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -243,7 +243,7 @@ func TestModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
|
assert.True(t, mock.hasExecuted(`systemctl daemon-reload`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleSystemd_Good_DelegationToService(t *testing.T) {
|
func TestModulesSvc_ModuleSystemd_Good_DelegationToService(t *testing.T) {
|
||||||
// Without daemon_reload, systemd delegates entirely to service
|
// Without daemon_reload, systemd delegates entirely to service
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl restart docker`, "", "", 0)
|
mock.expectCommand(`systemctl restart docker`, "", "", 0)
|
||||||
|
|
@ -261,7 +261,7 @@ func TestModuleSystemd_Good_DelegationToService(t *testing.T) {
|
||||||
assert.False(t, mock.hasExecuted(`daemon-reload`))
|
assert.False(t, mock.hasExecuted(`daemon-reload`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
|
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||||
mock.expectCommand(`systemctl enable myapp`, "", "", 0)
|
mock.expectCommand(`systemctl enable myapp`, "", "", 0)
|
||||||
|
|
@ -281,7 +281,7 @@ func TestModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
|
||||||
|
|
||||||
// --- apt module ---
|
// --- apt module ---
|
||||||
|
|
||||||
func TestModuleApt_Good_InstallPresent(t *testing.T) {
|
func TestModulesSvc_ModuleApt_Good_InstallPresent(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get install -y -qq nginx`, "installed", "", 0)
|
mock.expectCommand(`apt-get install -y -qq nginx`, "installed", "", 0)
|
||||||
|
|
||||||
|
|
@ -296,7 +296,7 @@ func TestModuleApt_Good_InstallPresent(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx`))
|
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleApt_Good_InstallInstalled(t *testing.T) {
|
func TestModulesSvc_ModuleApt_Good_InstallInstalled(t *testing.T) {
|
||||||
// state=installed is an alias for present
|
// state=installed is an alias for present
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get install -y -qq curl`, "", "", 0)
|
mock.expectCommand(`apt-get install -y -qq curl`, "", "", 0)
|
||||||
|
|
@ -312,7 +312,7 @@ func TestModuleApt_Good_InstallInstalled(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq curl`))
|
assert.True(t, mock.hasExecuted(`apt-get install -y -qq curl`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleApt_Good_RemoveAbsent(t *testing.T) {
|
func TestModulesSvc_ModuleApt_Good_RemoveAbsent(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
|
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -327,7 +327,7 @@ func TestModuleApt_Good_RemoveAbsent(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq nginx`))
|
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleApt_Good_RemoveRemoved(t *testing.T) {
|
func TestModulesSvc_ModuleApt_Good_RemoveRemoved(t *testing.T) {
|
||||||
// state=removed is an alias for absent
|
// state=removed is an alias for absent
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
|
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
|
||||||
|
|
@ -342,7 +342,7 @@ func TestModuleApt_Good_RemoveRemoved(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nginx`))
|
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleApt_Good_UpgradeLatest(t *testing.T) {
|
func TestModulesSvc_ModuleApt_Good_UpgradeLatest(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get install -y -qq --only-upgrade nginx`, "", "", 0)
|
mock.expectCommand(`apt-get install -y -qq --only-upgrade nginx`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -357,7 +357,7 @@ func TestModuleApt_Good_UpgradeLatest(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade nginx`))
|
assert.True(t, mock.hasExecuted(`DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
|
func TestModulesSvc_ModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||||
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
|
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
|
||||||
|
|
@ -379,7 +379,7 @@ func TestModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
|
||||||
assert.Contains(t, cmds[1].Cmd, "apt-get install")
|
assert.Contains(t, cmds[1].Cmd, "apt-get install")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleApt_Good_UpdateCacheOnly(t *testing.T) {
|
func TestModulesSvc_ModuleApt_Good_UpdateCacheOnly(t *testing.T) {
|
||||||
// update_cache with no name means update only, no install
|
// update_cache with no name means update only, no install
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||||
|
|
@ -395,7 +395,7 @@ func TestModuleApt_Good_UpdateCacheOnly(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-get update`))
|
assert.True(t, mock.hasExecuted(`apt-get update`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleApt_Good_CommandFailure(t *testing.T) {
|
func TestModulesSvc_ModuleApt_Good_CommandFailure(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get install`, "", "E: Unable to locate package badpkg", 100)
|
mock.expectCommand(`apt-get install`, "", "E: Unable to locate package badpkg", 100)
|
||||||
|
|
||||||
|
|
@ -410,7 +410,7 @@ func TestModuleApt_Good_CommandFailure(t *testing.T) {
|
||||||
assert.Equal(t, 100, result.RC)
|
assert.Equal(t, 100, result.RC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
|
func TestModulesSvc_ModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
|
||||||
// If no state is given, default is "present" (install)
|
// If no state is given, default is "present" (install)
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get install -y -qq vim`, "", "", 0)
|
mock.expectCommand(`apt-get install -y -qq vim`, "", "", 0)
|
||||||
|
|
@ -424,9 +424,22 @@ func TestModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
|
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ModuleApt_Good_InstallMultiplePackages(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`apt-get install -y -qq nginx curl`, "", "", 0)
|
||||||
|
|
||||||
|
result, err := moduleAptWithClient(e, mock, map[string]any{
|
||||||
|
"name": []any{"nginx", "curl"},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.True(t, mock.hasExecuted(`apt-get install -y -qq nginx curl`))
|
||||||
|
}
|
||||||
|
|
||||||
// --- apt_key module ---
|
// --- apt_key module ---
|
||||||
|
|
||||||
func TestModuleAptKey_Good_AddWithKeyring(t *testing.T) {
|
func TestModulesSvc_ModuleAptKey_Good_AddWithKeyring(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`curl -fsSL.*gpg --dearmor`, "", "", 0)
|
mock.expectCommand(`curl -fsSL.*gpg --dearmor`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -443,7 +456,7 @@ func TestModuleAptKey_Good_AddWithKeyring(t *testing.T) {
|
||||||
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/example.gpg"))
|
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/example.gpg"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
|
func TestModulesSvc_ModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`curl -fsSL.*apt-key add -`, "", "", 0)
|
mock.expectCommand(`curl -fsSL.*apt-key add -`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -457,7 +470,7 @@ func TestModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-key add -`))
|
assert.True(t, mock.hasExecuted(`apt-key add -`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptKey_Good_RemoveKey(t *testing.T) {
|
func TestModulesSvc_ModuleAptKey_Good_RemoveKey(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
|
||||||
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
|
result, err := moduleAptKeyWithClient(e, mock, map[string]any{
|
||||||
|
|
@ -472,7 +485,7 @@ func TestModuleAptKey_Good_RemoveKey(t *testing.T) {
|
||||||
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/old.gpg"))
|
assert.True(t, mock.containsSubstring("/etc/apt/keyrings/old.gpg"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
|
func TestModulesSvc_ModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
|
||||||
// Absent with no keyring — still succeeds, just no rm command
|
// Absent with no keyring — still succeeds, just no rm command
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
|
||||||
|
|
@ -485,7 +498,7 @@ func TestModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
|
||||||
assert.Equal(t, 0, mock.commandCount())
|
assert.Equal(t, 0, mock.commandCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptKey_Bad_MissingURL(t *testing.T) {
|
func TestModulesSvc_ModuleAptKey_Bad_MissingURL(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
|
|
@ -497,7 +510,7 @@ func TestModuleAptKey_Bad_MissingURL(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "url required")
|
assert.Contains(t, err.Error(), "url required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptKey_Good_CommandFailure(t *testing.T) {
|
func TestModulesSvc_ModuleAptKey_Good_CommandFailure(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`curl`, "", "curl: (22) 404 Not Found", 22)
|
mock.expectCommand(`curl`, "", "curl: (22) 404 Not Found", 22)
|
||||||
|
|
||||||
|
|
@ -513,7 +526,7 @@ func TestModuleAptKey_Good_CommandFailure(t *testing.T) {
|
||||||
|
|
||||||
// --- apt_repository module ---
|
// --- apt_repository module ---
|
||||||
|
|
||||||
func TestModuleAptRepository_Good_AddRepository(t *testing.T) {
|
func TestModulesSvc_ModuleAptRepository_Good_AddRepository(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`echo.*sources\.list\.d`, "", "", 0)
|
mock.expectCommand(`echo.*sources\.list\.d`, "", "", 0)
|
||||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||||
|
|
@ -529,7 +542,7 @@ func TestModuleAptRepository_Good_AddRepository(t *testing.T) {
|
||||||
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/example.list"))
|
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/example.list"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptRepository_Good_RemoveRepository(t *testing.T) {
|
func TestModulesSvc_ModuleAptRepository_Good_RemoveRepository(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
|
||||||
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
result, err := moduleAptRepositoryWithClient(e, mock, map[string]any{
|
||||||
|
|
@ -545,7 +558,7 @@ func TestModuleAptRepository_Good_RemoveRepository(t *testing.T) {
|
||||||
assert.True(t, mock.containsSubstring("example.list"))
|
assert.True(t, mock.containsSubstring("example.list"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
|
func TestModulesSvc_ModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`echo`, "", "", 0)
|
mock.expectCommand(`echo`, "", "", 0)
|
||||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||||
|
|
@ -564,7 +577,7 @@ func TestModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-get update`))
|
assert.True(t, mock.hasExecuted(`apt-get update`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
|
func TestModulesSvc_ModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`echo`, "", "", 0)
|
mock.expectCommand(`echo`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -582,7 +595,7 @@ func TestModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
|
||||||
assert.False(t, mock.hasExecuted(`apt-get update`))
|
assert.False(t, mock.hasExecuted(`apt-get update`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptRepository_Good_CustomFilename(t *testing.T) {
|
func TestModulesSvc_ModuleAptRepository_Good_CustomFilename(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`echo`, "", "", 0)
|
mock.expectCommand(`echo`, "", "", 0)
|
||||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||||
|
|
@ -597,7 +610,7 @@ func TestModuleAptRepository_Good_CustomFilename(t *testing.T) {
|
||||||
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/custom-ppa.list"))
|
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/custom-ppa.list"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T) {
|
func TestModulesSvc_ModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T) {
|
||||||
// When no filename is given, it auto-generates from the repo string
|
// When no filename is given, it auto-generates from the repo string
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`echo`, "", "", 0)
|
mock.expectCommand(`echo`, "", "", 0)
|
||||||
|
|
@ -613,7 +626,7 @@ func TestModuleAptRepository_Good_AutoGeneratedFilename(t *testing.T) {
|
||||||
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/"))
|
assert.True(t, mock.containsSubstring("/etc/apt/sources.list.d/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptRepository_Bad_MissingRepo(t *testing.T) {
|
func TestModulesSvc_ModuleAptRepository_Bad_MissingRepo(t *testing.T) {
|
||||||
e, _ := newTestExecutorWithMock("host1")
|
e, _ := newTestExecutorWithMock("host1")
|
||||||
mock := NewMockSSHClient()
|
mock := NewMockSSHClient()
|
||||||
|
|
||||||
|
|
@ -625,7 +638,7 @@ func TestModuleAptRepository_Bad_MissingRepo(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "repo required")
|
assert.Contains(t, err.Error(), "repo required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleAptRepository_Good_WriteFailure(t *testing.T) {
|
func TestModulesSvc_ModuleAptRepository_Good_WriteFailure(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`echo`, "", "permission denied", 1)
|
mock.expectCommand(`echo`, "", "permission denied", 1)
|
||||||
|
|
||||||
|
|
@ -641,7 +654,7 @@ func TestModuleAptRepository_Good_WriteFailure(t *testing.T) {
|
||||||
|
|
||||||
// --- package module ---
|
// --- package module ---
|
||||||
|
|
||||||
func TestModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
|
func TestModulesSvc_ModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
// First command: which apt-get returns the path
|
// First command: which apt-get returns the path
|
||||||
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
||||||
|
|
@ -660,7 +673,7 @@ func TestModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq htop`))
|
assert.True(t, mock.hasExecuted(`apt-get install -y -qq htop`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePackage_Good_FallbackToApt(t *testing.T) {
|
func TestModulesSvc_ModulePackage_Good_FallbackToApt(t *testing.T) {
|
||||||
// When which returns nothing (no package manager found), still falls back to apt
|
// When which returns nothing (no package manager found), still falls back to apt
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`which apt-get`, "", "", 1)
|
mock.expectCommand(`which apt-get`, "", "", 1)
|
||||||
|
|
@ -677,7 +690,7 @@ func TestModulePackage_Good_FallbackToApt(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
|
assert.True(t, mock.hasExecuted(`apt-get install -y -qq vim`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePackage_Good_RemovePackage(t *testing.T) {
|
func TestModulesSvc_ModulePackage_Good_RemovePackage(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
||||||
mock.expectCommand(`apt-get remove -y -qq nano`, "", "", 0)
|
mock.expectCommand(`apt-get remove -y -qq nano`, "", "", 0)
|
||||||
|
|
@ -692,9 +705,113 @@ func TestModulePackage_Good_RemovePackage(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nano`))
|
assert.True(t, mock.hasExecuted(`apt-get remove -y -qq nano`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ModulePackage_Good_DetectYumAndDelegate(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`which apt-get yum dnf`, "/usr/bin/yum", "", 0)
|
||||||
|
mock.expectCommand(`yum install -y -q htop`, "", "", 0)
|
||||||
|
|
||||||
|
result, err := modulePackageWithClient(e, mock, map[string]any{
|
||||||
|
"name": "htop",
|
||||||
|
"state": "present",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.False(t, result.Failed)
|
||||||
|
assert.True(t, mock.hasExecuted(`yum install -y -q htop`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ModulePackage_Good_DetectDnfAndDelegate(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`which apt-get yum dnf`, "/usr/bin/dnf", "", 0)
|
||||||
|
mock.expectCommand(`dnf upgrade -y -q vim`, "", "", 0)
|
||||||
|
|
||||||
|
result, err := modulePackageWithClient(e, mock, map[string]any{
|
||||||
|
"name": "vim",
|
||||||
|
"state": "latest",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.False(t, result.Failed)
|
||||||
|
assert.True(t, mock.hasExecuted(`dnf upgrade -y -q vim`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchYum(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`yum install -y -q htop`, "", "", 0)
|
||||||
|
|
||||||
|
task := &Task{
|
||||||
|
Module: "yum",
|
||||||
|
Args: map[string]any{
|
||||||
|
"name": "htop",
|
||||||
|
"state": "present",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.True(t, mock.hasExecuted(`yum install -y -q htop`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchDnf(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`dnf remove -y -q nano`, "", "", 0)
|
||||||
|
|
||||||
|
task := &Task{
|
||||||
|
Module: "dnf",
|
||||||
|
Args: map[string]any{
|
||||||
|
"name": "nano",
|
||||||
|
"state": "absent",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.True(t, mock.hasExecuted(`dnf remove -y -q nano`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ModuleRpm_Good_InstallPackage(t *testing.T) {
|
||||||
|
mock := NewMockSSHClient()
|
||||||
|
mock.expectCommand(`rpm -ivh /tmp/nginx.rpm`, "", "", 0)
|
||||||
|
|
||||||
|
result, err := moduleRPMWithClient(mock, map[string]any{
|
||||||
|
"name": "/tmp/nginx.rpm",
|
||||||
|
"state": "present",
|
||||||
|
}, "rpm")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.False(t, result.Failed)
|
||||||
|
assert.True(t, mock.hasExecuted(`rpm -ivh /tmp/nginx.rpm`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchRpm(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`rpm -e nginx`, "", "", 0)
|
||||||
|
|
||||||
|
task := &Task{
|
||||||
|
Module: "rpm",
|
||||||
|
Args: map[string]any{
|
||||||
|
"name": "nginx",
|
||||||
|
"state": "absent",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := executeModuleWithMock(e, mock, "host1", task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.True(t, mock.hasExecuted(`rpm -e nginx`))
|
||||||
|
}
|
||||||
|
|
||||||
// --- pip module ---
|
// --- pip module ---
|
||||||
|
|
||||||
func TestModulePip_Good_InstallPresent(t *testing.T) {
|
func TestModulesSvc_ModulePip_Good_InstallPresent(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`pip3 install flask`, "Successfully installed", "", 0)
|
mock.expectCommand(`pip3 install flask`, "Successfully installed", "", 0)
|
||||||
|
|
||||||
|
|
@ -709,7 +826,7 @@ func TestModulePip_Good_InstallPresent(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`pip3 install flask`))
|
assert.True(t, mock.hasExecuted(`pip3 install flask`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePip_Good_UninstallAbsent(t *testing.T) {
|
func TestModulesSvc_ModulePip_Good_UninstallAbsent(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`pip3 uninstall -y flask`, "Successfully uninstalled", "", 0)
|
mock.expectCommand(`pip3 uninstall -y flask`, "Successfully uninstalled", "", 0)
|
||||||
|
|
||||||
|
|
@ -724,7 +841,7 @@ func TestModulePip_Good_UninstallAbsent(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`pip3 uninstall -y flask`))
|
assert.True(t, mock.hasExecuted(`pip3 uninstall -y flask`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePip_Good_UpgradeLatest(t *testing.T) {
|
func TestModulesSvc_ModulePip_Good_UpgradeLatest(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`pip3 install --upgrade flask`, "Successfully installed", "", 0)
|
mock.expectCommand(`pip3 install --upgrade flask`, "Successfully installed", "", 0)
|
||||||
|
|
||||||
|
|
@ -739,7 +856,7 @@ func TestModulePip_Good_UpgradeLatest(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`pip3 install --upgrade flask`))
|
assert.True(t, mock.hasExecuted(`pip3 install --upgrade flask`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePip_Good_CustomExecutable(t *testing.T) {
|
func TestModulesSvc_ModulePip_Good_CustomExecutable(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`/opt/venv/bin/pip install requests`, "", "", 0)
|
mock.expectCommand(`/opt/venv/bin/pip install requests`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -755,7 +872,37 @@ func TestModulePip_Good_CustomExecutable(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`/opt/venv/bin/pip install requests`))
|
assert.True(t, mock.hasExecuted(`/opt/venv/bin/pip install requests`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePip_Good_DefaultStateIsPresent(t *testing.T) {
|
func TestModulesSvc_ModulePip_Good_RequirementsFile(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`pip3 install -r "/tmp/requirements.txt"`, "", "", 0)
|
||||||
|
|
||||||
|
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||||
|
"requirements": "/tmp/requirements.txt",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.False(t, result.Failed)
|
||||||
|
assert.True(t, mock.hasExecuted(`pip3 install -r "/tmp/requirements.txt"`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ModulePip_Good_VirtualenvUsesVenvPip(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`/opt/venv/bin/pip install requests`, "", "", 0)
|
||||||
|
|
||||||
|
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||||
|
"name": "requests",
|
||||||
|
"state": "present",
|
||||||
|
"virtualenv": "/opt/venv",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.False(t, result.Failed)
|
||||||
|
assert.True(t, mock.hasExecuted(`/opt/venv/bin/pip install requests`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ModulePip_Good_DefaultStateIsPresent(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`pip3 install django`, "", "", 0)
|
mock.expectCommand(`pip3 install django`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -768,7 +915,20 @@ func TestModulePip_Good_DefaultStateIsPresent(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`pip3 install django`))
|
assert.True(t, mock.hasExecuted(`pip3 install django`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePip_Good_CommandFailure(t *testing.T) {
|
func TestModulesSvc_ModulePip_Good_InstallMultiplePackages(t *testing.T) {
|
||||||
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
|
mock.expectCommand(`pip3 install requests flask`, "", "", 0)
|
||||||
|
|
||||||
|
result, err := modulePipWithClient(e, mock, map[string]any{
|
||||||
|
"name": []any{"requests", "flask"},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Changed)
|
||||||
|
assert.True(t, mock.hasExecuted(`pip3 install requests flask`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModulesSvc_ModulePip_Good_CommandFailure(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`pip3 install`, "", "ERROR: No matching distribution found", 1)
|
mock.expectCommand(`pip3 install`, "", "ERROR: No matching distribution found", 1)
|
||||||
|
|
||||||
|
|
@ -782,7 +942,7 @@ func TestModulePip_Good_CommandFailure(t *testing.T) {
|
||||||
assert.Contains(t, result.Msg, "No matching distribution found")
|
assert.Contains(t, result.Msg, "No matching distribution found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePip_Good_InstalledAlias(t *testing.T) {
|
func TestModulesSvc_ModulePip_Good_InstalledAlias(t *testing.T) {
|
||||||
// state=installed is an alias for present
|
// state=installed is an alias for present
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`pip3 install boto3`, "", "", 0)
|
mock.expectCommand(`pip3 install boto3`, "", "", 0)
|
||||||
|
|
@ -797,7 +957,7 @@ func TestModulePip_Good_InstalledAlias(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`pip3 install boto3`))
|
assert.True(t, mock.hasExecuted(`pip3 install boto3`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePip_Good_RemovedAlias(t *testing.T) {
|
func TestModulesSvc_ModulePip_Good_RemovedAlias(t *testing.T) {
|
||||||
// state=removed is an alias for absent
|
// state=removed is an alias for absent
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`pip3 uninstall -y boto3`, "", "", 0)
|
mock.expectCommand(`pip3 uninstall -y boto3`, "", "", 0)
|
||||||
|
|
@ -814,7 +974,7 @@ func TestModulePip_Good_RemovedAlias(t *testing.T) {
|
||||||
|
|
||||||
// --- Cross-module dispatch tests ---
|
// --- Cross-module dispatch tests ---
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl restart nginx`, "", "", 0)
|
mock.expectCommand(`systemctl restart nginx`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -833,7 +993,7 @@ func TestExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl restart nginx`))
|
assert.True(t, mock.hasExecuted(`systemctl restart nginx`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
|
||||||
mock.expectCommand(`systemctl start myapp`, "", "", 0)
|
mock.expectCommand(`systemctl start myapp`, "", "", 0)
|
||||||
|
|
@ -855,7 +1015,7 @@ func TestExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`systemctl start myapp`))
|
assert.True(t, mock.hasExecuted(`systemctl start myapp`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
|
mock.expectCommand(`apt-get install -y -qq nginx`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -874,7 +1034,7 @@ func TestExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
|
||||||
assert.True(t, mock.hasExecuted(`apt-get install`))
|
assert.True(t, mock.hasExecuted(`apt-get install`))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`curl.*gpg`, "", "", 0)
|
mock.expectCommand(`curl.*gpg`, "", "", 0)
|
||||||
|
|
||||||
|
|
@ -892,7 +1052,7 @@ func TestExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
|
||||||
assert.True(t, result.Changed)
|
assert.True(t, result.Changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`echo`, "", "", 0)
|
mock.expectCommand(`echo`, "", "", 0)
|
||||||
mock.expectCommand(`apt-get update`, "", "", 0)
|
mock.expectCommand(`apt-get update`, "", "", 0)
|
||||||
|
|
@ -911,7 +1071,7 @@ func TestExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
|
||||||
assert.True(t, result.Changed)
|
assert.True(t, result.Changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
|
||||||
mock.expectCommand(`apt-get install -y -qq git`, "", "", 0)
|
mock.expectCommand(`apt-get install -y -qq git`, "", "", 0)
|
||||||
|
|
@ -930,7 +1090,7 @@ func TestExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
|
||||||
assert.True(t, result.Changed)
|
assert.True(t, result.Changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
|
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
|
||||||
e, mock := newTestExecutorWithMock("host1")
|
e, mock := newTestExecutorWithMock("host1")
|
||||||
mock.expectCommand(`pip3 install ansible`, "", "", 0)
|
mock.expectCommand(`pip3 install ansible`, "", "", 0)
|
||||||
|
|
||||||
|
|
|
||||||
491
parser_test.go
491
parser_test.go
|
|
@ -2,7 +2,6 @@ package ansible
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -11,9 +10,9 @@ import (
|
||||||
|
|
||||||
// --- ParsePlaybook ---
|
// --- ParsePlaybook ---
|
||||||
|
|
||||||
func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_SimplePlay(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: Configure webserver
|
- name: Configure webserver
|
||||||
|
|
@ -25,7 +24,7 @@ func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
|
||||||
name: nginx
|
name: nginx
|
||||||
state: present
|
state: present
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -42,9 +41,9 @@ func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
|
||||||
assert.Equal(t, "present", plays[0].Tasks[0].Args["state"])
|
assert.Equal(t, "present", plays[0].Tasks[0].Args["state"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_MultiplePlays(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: Play one
|
- name: Play one
|
||||||
|
|
@ -62,7 +61,7 @@ func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
|
||||||
debug:
|
debug:
|
||||||
msg: "Goodbye"
|
msg: "Goodbye"
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -76,9 +75,156 @@ func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
|
||||||
assert.Equal(t, "local", plays[1].Connection)
|
assert.Equal(t, "local", plays[1].Connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_WithVars(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_ImportPlaybook(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
mainPath := joinPath(dir, "site.yml")
|
||||||
|
importDir := joinPath(dir, "plays")
|
||||||
|
importPath := joinPath(importDir, "web.yml")
|
||||||
|
|
||||||
|
yamlMain := `---
|
||||||
|
- name: Before import
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Say before
|
||||||
|
debug:
|
||||||
|
msg: "before"
|
||||||
|
|
||||||
|
- import_playbook: plays/web.yml
|
||||||
|
|
||||||
|
- name: After import
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Say after
|
||||||
|
debug:
|
||||||
|
msg: "after"
|
||||||
|
`
|
||||||
|
yamlImported := `---
|
||||||
|
- name: Imported play
|
||||||
|
hosts: webservers
|
||||||
|
tasks:
|
||||||
|
- name: Say imported
|
||||||
|
debug:
|
||||||
|
msg: "imported"
|
||||||
|
`
|
||||||
|
require.NoError(t, os.MkdirAll(importDir, 0755))
|
||||||
|
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
|
||||||
|
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
|
||||||
|
|
||||||
|
p := NewParser(dir)
|
||||||
|
plays, err := p.ParsePlaybook("site.yml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plays, 3)
|
||||||
|
assert.Equal(t, "Before import", plays[0].Name)
|
||||||
|
assert.Equal(t, "Imported play", plays[1].Name)
|
||||||
|
assert.Equal(t, "After import", plays[2].Name)
|
||||||
|
assert.Equal(t, "webservers", plays[1].Hosts)
|
||||||
|
assert.Len(t, plays[1].Tasks, 1)
|
||||||
|
assert.Equal(t, "Say imported", plays[1].Tasks[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ParsePlaybook_Good_TemplatedImportPlaybook(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mainPath := joinPath(dir, "site.yml")
|
||||||
|
importDir := joinPath(dir, "plays")
|
||||||
|
importPath := joinPath(importDir, "web.yml")
|
||||||
|
|
||||||
|
yamlMain := `---
|
||||||
|
- import_playbook: "{{ playbook_dir }}/plays/web.yml"
|
||||||
|
`
|
||||||
|
yamlImported := `---
|
||||||
|
- name: Imported play
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Say imported
|
||||||
|
debug:
|
||||||
|
msg: "imported"
|
||||||
|
`
|
||||||
|
require.NoError(t, os.MkdirAll(importDir, 0755))
|
||||||
|
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
|
||||||
|
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
|
||||||
|
|
||||||
|
p := NewParser(dir)
|
||||||
|
plays, err := p.ParsePlaybook("site.yml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plays, 1)
|
||||||
|
assert.Equal(t, "Imported play", plays[0].Name)
|
||||||
|
assert.Equal(t, "all", plays[0].Hosts)
|
||||||
|
require.NotNil(t, plays[0].Vars)
|
||||||
|
assert.Equal(t, dir, plays[0].Vars["playbook_dir"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ParsePlaybook_Good_FQCNImportPlaybook(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mainPath := joinPath(dir, "site.yml")
|
||||||
|
importDir := joinPath(dir, "plays")
|
||||||
|
importPath := joinPath(importDir, "web.yml")
|
||||||
|
|
||||||
|
yamlMain := `---
|
||||||
|
- ansible.builtin.import_playbook: plays/web.yml
|
||||||
|
`
|
||||||
|
yamlImported := `---
|
||||||
|
- name: Imported play
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Say imported
|
||||||
|
debug:
|
||||||
|
msg: "imported"
|
||||||
|
`
|
||||||
|
require.NoError(t, os.MkdirAll(importDir, 0755))
|
||||||
|
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
|
||||||
|
require.NoError(t, writeTestFile(importPath, []byte(yamlImported), 0644))
|
||||||
|
|
||||||
|
p := NewParser(dir)
|
||||||
|
plays, err := p.ParsePlaybook(mainPath)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plays, 1)
|
||||||
|
assert.Equal(t, "Imported play", plays[0].Name)
|
||||||
|
assert.Equal(t, "all", plays[0].Hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ParsePlaybook_Good_NestedImportPlaybookDirScope(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mainPath := joinPath(dir, "site.yml")
|
||||||
|
outerDir := joinPath(dir, "plays")
|
||||||
|
outerPath := joinPath(outerDir, "outer.yml")
|
||||||
|
innerDir := joinPath(outerDir, "nested")
|
||||||
|
innerPath := joinPath(innerDir, "inner.yml")
|
||||||
|
|
||||||
|
yamlMain := `---
|
||||||
|
- import_playbook: plays/outer.yml
|
||||||
|
`
|
||||||
|
yamlOuter := `---
|
||||||
|
- import_playbook: "{{ playbook_dir }}/nested/inner.yml"
|
||||||
|
`
|
||||||
|
yamlInner := `---
|
||||||
|
- name: Inner play
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Say inner
|
||||||
|
debug:
|
||||||
|
msg: "inner"
|
||||||
|
`
|
||||||
|
require.NoError(t, os.MkdirAll(innerDir, 0755))
|
||||||
|
require.NoError(t, writeTestFile(mainPath, []byte(yamlMain), 0644))
|
||||||
|
require.NoError(t, writeTestFile(outerPath, []byte(yamlOuter), 0644))
|
||||||
|
require.NoError(t, writeTestFile(innerPath, []byte(yamlInner), 0644))
|
||||||
|
|
||||||
|
p := NewParser(dir)
|
||||||
|
plays, err := p.ParsePlaybook("site.yml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plays, 1)
|
||||||
|
assert.Equal(t, "Inner play", plays[0].Name)
|
||||||
|
require.NotNil(t, plays[0].Vars)
|
||||||
|
assert.Equal(t, dir, plays[0].Vars["playbook_dir"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ParsePlaybook_Good_WithVars(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: With vars
|
- name: With vars
|
||||||
|
|
@ -91,7 +237,7 @@ func TestParsePlaybook_Good_WithVars(t *testing.T) {
|
||||||
debug:
|
debug:
|
||||||
msg: "Port is {{ http_port }}"
|
msg: "Port is {{ http_port }}"
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -102,9 +248,9 @@ func TestParsePlaybook_Good_WithVars(t *testing.T) {
|
||||||
assert.Equal(t, "myapp", plays[0].Vars["app_name"])
|
assert.Equal(t, "myapp", plays[0].Vars["app_name"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_PrePostTasks(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: Full lifecycle
|
- name: Full lifecycle
|
||||||
|
|
@ -122,7 +268,7 @@ func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
|
||||||
debug:
|
debug:
|
||||||
msg: "post"
|
msg: "post"
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -137,9 +283,9 @@ func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
|
||||||
assert.Equal(t, "Post task", plays[0].PostTasks[0].Name)
|
assert.Equal(t, "Post task", plays[0].PostTasks[0].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_Handlers(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_Handlers(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: With handlers
|
- name: With handlers
|
||||||
|
|
@ -155,7 +301,7 @@ func TestParsePlaybook_Good_Handlers(t *testing.T) {
|
||||||
name: nginx
|
name: nginx
|
||||||
state: restarted
|
state: restarted
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -167,9 +313,9 @@ func TestParsePlaybook_Good_Handlers(t *testing.T) {
|
||||||
assert.Equal(t, "service", plays[0].Handlers[0].Module)
|
assert.Equal(t, "service", plays[0].Handlers[0].Module)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_ShellFreeForm(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: Shell tasks
|
- name: Shell tasks
|
||||||
|
|
@ -180,7 +326,7 @@ func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
|
||||||
- name: Run raw command
|
- name: Run raw command
|
||||||
command: ls -la /tmp
|
command: ls -la /tmp
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -193,9 +339,9 @@ func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
|
||||||
assert.Equal(t, "ls -la /tmp", plays[0].Tasks[1].Args["_raw_params"])
|
assert.Equal(t, "ls -la /tmp", plays[0].Tasks[1].Args["_raw_params"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_WithTags(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_WithTags(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: Tagged play
|
- name: Tagged play
|
||||||
|
|
@ -210,7 +356,7 @@ func TestParsePlaybook_Good_WithTags(t *testing.T) {
|
||||||
- debug
|
- debug
|
||||||
- always
|
- always
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -220,9 +366,9 @@ func TestParsePlaybook_Good_WithTags(t *testing.T) {
|
||||||
assert.Equal(t, []string{"debug", "always"}, plays[0].Tasks[0].Tags)
|
assert.Equal(t, []string{"debug", "always"}, plays[0].Tasks[0].Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: With blocks
|
- name: With blocks
|
||||||
|
|
@ -241,7 +387,7 @@ func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
|
||||||
debug:
|
debug:
|
||||||
msg: "always"
|
msg: "always"
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -256,9 +402,9 @@ func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
|
||||||
assert.Equal(t, "Always runs", task.Always[0].Name)
|
assert.Equal(t, "Always runs", task.Always[0].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_WithLoop(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_WithLoop(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: Loop test
|
- name: Loop test
|
||||||
|
|
@ -273,7 +419,7 @@ func TestParsePlaybook_Good_WithLoop(t *testing.T) {
|
||||||
- curl
|
- curl
|
||||||
- git
|
- git
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -286,9 +432,9 @@ func TestParsePlaybook_Good_WithLoop(t *testing.T) {
|
||||||
assert.Len(t, items, 3)
|
assert.Len(t, items, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_RoleRefs(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: With roles
|
- name: With roles
|
||||||
|
|
@ -301,7 +447,7 @@ func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
|
||||||
tags:
|
tags:
|
||||||
- web
|
- web
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -314,9 +460,9 @@ func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
|
||||||
assert.Equal(t, []string{"web"}, plays[0].Roles[1].Tags)
|
assert.Equal(t, []string{"web"}, plays[0].Roles[1].Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: FQCN modules
|
- name: FQCN modules
|
||||||
|
|
@ -329,7 +475,7 @@ func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
|
||||||
- name: Run shell
|
- name: Run shell
|
||||||
ansible.builtin.shell: echo hello
|
ansible.builtin.shell: echo hello
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -341,9 +487,9 @@ func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
|
||||||
assert.Equal(t, "echo hello", plays[0].Tasks[1].Args["_raw_params"])
|
assert.Equal(t, "echo hello", plays[0].Tasks[1].Args["_raw_params"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: Conditional play
|
- name: Conditional play
|
||||||
|
|
@ -358,7 +504,7 @@ func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
|
||||||
msg: "File exists"
|
msg: "File exists"
|
||||||
when: nginx_conf.stat.exists
|
when: nginx_conf.stat.exists
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -368,11 +514,11 @@ func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
|
||||||
assert.NotNil(t, plays[0].Tasks[1].When)
|
assert.NotNil(t, plays[0].Tasks[1].When)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("---\n[]"), 0644))
|
require.NoError(t, writeTestFile(path, []byte("---\n[]"), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -381,11 +527,11 @@ func TestParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
|
||||||
assert.Empty(t, plays)
|
assert.Empty(t, plays)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Bad_InvalidYAML(t *testing.T) {
|
func TestParser_ParsePlaybook_Bad_InvalidYAML(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "bad.yml")
|
path := joinPath(dir, "bad.yml")
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("{{invalid yaml}}"), 0644))
|
require.NoError(t, writeTestFile(path, []byte("{{invalid yaml}}"), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
_, err := p.ParsePlaybook(path)
|
_, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -394,7 +540,7 @@ func TestParsePlaybook_Bad_InvalidYAML(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "parse playbook")
|
assert.Contains(t, err.Error(), "parse playbook")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Bad_FileNotFound(t *testing.T) {
|
func TestParser_ParsePlaybook_Bad_FileNotFound(t *testing.T) {
|
||||||
p := NewParser(t.TempDir())
|
p := NewParser(t.TempDir())
|
||||||
_, err := p.ParsePlaybook("/nonexistent/playbook.yml")
|
_, err := p.ParsePlaybook("/nonexistent/playbook.yml")
|
||||||
|
|
||||||
|
|
@ -402,9 +548,9 @@ func TestParsePlaybook_Bad_FileNotFound(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "read playbook")
|
assert.Contains(t, err.Error(), "read playbook")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
|
func TestParser_ParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "playbook.yml")
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: No facts
|
- name: No facts
|
||||||
|
|
@ -412,7 +558,7 @@ func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
tasks: []
|
tasks: []
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
plays, err := p.ParsePlaybook(path)
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
@ -422,11 +568,33 @@ func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
|
||||||
assert.False(t, *plays[0].GatherFacts)
|
assert.False(t, *plays[0].GatherFacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParser_ParsePlaybook_Good_ForceHandlers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := joinPath(dir, "playbook.yml")
|
||||||
|
|
||||||
|
yaml := `---
|
||||||
|
- name: Handler control
|
||||||
|
hosts: all
|
||||||
|
force_handlers: true
|
||||||
|
any_errors_fatal: true
|
||||||
|
tasks: []
|
||||||
|
`
|
||||||
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
|
p := NewParser(dir)
|
||||||
|
plays, err := p.ParsePlaybook(path)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plays, 1)
|
||||||
|
assert.True(t, plays[0].ForceHandlers)
|
||||||
|
assert.True(t, plays[0].AnyErrorsFatal)
|
||||||
|
}
|
||||||
|
|
||||||
// --- ParseInventory ---
|
// --- ParseInventory ---
|
||||||
|
|
||||||
func TestParseInventory_Good_SimpleInventory(t *testing.T) {
|
func TestParser_ParseInventory_Good_SimpleInventory(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "inventory.yml")
|
path := joinPath(dir, "inventory.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
all:
|
all:
|
||||||
|
|
@ -436,7 +604,7 @@ all:
|
||||||
web2:
|
web2:
|
||||||
ansible_host: 192.168.1.11
|
ansible_host: 192.168.1.11
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
inv, err := p.ParseInventory(path)
|
inv, err := p.ParseInventory(path)
|
||||||
|
|
@ -448,9 +616,32 @@ all:
|
||||||
assert.Equal(t, "192.168.1.11", inv.All.Hosts["web2"].AnsibleHost)
|
assert.Equal(t, "192.168.1.11", inv.All.Hosts["web2"].AnsibleHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseInventory_Good_WithGroups(t *testing.T) {
|
func TestParser_ParseInventory_Good_DirectoryInventory(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "inventory.yml")
|
inventoryDir := joinPath(dir, "inventory")
|
||||||
|
require.NoError(t, os.MkdirAll(inventoryDir, 0755))
|
||||||
|
|
||||||
|
path := joinPath(inventoryDir, "hosts.yml")
|
||||||
|
yaml := `---
|
||||||
|
all:
|
||||||
|
hosts:
|
||||||
|
web1:
|
||||||
|
ansible_host: 192.168.1.10
|
||||||
|
`
|
||||||
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
|
p := NewParser(dir)
|
||||||
|
inv, err := p.ParseInventory(inventoryDir)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, inv.All)
|
||||||
|
require.Contains(t, inv.All.Hosts, "web1")
|
||||||
|
assert.Equal(t, "192.168.1.10", inv.All.Hosts["web1"].AnsibleHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ParseInventory_Good_WithGroups(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := joinPath(dir, "inventory.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
all:
|
all:
|
||||||
|
|
@ -466,7 +657,7 @@ all:
|
||||||
db1:
|
db1:
|
||||||
ansible_host: 10.0.1.1
|
ansible_host: 10.0.1.1
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
inv, err := p.ParseInventory(path)
|
inv, err := p.ParseInventory(path)
|
||||||
|
|
@ -478,9 +669,44 @@ all:
|
||||||
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
|
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseInventory_Good_WithVars(t *testing.T) {
|
func TestParser_ParseInventory_Good_TopLevelGroups(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "inventory.yml")
|
path := joinPath(dir, "inventory.yml")
|
||||||
|
|
||||||
|
yaml := `---
|
||||||
|
webservers:
|
||||||
|
vars:
|
||||||
|
tier: web
|
||||||
|
hosts:
|
||||||
|
web1:
|
||||||
|
ansible_host: 10.0.0.1
|
||||||
|
web2:
|
||||||
|
ansible_host: 10.0.0.2
|
||||||
|
databases:
|
||||||
|
hosts:
|
||||||
|
db1:
|
||||||
|
ansible_host: 10.0.1.1
|
||||||
|
`
|
||||||
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
|
p := NewParser(dir)
|
||||||
|
inv, err := p.ParseInventory(path)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, inv.All)
|
||||||
|
require.NotNil(t, inv.All.Children["webservers"])
|
||||||
|
require.NotNil(t, inv.All.Children["databases"])
|
||||||
|
assert.Len(t, inv.All.Children["webservers"].Hosts, 2)
|
||||||
|
assert.Len(t, inv.All.Children["databases"].Hosts, 1)
|
||||||
|
assert.Equal(t, "web", inv.All.Children["webservers"].Vars["tier"])
|
||||||
|
assert.ElementsMatch(t, []string{"web1", "web2", "db1"}, GetHosts(inv, "all"))
|
||||||
|
assert.Equal(t, []string{"web1", "web2"}, GetHosts(inv, "webservers"))
|
||||||
|
assert.Equal(t, "web", GetHostVars(inv, "web1")["tier"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_ParseInventory_Good_WithVars(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := joinPath(dir, "inventory.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
all:
|
all:
|
||||||
|
|
@ -495,7 +721,7 @@ all:
|
||||||
ansible_host: 10.0.0.1
|
ansible_host: 10.0.0.1
|
||||||
ansible_port: 2222
|
ansible_port: 2222
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
inv, err := p.ParseInventory(path)
|
inv, err := p.ParseInventory(path)
|
||||||
|
|
@ -506,11 +732,11 @@ all:
|
||||||
assert.Equal(t, 2222, inv.All.Children["production"].Hosts["prod1"].AnsiblePort)
|
assert.Equal(t, 2222, inv.All.Children["production"].Hosts["prod1"].AnsiblePort)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseInventory_Bad_InvalidYAML(t *testing.T) {
|
func TestParser_ParseInventory_Bad_InvalidYAML(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "bad.yml")
|
path := joinPath(dir, "bad.yml")
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("{{{bad"), 0644))
|
require.NoError(t, writeTestFile(path, []byte("{{{bad"), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
_, err := p.ParseInventory(path)
|
_, err := p.ParseInventory(path)
|
||||||
|
|
@ -519,7 +745,7 @@ func TestParseInventory_Bad_InvalidYAML(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "parse inventory")
|
assert.Contains(t, err.Error(), "parse inventory")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseInventory_Bad_FileNotFound(t *testing.T) {
|
func TestParser_ParseInventory_Bad_FileNotFound(t *testing.T) {
|
||||||
p := NewParser(t.TempDir())
|
p := NewParser(t.TempDir())
|
||||||
_, err := p.ParseInventory("/nonexistent/inventory.yml")
|
_, err := p.ParseInventory("/nonexistent/inventory.yml")
|
||||||
|
|
||||||
|
|
@ -529,9 +755,9 @@ func TestParseInventory_Bad_FileNotFound(t *testing.T) {
|
||||||
|
|
||||||
// --- ParseTasks ---
|
// --- ParseTasks ---
|
||||||
|
|
||||||
func TestParseTasks_Good_TaskFile(t *testing.T) {
|
func TestParser_ParseTasks_Good_TaskFile(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "tasks.yml")
|
path := joinPath(dir, "tasks.yml")
|
||||||
|
|
||||||
yaml := `---
|
yaml := `---
|
||||||
- name: First task
|
- name: First task
|
||||||
|
|
@ -541,7 +767,7 @@ func TestParseTasks_Good_TaskFile(t *testing.T) {
|
||||||
src: /tmp/a
|
src: /tmp/a
|
||||||
dest: /tmp/b
|
dest: /tmp/b
|
||||||
`
|
`
|
||||||
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
|
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
tasks, err := p.ParseTasks(path)
|
tasks, err := p.ParseTasks(path)
|
||||||
|
|
@ -554,11 +780,11 @@ func TestParseTasks_Good_TaskFile(t *testing.T) {
|
||||||
assert.Equal(t, "/tmp/a", tasks[1].Args["src"])
|
assert.Equal(t, "/tmp/a", tasks[1].Args["src"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseTasks_Bad_InvalidYAML(t *testing.T) {
|
func TestParser_ParseTasks_Bad_InvalidYAML(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "bad.yml")
|
path := joinPath(dir, "bad.yml")
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("not: [valid: tasks"), 0644))
|
require.NoError(t, writeTestFile(path, []byte("not: [valid: tasks"), 0644))
|
||||||
|
|
||||||
p := NewParser(dir)
|
p := NewParser(dir)
|
||||||
_, err := p.ParseTasks(path)
|
_, err := p.ParseTasks(path)
|
||||||
|
|
@ -566,9 +792,40 @@ func TestParseTasks_Bad_InvalidYAML(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParser_ParseRole_Good_LoadsRoleVarsIntoParserContext(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
require.NoError(t, writeTestFile(joinPath(dir, "roles", "web", "tasks", "main.yml"), []byte(`---
|
||||||
|
- name: Role task
|
||||||
|
debug:
|
||||||
|
msg: "{{ role_default }} {{ role_value }} {{ shared_value }}"
|
||||||
|
`), 0644))
|
||||||
|
require.NoError(t, writeTestFile(joinPath(dir, "roles", "web", "defaults", "main.yml"), []byte(`---
|
||||||
|
role_default: default-value
|
||||||
|
shared_value: default-shared
|
||||||
|
`), 0644))
|
||||||
|
require.NoError(t, writeTestFile(joinPath(dir, "roles", "web", "vars", "main.yml"), []byte(`---
|
||||||
|
role_value: vars-value
|
||||||
|
shared_value: role-shared
|
||||||
|
`), 0644))
|
||||||
|
|
||||||
|
p := NewParser(dir)
|
||||||
|
p.vars["existing_value"] = "keep-me"
|
||||||
|
|
||||||
|
tasks, err := p.ParseRole("web", "main.yml")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, tasks, 1)
|
||||||
|
assert.Equal(t, "debug", tasks[0].Module)
|
||||||
|
assert.Equal(t, "keep-me", p.vars["existing_value"])
|
||||||
|
assert.Equal(t, "default-value", p.vars["role_default"])
|
||||||
|
assert.Equal(t, "vars-value", p.vars["role_value"])
|
||||||
|
assert.Equal(t, "role-shared", p.vars["shared_value"])
|
||||||
|
}
|
||||||
|
|
||||||
// --- GetHosts ---
|
// --- GetHosts ---
|
||||||
|
|
||||||
func TestGetHosts_Good_AllPattern(t *testing.T) {
|
func TestParser_GetHosts_Good_AllPattern(t *testing.T) {
|
||||||
inv := &Inventory{
|
inv := &Inventory{
|
||||||
All: &InventoryGroup{
|
All: &InventoryGroup{
|
||||||
Hosts: map[string]*Host{
|
Hosts: map[string]*Host{
|
||||||
|
|
@ -584,13 +841,13 @@ func TestGetHosts_Good_AllPattern(t *testing.T) {
|
||||||
assert.Contains(t, hosts, "host2")
|
assert.Contains(t, hosts, "host2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHosts_Good_LocalhostPattern(t *testing.T) {
|
func TestParser_GetHosts_Good_LocalhostPattern(t *testing.T) {
|
||||||
inv := &Inventory{All: &InventoryGroup{}}
|
inv := &Inventory{All: &InventoryGroup{}}
|
||||||
hosts := GetHosts(inv, "localhost")
|
hosts := GetHosts(inv, "localhost")
|
||||||
assert.Equal(t, []string{"localhost"}, hosts)
|
assert.Equal(t, []string{"localhost"}, hosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHosts_Good_GroupPattern(t *testing.T) {
|
func TestParser_GetHosts_Good_GroupPattern(t *testing.T) {
|
||||||
inv := &Inventory{
|
inv := &Inventory{
|
||||||
All: &InventoryGroup{
|
All: &InventoryGroup{
|
||||||
Children: map[string]*InventoryGroup{
|
Children: map[string]*InventoryGroup{
|
||||||
|
|
@ -615,7 +872,7 @@ func TestGetHosts_Good_GroupPattern(t *testing.T) {
|
||||||
assert.Contains(t, hosts, "web2")
|
assert.Contains(t, hosts, "web2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHosts_Good_SpecificHost(t *testing.T) {
|
func TestParser_GetHosts_Good_SpecificHost(t *testing.T) {
|
||||||
inv := &Inventory{
|
inv := &Inventory{
|
||||||
All: &InventoryGroup{
|
All: &InventoryGroup{
|
||||||
Children: map[string]*InventoryGroup{
|
Children: map[string]*InventoryGroup{
|
||||||
|
|
@ -632,7 +889,39 @@ func TestGetHosts_Good_SpecificHost(t *testing.T) {
|
||||||
assert.Equal(t, []string{"myhost"}, hosts)
|
assert.Equal(t, []string{"myhost"}, hosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHosts_Good_AllIncludesChildren(t *testing.T) {
|
func TestParser_GetHosts_Good_ColonUnionIntersectionExclusion(t *testing.T) {
|
||||||
|
inv := &Inventory{
|
||||||
|
All: &InventoryGroup{
|
||||||
|
Children: map[string]*InventoryGroup{
|
||||||
|
"web": {
|
||||||
|
Hosts: map[string]*Host{
|
||||||
|
"web1": {},
|
||||||
|
"web2": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"db": {
|
||||||
|
Hosts: map[string]*Host{
|
||||||
|
"db1": {},
|
||||||
|
"web2": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"canary": {
|
||||||
|
Hosts: map[string]*Host{
|
||||||
|
"web2": {},
|
||||||
|
"db1": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"web1", "web2", "db1"}, GetHosts(inv, "web:db"))
|
||||||
|
assert.Equal(t, []string{"web2"}, GetHosts(inv, "web:&db"))
|
||||||
|
assert.Equal(t, []string{"web1"}, GetHosts(inv, "web:!canary"))
|
||||||
|
assert.Equal(t, []string{"web1"}, GetHosts(inv, "web:db:!canary"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_GetHosts_Good_AllIncludesChildren(t *testing.T) {
|
||||||
inv := &Inventory{
|
inv := &Inventory{
|
||||||
All: &InventoryGroup{
|
All: &InventoryGroup{
|
||||||
Hosts: map[string]*Host{"top": {}},
|
Hosts: map[string]*Host{"top": {}},
|
||||||
|
|
@ -650,7 +939,7 @@ func TestGetHosts_Good_AllIncludesChildren(t *testing.T) {
|
||||||
assert.Contains(t, hosts, "child1")
|
assert.Contains(t, hosts, "child1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHosts_Bad_NoMatch(t *testing.T) {
|
func TestParser_GetHosts_Bad_NoMatch(t *testing.T) {
|
||||||
inv := &Inventory{
|
inv := &Inventory{
|
||||||
All: &InventoryGroup{
|
All: &InventoryGroup{
|
||||||
Hosts: map[string]*Host{"host1": {}},
|
Hosts: map[string]*Host{"host1": {}},
|
||||||
|
|
@ -661,7 +950,7 @@ func TestGetHosts_Bad_NoMatch(t *testing.T) {
|
||||||
assert.Empty(t, hosts)
|
assert.Empty(t, hosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHosts_Bad_NilGroup(t *testing.T) {
|
func TestParser_GetHosts_Bad_NilGroup(t *testing.T) {
|
||||||
inv := &Inventory{All: nil}
|
inv := &Inventory{All: nil}
|
||||||
hosts := GetHosts(inv, "all")
|
hosts := GetHosts(inv, "all")
|
||||||
assert.Empty(t, hosts)
|
assert.Empty(t, hosts)
|
||||||
|
|
@ -669,15 +958,16 @@ func TestGetHosts_Bad_NilGroup(t *testing.T) {
|
||||||
|
|
||||||
// --- GetHostVars ---
|
// --- GetHostVars ---
|
||||||
|
|
||||||
func TestGetHostVars_Good_DirectHost(t *testing.T) {
|
func TestParser_GetHostVars_Good_DirectHost(t *testing.T) {
|
||||||
inv := &Inventory{
|
inv := &Inventory{
|
||||||
All: &InventoryGroup{
|
All: &InventoryGroup{
|
||||||
Vars: map[string]any{"global_var": "global"},
|
Vars: map[string]any{"global_var": "global"},
|
||||||
Hosts: map[string]*Host{
|
Hosts: map[string]*Host{
|
||||||
"myhost": {
|
"myhost": {
|
||||||
AnsibleHost: "10.0.0.1",
|
AnsibleHost: "10.0.0.1",
|
||||||
AnsiblePort: 2222,
|
AnsiblePort: 2222,
|
||||||
AnsibleUser: "deploy",
|
AnsibleUser: "deploy",
|
||||||
|
AnsibleBecomePassword: "secret",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -687,10 +977,11 @@ func TestGetHostVars_Good_DirectHost(t *testing.T) {
|
||||||
assert.Equal(t, "10.0.0.1", vars["ansible_host"])
|
assert.Equal(t, "10.0.0.1", vars["ansible_host"])
|
||||||
assert.Equal(t, 2222, vars["ansible_port"])
|
assert.Equal(t, 2222, vars["ansible_port"])
|
||||||
assert.Equal(t, "deploy", vars["ansible_user"])
|
assert.Equal(t, "deploy", vars["ansible_user"])
|
||||||
|
assert.Equal(t, "secret", vars["ansible_become_password"])
|
||||||
assert.Equal(t, "global", vars["global_var"])
|
assert.Equal(t, "global", vars["global_var"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHostVars_Good_InheritedGroupVars(t *testing.T) {
|
func TestParser_GetHostVars_Good_InheritedGroupVars(t *testing.T) {
|
||||||
inv := &Inventory{
|
inv := &Inventory{
|
||||||
All: &InventoryGroup{
|
All: &InventoryGroup{
|
||||||
Vars: map[string]any{"level": "all"},
|
Vars: map[string]any{"level": "all"},
|
||||||
|
|
@ -712,7 +1003,7 @@ func TestGetHostVars_Good_InheritedGroupVars(t *testing.T) {
|
||||||
assert.Equal(t, "prod", vars["env"])
|
assert.Equal(t, "prod", vars["env"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHostVars_Good_HostNotFound(t *testing.T) {
|
func TestParser_GetHostVars_Good_HostNotFound(t *testing.T) {
|
||||||
inv := &Inventory{
|
inv := &Inventory{
|
||||||
All: &InventoryGroup{
|
All: &InventoryGroup{
|
||||||
Hosts: map[string]*Host{"other": {}},
|
Hosts: map[string]*Host{"other": {}},
|
||||||
|
|
@ -725,7 +1016,7 @@ func TestGetHostVars_Good_HostNotFound(t *testing.T) {
|
||||||
|
|
||||||
// --- isModule ---
|
// --- isModule ---
|
||||||
|
|
||||||
func TestIsModule_Good_KnownModules(t *testing.T) {
|
func TestParser_IsModule_Good_KnownModules(t *testing.T) {
|
||||||
assert.True(t, isModule("shell"))
|
assert.True(t, isModule("shell"))
|
||||||
assert.True(t, isModule("command"))
|
assert.True(t, isModule("command"))
|
||||||
assert.True(t, isModule("copy"))
|
assert.True(t, isModule("copy"))
|
||||||
|
|
@ -733,43 +1024,71 @@ func TestIsModule_Good_KnownModules(t *testing.T) {
|
||||||
assert.True(t, isModule("apt"))
|
assert.True(t, isModule("apt"))
|
||||||
assert.True(t, isModule("service"))
|
assert.True(t, isModule("service"))
|
||||||
assert.True(t, isModule("systemd"))
|
assert.True(t, isModule("systemd"))
|
||||||
|
assert.True(t, isModule("rpm"))
|
||||||
assert.True(t, isModule("debug"))
|
assert.True(t, isModule("debug"))
|
||||||
assert.True(t, isModule("set_fact"))
|
assert.True(t, isModule("set_fact"))
|
||||||
|
assert.True(t, isModule("ping"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsModule_Good_FQCN(t *testing.T) {
|
func TestParser_IsModule_Good_FQCN(t *testing.T) {
|
||||||
assert.True(t, isModule("ansible.builtin.shell"))
|
assert.True(t, isModule("ansible.builtin.shell"))
|
||||||
assert.True(t, isModule("ansible.builtin.copy"))
|
assert.True(t, isModule("ansible.builtin.copy"))
|
||||||
assert.True(t, isModule("ansible.builtin.apt"))
|
assert.True(t, isModule("ansible.builtin.apt"))
|
||||||
|
assert.True(t, isModule("ansible.builtin.rpm"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsModule_Good_DottedUnknown(t *testing.T) {
|
func TestParser_IsModule_Good_DottedUnknown(t *testing.T) {
|
||||||
// Any key with dots is considered a module
|
// Any key with dots is considered a module
|
||||||
assert.True(t, isModule("community.general.ufw"))
|
assert.True(t, isModule("community.general.ufw"))
|
||||||
assert.True(t, isModule("ansible.posix.authorized_key"))
|
assert.True(t, isModule("ansible.posix.authorized_key"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsModule_Bad_NotAModule(t *testing.T) {
|
func TestParser_IsModule_Bad_NotAModule(t *testing.T) {
|
||||||
assert.False(t, isModule("some_random_key"))
|
assert.False(t, isModule("some_random_key"))
|
||||||
assert.False(t, isModule("foobar"))
|
assert.False(t, isModule("foobar"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NormalizeModule ---
|
// --- NormalizeModule ---
|
||||||
|
|
||||||
func TestNormalizeModule_Good(t *testing.T) {
|
func TestParser_NormalizeModule_Good(t *testing.T) {
|
||||||
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("shell"))
|
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("shell"))
|
||||||
assert.Equal(t, "ansible.builtin.copy", NormalizeModule("copy"))
|
assert.Equal(t, "ansible.builtin.copy", NormalizeModule("copy"))
|
||||||
assert.Equal(t, "ansible.builtin.apt", NormalizeModule("apt"))
|
assert.Equal(t, "ansible.builtin.apt", NormalizeModule("apt"))
|
||||||
|
assert.Equal(t, "ansible.builtin.rpm", NormalizeModule("rpm"))
|
||||||
|
assert.Equal(t, "ansible.builtin.ping", NormalizeModule("ping"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeModule_Good_AlreadyFQCN(t *testing.T) {
|
func TestParser_NormalizeModule_Good_CommunityAliases(t *testing.T) {
|
||||||
|
assert.Equal(t, "ansible.posix.authorized_key", NormalizeModule("authorized_key"))
|
||||||
|
assert.Equal(t, "ansible.posix.authorized_key", NormalizeModule("ansible.builtin.authorized_key"))
|
||||||
|
assert.Equal(t, "community.general.ufw", NormalizeModule("ufw"))
|
||||||
|
assert.Equal(t, "community.general.ufw", NormalizeModule("ansible.builtin.ufw"))
|
||||||
|
assert.Equal(t, "community.docker.docker_compose", NormalizeModule("docker_compose"))
|
||||||
|
assert.Equal(t, "community.docker.docker_compose_v2", NormalizeModule("docker_compose_v2"))
|
||||||
|
assert.Equal(t, "community.docker.docker_compose", NormalizeModule("ansible.builtin.docker_compose"))
|
||||||
|
assert.Equal(t, "community.docker.docker_compose_v2", NormalizeModule("ansible.builtin.docker_compose_v2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_NormalizeModule_Good_AlreadyFQCN(t *testing.T) {
|
||||||
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("ansible.builtin.shell"))
|
assert.Equal(t, "ansible.builtin.shell", NormalizeModule("ansible.builtin.shell"))
|
||||||
assert.Equal(t, "community.general.ufw", NormalizeModule("community.general.ufw"))
|
assert.Equal(t, "community.general.ufw", NormalizeModule("community.general.ufw"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParser_IsModule_Good_AdditionalFQCN(t *testing.T) {
|
||||||
|
assert.True(t, isModule("ansible.builtin.hostname"))
|
||||||
|
assert.True(t, isModule("ansible.builtin.sysctl"))
|
||||||
|
assert.True(t, isModule("ansible.builtin.reboot"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParser_NormalizeModule_Good_LegacyNamespace(t *testing.T) {
|
||||||
|
assert.Equal(t, "ansible.builtin.command", NormalizeModule("ansible.legacy.command"))
|
||||||
|
assert.Equal(t, "ansible.posix.authorized_key", NormalizeModule("ansible.legacy.authorized_key"))
|
||||||
|
assert.Equal(t, "community.general.ufw", NormalizeModule("ansible.legacy.ufw"))
|
||||||
|
}
|
||||||
|
|
||||||
// --- NewParser ---
|
// --- NewParser ---
|
||||||
|
|
||||||
func TestNewParser_Good(t *testing.T) {
|
func TestParser_NewParser_Good(t *testing.T) {
|
||||||
p := NewParser("/some/path")
|
p := NewParser("/some/path")
|
||||||
assert.NotNil(t, p)
|
assert.NotNil(t, p)
|
||||||
assert.Equal(t, "/some/path", p.basePath)
|
assert.Equal(t, "/some/path", p.basePath)
|
||||||
|
|
|
||||||
180
ssh.go
180
ssh.go
|
|
@ -3,12 +3,9 @@ package ansible
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -19,6 +16,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// SSHClient handles SSH connections to remote hosts.
|
// SSHClient handles SSH connections to remote hosts.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// client, _ := NewSSHClient(SSHConfig{Host: "web1"})
|
||||||
type SSHClient struct {
|
type SSHClient struct {
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
|
|
@ -34,6 +35,10 @@ type SSHClient struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHConfig holds SSH connection configuration.
|
// SSHConfig holds SSH connection configuration.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// config := SSHConfig{Host: "web1", User: "deploy", Port: 22}
|
||||||
type SSHConfig struct {
|
type SSHConfig struct {
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
|
|
@ -47,33 +52,41 @@ type SSHConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSSHClient creates a new SSH client.
|
// NewSSHClient creates a new SSH client.
|
||||||
func NewSSHClient(cfg SSHConfig) (*SSHClient, error) {
|
//
|
||||||
if cfg.Port == 0 {
|
// Example:
|
||||||
cfg.Port = 22
|
//
|
||||||
|
// client, err := NewSSHClient(SSHConfig{Host: "web1", User: "deploy"})
|
||||||
|
func NewSSHClient(config SSHConfig) (*SSHClient, error) {
|
||||||
|
if config.Port == 0 {
|
||||||
|
config.Port = 22
|
||||||
}
|
}
|
||||||
if cfg.User == "" {
|
if config.User == "" {
|
||||||
cfg.User = "root"
|
config.User = "root"
|
||||||
}
|
}
|
||||||
if cfg.Timeout == 0 {
|
if config.Timeout == 0 {
|
||||||
cfg.Timeout = 30 * time.Second
|
config.Timeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &SSHClient{
|
client := &SSHClient{
|
||||||
host: cfg.Host,
|
host: config.Host,
|
||||||
port: cfg.Port,
|
port: config.Port,
|
||||||
user: cfg.User,
|
user: config.User,
|
||||||
password: cfg.Password,
|
password: config.Password,
|
||||||
keyFile: cfg.KeyFile,
|
keyFile: config.KeyFile,
|
||||||
become: cfg.Become,
|
become: config.Become,
|
||||||
becomeUser: cfg.BecomeUser,
|
becomeUser: config.BecomeUser,
|
||||||
becomePass: cfg.BecomePass,
|
becomePass: config.BecomePass,
|
||||||
timeout: cfg.Timeout,
|
timeout: config.Timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect establishes the SSH connection.
|
// Connect establishes the SSH connection.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = client.Connect(context.Background())
|
||||||
func (c *SSHClient) Connect(ctx context.Context) error {
|
func (c *SSHClient) Connect(ctx context.Context) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
@ -87,9 +100,8 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
||||||
// Try key-based auth first
|
// Try key-based auth first
|
||||||
if c.keyFile != "" {
|
if c.keyFile != "" {
|
||||||
keyPath := c.keyFile
|
keyPath := c.keyFile
|
||||||
if strings.HasPrefix(keyPath, "~") {
|
if corexHasPrefix(keyPath, "~") {
|
||||||
home, _ := os.UserHomeDir()
|
keyPath = joinPath(env("DIR_HOME"), keyPath[1:])
|
||||||
keyPath = filepath.Join(home, keyPath[1:])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if key, err := coreio.Local.Read(keyPath); err == nil {
|
if key, err := coreio.Local.Read(keyPath); err == nil {
|
||||||
|
|
@ -101,10 +113,10 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
||||||
|
|
||||||
// Try default SSH keys
|
// Try default SSH keys
|
||||||
if len(authMethods) == 0 {
|
if len(authMethods) == 0 {
|
||||||
home, _ := os.UserHomeDir()
|
home := env("DIR_HOME")
|
||||||
defaultKeys := []string{
|
defaultKeys := []string{
|
||||||
filepath.Join(home, ".ssh", "id_ed25519"),
|
joinPath(home, ".ssh", "id_ed25519"),
|
||||||
filepath.Join(home, ".ssh", "id_rsa"),
|
joinPath(home, ".ssh", "id_rsa"),
|
||||||
}
|
}
|
||||||
for _, keyPath := range defaultKeys {
|
for _, keyPath := range defaultKeys {
|
||||||
if key, err := coreio.Local.Read(keyPath); err == nil {
|
if key, err := coreio.Local.Read(keyPath); err == nil {
|
||||||
|
|
@ -135,15 +147,15 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
||||||
// Host key verification
|
// Host key verification
|
||||||
var hostKeyCallback ssh.HostKeyCallback
|
var hostKeyCallback ssh.HostKeyCallback
|
||||||
|
|
||||||
home, err := os.UserHomeDir()
|
home := env("DIR_HOME")
|
||||||
if err != nil {
|
if home == "" {
|
||||||
return coreerr.E("ssh.Connect", "failed to get user home dir", err)
|
return coreerr.E("ssh.Connect", "failed to get user home dir", nil)
|
||||||
}
|
}
|
||||||
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
|
knownHostsPath := joinPath(home, ".ssh", "known_hosts")
|
||||||
|
|
||||||
// Ensure known_hosts file exists
|
// Ensure known_hosts file exists
|
||||||
if !coreio.Local.Exists(knownHostsPath) {
|
if !coreio.Local.Exists(knownHostsPath) {
|
||||||
if err := coreio.Local.EnsureDir(filepath.Dir(knownHostsPath)); err != nil {
|
if err := coreio.Local.EnsureDir(pathDir(knownHostsPath)); err != nil {
|
||||||
return coreerr.E("ssh.Connect", "failed to create .ssh dir", err)
|
return coreerr.E("ssh.Connect", "failed to create .ssh dir", err)
|
||||||
}
|
}
|
||||||
if err := coreio.Local.Write(knownHostsPath, ""); err != nil {
|
if err := coreio.Local.Write(knownHostsPath, ""); err != nil {
|
||||||
|
|
@ -164,19 +176,19 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
||||||
Timeout: c.timeout,
|
Timeout: c.timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", c.host, c.port)
|
addr := sprintf("%s:%d", c.host, c.port)
|
||||||
|
|
||||||
// Connect with context timeout
|
// Connect with context timeout
|
||||||
var d net.Dialer
|
var d net.Dialer
|
||||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("ssh.Connect", fmt.Sprintf("dial %s", addr), err)
|
return coreerr.E("ssh.Connect", sprintf("dial %s", addr), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// conn is closed by NewClientConn on error
|
// conn is closed by NewClientConn on error
|
||||||
return coreerr.E("ssh.Connect", fmt.Sprintf("ssh connect %s", addr), err)
|
return coreerr.E("ssh.Connect", sprintf("ssh connect %s", addr), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.client = ssh.NewClient(sshConn, chans, reqs)
|
c.client = ssh.NewClient(sshConn, chans, reqs)
|
||||||
|
|
@ -184,6 +196,10 @@ func (c *SSHClient) Connect(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the SSH connection.
|
// Close closes the SSH connection.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = client.Close()
|
||||||
func (c *SSHClient) Close() error {
|
func (c *SSHClient) Close() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
@ -196,7 +212,22 @@ func (c *SSHClient) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BecomeState returns the current privilege escalation settings.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// become, user, password := client.BecomeState()
|
||||||
|
func (c *SSHClient) BecomeState() (bool, string, string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.become, c.becomeUser, c.becomePass
|
||||||
|
}
|
||||||
|
|
||||||
// Run executes a command on the remote host.
|
// Run executes a command on the remote host.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// stdout, stderr, rc, err := client.Run(context.Background(), "hostname")
|
||||||
func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
||||||
if err := c.Connect(ctx); err != nil {
|
if err := c.Connect(ctx); err != nil {
|
||||||
return "", "", -1, err
|
return "", "", -1, err
|
||||||
|
|
@ -219,33 +250,33 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
|
||||||
becomeUser = "root"
|
becomeUser = "root"
|
||||||
}
|
}
|
||||||
// Escape single quotes in the command
|
// Escape single quotes in the command
|
||||||
escapedCmd := strings.ReplaceAll(cmd, "'", "'\\''")
|
escapedCmd := replaceAll(cmd, "'", "'\\''")
|
||||||
if c.becomePass != "" {
|
if c.becomePass != "" {
|
||||||
// Use sudo with password via stdin (-S flag)
|
// Use sudo with password via stdin (-S flag)
|
||||||
// We launch a goroutine to write the password to stdin
|
// We launch a goroutine to write the password to stdin
|
||||||
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||||
stdin, err := session.StdinPipe()
|
stdin, err := session.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
|
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
defer func() { _ = stdin.Close() }()
|
defer func() { _ = stdin.Close() }()
|
||||||
_, _ = io.WriteString(stdin, c.becomePass+"\n")
|
writeString(stdin, c.becomePass+"\n")
|
||||||
}()
|
}()
|
||||||
} else if c.password != "" {
|
} else if c.password != "" {
|
||||||
// Try using connection password for sudo
|
// Try using connection password for sudo
|
||||||
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||||
stdin, err := session.StdinPipe()
|
stdin, err := session.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
|
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
defer func() { _ = stdin.Close() }()
|
defer func() { _ = stdin.Close() }()
|
||||||
_, _ = io.WriteString(stdin, c.password+"\n")
|
writeString(stdin, c.password+"\n")
|
||||||
}()
|
}()
|
||||||
} else {
|
} else {
|
||||||
// Try passwordless sudo
|
// Try passwordless sudo
|
||||||
cmd = fmt.Sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
|
cmd = sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,36 +304,44 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunScript runs a script on the remote host.
|
// RunScript runs a script on the remote host.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// stdout, stderr, rc, err := client.RunScript(context.Background(), "echo hello")
|
||||||
func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
|
func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
|
||||||
// Escape the script for heredoc
|
// Escape the script for heredoc
|
||||||
cmd := fmt.Sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
|
cmd := sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
|
||||||
return c.Run(ctx, cmd)
|
return c.Run(ctx, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload copies a file to the remote host.
|
// Upload copies a file to the remote host.
|
||||||
func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error {
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// err := client.Upload(context.Background(), newReader("hello"), "/tmp/hello.txt", 0644)
|
||||||
|
func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode fs.FileMode) error {
|
||||||
if err := c.Connect(ctx); err != nil {
|
if err := c.Connect(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read content
|
// Read content
|
||||||
content, err := io.ReadAll(local)
|
content, err := readAllString(local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("ssh.Upload", "read content", err)
|
return coreerr.E("ssh.Upload", "read content", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create parent directory
|
// Create parent directory
|
||||||
dir := filepath.Dir(remote)
|
dir := pathDir(remote)
|
||||||
dirCmd := fmt.Sprintf("mkdir -p %q", dir)
|
dirCmd := sprintf("mkdir -p %q", dir)
|
||||||
if c.become {
|
if c.become {
|
||||||
dirCmd = fmt.Sprintf("sudo mkdir -p %q", dir)
|
dirCmd = sprintf("sudo mkdir -p %q", dir)
|
||||||
}
|
}
|
||||||
if _, _, _, err := c.Run(ctx, dirCmd); err != nil {
|
if _, _, _, err := c.Run(ctx, dirCmd); err != nil {
|
||||||
return coreerr.E("ssh.Upload", "create parent dir", err)
|
return coreerr.E("ssh.Upload", "create parent dir", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cat to write the file (simpler than SCP)
|
// Use cat to write the file (simpler than SCP)
|
||||||
writeCmd := fmt.Sprintf("cat > %q && chmod %o %q", remote, mode, remote)
|
writeCmd := sprintf("cat > %q && chmod %o %q", remote, mode, remote)
|
||||||
|
|
||||||
// If become is needed, we construct a command that reads password then content from stdin
|
// 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.
|
// But we need to be careful with handling stdin for sudo + cat.
|
||||||
|
|
@ -335,11 +374,11 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
|
||||||
|
|
||||||
if pass != "" {
|
if pass != "" {
|
||||||
// Use sudo -S with password from stdin
|
// Use sudo -S with password from stdin
|
||||||
writeCmd = fmt.Sprintf("sudo -S -u %s bash -c 'cat > %q && chmod %o %q'",
|
writeCmd = sprintf("sudo -S -u %s bash -c 'cat > %q && chmod %o %q'",
|
||||||
becomeUser, remote, mode, remote)
|
becomeUser, remote, mode, remote)
|
||||||
} else {
|
} else {
|
||||||
// Use passwordless sudo (sudo -n) to avoid consuming file content as password
|
// 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'",
|
writeCmd = sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
|
||||||
becomeUser, remote, mode, remote)
|
becomeUser, remote, mode, remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,9 +389,9 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
|
||||||
go func() {
|
go func() {
|
||||||
defer func() { _ = stdin.Close() }()
|
defer func() { _ = stdin.Close() }()
|
||||||
if pass != "" {
|
if pass != "" {
|
||||||
_, _ = io.WriteString(stdin, pass+"\n")
|
writeString(stdin, pass+"\n")
|
||||||
}
|
}
|
||||||
_, _ = stdin.Write(content)
|
_, _ = stdin.Write([]byte(content))
|
||||||
}()
|
}()
|
||||||
} else {
|
} else {
|
||||||
// Normal write
|
// Normal write
|
||||||
|
|
@ -362,39 +401,47 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer func() { _ = stdin.Close() }()
|
defer func() { _ = stdin.Close() }()
|
||||||
_, _ = stdin.Write(content)
|
_, _ = stdin.Write([]byte(content))
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := session2.Wait(); err != nil {
|
if err := session2.Wait(); err != nil {
|
||||||
return coreerr.E("ssh.Upload", fmt.Sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
|
return coreerr.E("ssh.Upload", sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download copies a file from the remote host.
|
// Download copies a file from the remote host.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// data, err := client.Download(context.Background(), "/etc/hostname")
|
||||||
func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error) {
|
func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error) {
|
||||||
if err := c.Connect(ctx); err != nil {
|
if err := c.Connect(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := fmt.Sprintf("cat %q", remote)
|
cmd := sprintf("cat %q", remote)
|
||||||
|
|
||||||
stdout, stderr, exitCode, err := c.Run(ctx, cmd)
|
stdout, stderr, exitCode, err := c.Run(ctx, cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if exitCode != 0 {
|
if exitCode != 0 {
|
||||||
return nil, coreerr.E("ssh.Download", fmt.Sprintf("cat failed: %s", stderr), nil)
|
return nil, coreerr.E("ssh.Download", sprintf("cat failed: %s", stderr), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return []byte(stdout), nil
|
return []byte(stdout), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileExists checks if a file exists on the remote host.
|
// FileExists checks if a file exists on the remote host.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// ok, err := client.FileExists(context.Background(), "/etc/hosts")
|
||||||
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
|
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
|
||||||
cmd := fmt.Sprintf("test -e %q && echo yes || echo no", path)
|
cmd := sprintf("test -e %q && echo yes || echo no", path)
|
||||||
stdout, _, exitCode, err := c.Run(ctx, cmd)
|
stdout, _, exitCode, err := c.Run(ctx, cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -403,13 +450,17 @@ func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
|
||||||
// test command failed but didn't error - file doesn't exist
|
// test command failed but didn't error - file doesn't exist
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(stdout) == "yes", nil
|
return corexTrimSpace(stdout) == "yes", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stat returns file info from the remote host.
|
// Stat returns file info from the remote host.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// info, err := client.Stat(context.Background(), "/etc/hosts")
|
||||||
func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error) {
|
func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error) {
|
||||||
// Simple approach - get basic file info
|
// Simple approach - get basic file info
|
||||||
cmd := fmt.Sprintf(`
|
cmd := sprintf(`
|
||||||
if [ -e %q ]; then
|
if [ -e %q ]; then
|
||||||
if [ -d %q ]; then
|
if [ -d %q ]; then
|
||||||
echo "exists=true isdir=true"
|
echo "exists=true isdir=true"
|
||||||
|
|
@ -427,9 +478,9 @@ fi
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make(map[string]any)
|
result := make(map[string]any)
|
||||||
parts := strings.Fields(strings.TrimSpace(stdout))
|
parts := fields(corexTrimSpace(stdout))
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
kv := strings.SplitN(part, "=", 2)
|
kv := splitN(part, "=", 2)
|
||||||
if len(kv) == 2 {
|
if len(kv) == 2 {
|
||||||
result[kv[0]] = kv[1] == "true"
|
result[kv[0]] = kv[1] == "true"
|
||||||
}
|
}
|
||||||
|
|
@ -439,10 +490,19 @@ fi
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBecome enables privilege escalation.
|
// SetBecome enables privilege escalation.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// client.SetBecome(true, "root", "")
|
||||||
func (c *SSHClient) SetBecome(become bool, user, password string) {
|
func (c *SSHClient) SetBecome(become bool, user, password string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.become = become
|
c.become = become
|
||||||
|
if !become {
|
||||||
|
c.becomeUser = ""
|
||||||
|
c.becomePass = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
if user != "" {
|
if user != "" {
|
||||||
c.becomeUser = user
|
c.becomeUser = user
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
ssh_test.go
20
ssh_test.go
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSSHClient(t *testing.T) {
|
func TestSSH_NewSSHClient_Good_CustomConfig(t *testing.T) {
|
||||||
cfg := SSHConfig{
|
cfg := SSHConfig{
|
||||||
Host: "localhost",
|
Host: "localhost",
|
||||||
Port: 2222,
|
Port: 2222,
|
||||||
|
|
@ -23,7 +23,7 @@ func TestNewSSHClient(t *testing.T) {
|
||||||
assert.Equal(t, 30*time.Second, client.timeout)
|
assert.Equal(t, 30*time.Second, client.timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSSHConfig_Defaults(t *testing.T) {
|
func TestSSH_NewSSHClient_Good_Defaults(t *testing.T) {
|
||||||
cfg := SSHConfig{
|
cfg := SSHConfig{
|
||||||
Host: "localhost",
|
Host: "localhost",
|
||||||
}
|
}
|
||||||
|
|
@ -34,3 +34,19 @@ func TestSSHConfig_Defaults(t *testing.T) {
|
||||||
assert.Equal(t, "root", client.user)
|
assert.Equal(t, "root", client.user)
|
||||||
assert.Equal(t, 30*time.Second, client.timeout)
|
assert.Equal(t, 30*time.Second, client.timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSSH_SetBecome_Good_DisablesAndClearsState(t *testing.T) {
|
||||||
|
client := &SSHClient{}
|
||||||
|
|
||||||
|
client.SetBecome(true, "admin", "secret")
|
||||||
|
become, user, password := client.BecomeState()
|
||||||
|
assert.True(t, become)
|
||||||
|
assert.Equal(t, "admin", user)
|
||||||
|
assert.Equal(t, "secret", password)
|
||||||
|
|
||||||
|
client.SetBecome(false, "", "")
|
||||||
|
become, user, password = client.BecomeState()
|
||||||
|
assert.False(t, become)
|
||||||
|
assert.Empty(t, user)
|
||||||
|
assert.Empty(t, password)
|
||||||
|
}
|
||||||
|
|
|
||||||
23
test_primitives_test.go
Normal file
23
test_primitives_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readTestFile(path string) ([]byte, error) {
|
||||||
|
content, err := coreio.Local.Read(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []byte(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestFile(path string, content []byte, mode fs.FileMode) error {
|
||||||
|
return coreio.Local.WriteMode(path, string(content), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinStrings(parts []string, sep string) string {
|
||||||
|
return join(sep, parts)
|
||||||
|
}
|
||||||
403
types.go
403
types.go
|
|
@ -2,44 +2,95 @@ package ansible
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Playbook represents an Ansible playbook.
|
// Playbook represents an Ansible playbook.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// playbook := Playbook{Plays: []Play{{Name: "Bootstrap", Hosts: "all"}}}
|
||||||
type Playbook struct {
|
type Playbook struct {
|
||||||
Plays []Play `yaml:",inline"`
|
Plays []Play `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play represents a single play in a playbook.
|
// Play represents a single play in a playbook.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// play := Play{Name: "Configure web", Hosts: "webservers", Become: true}
|
||||||
type Play struct {
|
type Play struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Hosts string `yaml:"hosts"`
|
Hosts string `yaml:"hosts"`
|
||||||
Connection string `yaml:"connection,omitempty"`
|
ImportPlaybook string `yaml:"import_playbook,omitempty"`
|
||||||
Become bool `yaml:"become,omitempty"`
|
Connection string `yaml:"connection,omitempty"`
|
||||||
BecomeUser string `yaml:"become_user,omitempty"`
|
Become bool `yaml:"become,omitempty"`
|
||||||
GatherFacts *bool `yaml:"gather_facts,omitempty"`
|
BecomeUser string `yaml:"become_user,omitempty"`
|
||||||
Vars map[string]any `yaml:"vars,omitempty"`
|
GatherFacts *bool `yaml:"gather_facts,omitempty"`
|
||||||
PreTasks []Task `yaml:"pre_tasks,omitempty"`
|
ForceHandlers bool `yaml:"force_handlers,omitempty"`
|
||||||
Tasks []Task `yaml:"tasks,omitempty"`
|
AnyErrorsFatal bool `yaml:"any_errors_fatal,omitempty"`
|
||||||
PostTasks []Task `yaml:"post_tasks,omitempty"`
|
Vars map[string]any `yaml:"vars,omitempty"`
|
||||||
Roles []RoleRef `yaml:"roles,omitempty"`
|
VarsFiles any `yaml:"vars_files,omitempty"` // string or []string
|
||||||
Handlers []Task `yaml:"handlers,omitempty"`
|
ModuleDefaults map[string]map[string]any `yaml:"module_defaults,omitempty"`
|
||||||
Tags []string `yaml:"tags,omitempty"`
|
PreTasks []Task `yaml:"pre_tasks,omitempty"`
|
||||||
Environment map[string]string `yaml:"environment,omitempty"`
|
Tasks []Task `yaml:"tasks,omitempty"`
|
||||||
Serial any `yaml:"serial,omitempty"` // int or string
|
PostTasks []Task `yaml:"post_tasks,omitempty"`
|
||||||
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
|
Roles []RoleRef `yaml:"roles,omitempty"`
|
||||||
|
Handlers []Task `yaml:"handlers,omitempty"`
|
||||||
|
Tags []string `yaml:"tags,omitempty"`
|
||||||
|
Environment map[string]string `yaml:"environment,omitempty"`
|
||||||
|
Serial any `yaml:"serial,omitempty"` // int or string
|
||||||
|
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalYAML handles play-level aliases such as ansible.builtin.import_playbook.
|
||||||
|
func (p *Play) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
type rawPlay Play
|
||||||
|
var raw rawPlay
|
||||||
|
if err := node.Decode(&raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*p = Play(raw)
|
||||||
|
|
||||||
|
var fields map[string]any
|
||||||
|
if err := node.Decode(&fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := directiveValue(fields, "import_playbook"); ok && p.ImportPlaybook == "" {
|
||||||
|
p.ImportPlaybook = sprintf("%v", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoleRef represents a role reference in a play.
|
// RoleRef represents a role reference in a play.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// role := RoleRef{Role: "nginx", TasksFrom: "install.yml"}
|
||||||
type RoleRef struct {
|
type RoleRef struct {
|
||||||
Role string `yaml:"role,omitempty"`
|
Role string `yaml:"role,omitempty"`
|
||||||
Name string `yaml:"name,omitempty"` // Alternative to role
|
Name string `yaml:"name,omitempty"` // Alternative to role
|
||||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
TasksFrom string `yaml:"tasks_from,omitempty"`
|
||||||
Vars map[string]any `yaml:"vars,omitempty"`
|
DefaultsFrom string `yaml:"defaults_from,omitempty"`
|
||||||
When any `yaml:"when,omitempty"`
|
VarsFrom string `yaml:"vars_from,omitempty"`
|
||||||
Tags []string `yaml:"tags,omitempty"`
|
HandlersFrom string `yaml:"handlers_from,omitempty"`
|
||||||
|
Vars map[string]any `yaml:"vars,omitempty"`
|
||||||
|
Apply *TaskApply `yaml:"apply,omitempty"`
|
||||||
|
Public bool `yaml:"public,omitempty"`
|
||||||
|
When any `yaml:"when,omitempty"`
|
||||||
|
Tags []string `yaml:"tags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalYAML handles both string and struct role refs.
|
// UnmarshalYAML handles both string and struct role refs.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// var ref RoleRef
|
||||||
|
// _ = yaml.Unmarshal([]byte("common"), &ref)
|
||||||
func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
|
func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
|
||||||
// Try string first
|
// Try string first
|
||||||
var s string
|
var s string
|
||||||
|
|
@ -61,53 +112,77 @@ func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func directiveValue(fields map[string]any, name string) (any, bool) {
|
||||||
|
if fields == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{name, "ansible.builtin." + name, "ansible.legacy." + name} {
|
||||||
|
if value, ok := fields[key]; ok {
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
// Task represents an Ansible task.
|
// Task represents an Ansible task.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// task := Task{Name: "Install nginx", Module: "apt", Args: map[string]any{"name": "nginx"}}
|
||||||
type Task struct {
|
type Task struct {
|
||||||
Name string `yaml:"name,omitempty"`
|
Name string `yaml:"name,omitempty"`
|
||||||
Module string `yaml:"-"` // Derived from the module key
|
Module string `yaml:"-"` // Derived from the module key
|
||||||
Args map[string]any `yaml:"-"` // Module arguments
|
Args map[string]any `yaml:"-"` // Module arguments
|
||||||
Register string `yaml:"register,omitempty"`
|
Register string `yaml:"register,omitempty"`
|
||||||
When any `yaml:"when,omitempty"` // string or []string
|
When any `yaml:"when,omitempty"` // string or []string
|
||||||
Loop any `yaml:"loop,omitempty"` // string or []any
|
CheckMode *bool `yaml:"check_mode,omitempty"`
|
||||||
LoopControl *LoopControl `yaml:"loop_control,omitempty"`
|
Diff *bool `yaml:"diff,omitempty"`
|
||||||
Vars map[string]any `yaml:"vars,omitempty"`
|
Loop any `yaml:"loop,omitempty"` // string or []any
|
||||||
Environment map[string]string `yaml:"environment,omitempty"`
|
LoopControl *LoopControl `yaml:"loop_control,omitempty"`
|
||||||
ChangedWhen any `yaml:"changed_when,omitempty"`
|
Vars map[string]any `yaml:"vars,omitempty"`
|
||||||
FailedWhen any `yaml:"failed_when,omitempty"`
|
Environment map[string]string `yaml:"environment,omitempty"`
|
||||||
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
|
ChangedWhen any `yaml:"changed_when,omitempty"`
|
||||||
NoLog bool `yaml:"no_log,omitempty"`
|
FailedWhen any `yaml:"failed_when,omitempty"`
|
||||||
Become *bool `yaml:"become,omitempty"`
|
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
|
||||||
BecomeUser string `yaml:"become_user,omitempty"`
|
NoLog bool `yaml:"no_log,omitempty"`
|
||||||
Delegate string `yaml:"delegate_to,omitempty"`
|
Become *bool `yaml:"become,omitempty"`
|
||||||
RunOnce bool `yaml:"run_once,omitempty"`
|
BecomeUser string `yaml:"become_user,omitempty"`
|
||||||
Tags []string `yaml:"tags,omitempty"`
|
Delegate string `yaml:"delegate_to,omitempty"`
|
||||||
Block []Task `yaml:"block,omitempty"`
|
DelegateFacts bool `yaml:"delegate_facts,omitempty"`
|
||||||
Rescue []Task `yaml:"rescue,omitempty"`
|
RunOnce bool `yaml:"run_once,omitempty"`
|
||||||
Always []Task `yaml:"always,omitempty"`
|
Tags []string `yaml:"tags,omitempty"`
|
||||||
Notify any `yaml:"notify,omitempty"` // string or []string
|
Block []Task `yaml:"block,omitempty"`
|
||||||
Retries int `yaml:"retries,omitempty"`
|
Rescue []Task `yaml:"rescue,omitempty"`
|
||||||
Delay int `yaml:"delay,omitempty"`
|
Always []Task `yaml:"always,omitempty"`
|
||||||
Until string `yaml:"until,omitempty"`
|
Notify any `yaml:"notify,omitempty"` // string or []string
|
||||||
|
Listen any `yaml:"listen,omitempty"` // string or []string
|
||||||
|
Retries int `yaml:"retries,omitempty"`
|
||||||
|
Delay int `yaml:"delay,omitempty"`
|
||||||
|
Until string `yaml:"until,omitempty"`
|
||||||
|
|
||||||
// Include/import directives
|
// Include/import directives
|
||||||
IncludeTasks string `yaml:"include_tasks,omitempty"`
|
IncludeTasks string `yaml:"include_tasks,omitempty"`
|
||||||
ImportTasks string `yaml:"import_tasks,omitempty"`
|
ImportTasks string `yaml:"import_tasks,omitempty"`
|
||||||
IncludeRole *struct {
|
Apply *TaskApply `yaml:"apply,omitempty"`
|
||||||
Name string `yaml:"name"`
|
WithFile any `yaml:"with_file,omitempty"`
|
||||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
WithFileGlob any `yaml:"with_fileglob,omitempty"`
|
||||||
Vars map[string]any `yaml:"vars,omitempty"`
|
WithSequence any `yaml:"with_sequence,omitempty"`
|
||||||
} `yaml:"include_role,omitempty"`
|
WithTogether any `yaml:"with_together,omitempty"`
|
||||||
ImportRole *struct {
|
WithSubelements any `yaml:"with_subelements,omitempty"`
|
||||||
Name string `yaml:"name"`
|
IncludeRole *RoleRef `yaml:"include_role,omitempty"`
|
||||||
TasksFrom string `yaml:"tasks_from,omitempty"`
|
ImportRole *RoleRef `yaml:"import_role,omitempty"`
|
||||||
Vars map[string]any `yaml:"vars,omitempty"`
|
|
||||||
} `yaml:"import_role,omitempty"`
|
|
||||||
|
|
||||||
// Raw YAML for module extraction
|
// Raw YAML for module extraction
|
||||||
raw map[string]any
|
raw map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoopControl controls loop behavior.
|
// LoopControl controls loop behaviour.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// loop := LoopControl{LoopVar: "item", IndexVar: "idx"}
|
||||||
type LoopControl struct {
|
type LoopControl struct {
|
||||||
LoopVar string `yaml:"loop_var,omitempty"`
|
LoopVar string `yaml:"loop_var,omitempty"`
|
||||||
IndexVar string `yaml:"index_var,omitempty"`
|
IndexVar string `yaml:"index_var,omitempty"`
|
||||||
|
|
@ -116,7 +191,30 @@ type LoopControl struct {
|
||||||
Extended bool `yaml:"extended,omitempty"`
|
Extended bool `yaml:"extended,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskApply captures role-level task defaults from include_role/import_role.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// apply := TaskApply{Tags: []string{"deploy"}}
|
||||||
|
type TaskApply struct {
|
||||||
|
Tags []string `yaml:"tags,omitempty"`
|
||||||
|
Vars map[string]any `yaml:"vars,omitempty"`
|
||||||
|
Environment map[string]string `yaml:"environment,omitempty"`
|
||||||
|
When any `yaml:"when,omitempty"`
|
||||||
|
Become *bool `yaml:"become,omitempty"`
|
||||||
|
BecomeUser string `yaml:"become_user,omitempty"`
|
||||||
|
Delegate string `yaml:"delegate_to,omitempty"`
|
||||||
|
DelegateFacts bool `yaml:"delegate_facts,omitempty"`
|
||||||
|
RunOnce bool `yaml:"run_once,omitempty"`
|
||||||
|
NoLog bool `yaml:"no_log,omitempty"`
|
||||||
|
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// TaskResult holds the result of executing a task.
|
// TaskResult holds the result of executing a task.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := TaskResult{Changed: true, Stdout: "ok"}
|
||||||
type TaskResult struct {
|
type TaskResult struct {
|
||||||
Changed bool `json:"changed"`
|
Changed bool `json:"changed"`
|
||||||
Failed bool `json:"failed"`
|
Failed bool `json:"failed"`
|
||||||
|
|
@ -131,11 +229,116 @@ type TaskResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inventory represents Ansible inventory.
|
// Inventory represents Ansible inventory.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// inventory := Inventory{All: &InventoryGroup{Hosts: map[string]*Host{"web1": {AnsibleHost: "10.0.0.1"}}}}
|
||||||
type Inventory struct {
|
type Inventory struct {
|
||||||
All *InventoryGroup `yaml:"all"`
|
All *InventoryGroup `yaml:"all"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnmarshalYAML supports both the explicit `all:` root and inventories that
|
||||||
|
// declare top-level groups directly.
|
||||||
|
func (i *Inventory) UnmarshalYAML(unmarshal func(any) error) error {
|
||||||
|
var raw map[string]any
|
||||||
|
if err := unmarshal(&raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
root := &InventoryGroup{}
|
||||||
|
rootInput := make(map[string]any)
|
||||||
|
if all, ok := raw["all"]; ok {
|
||||||
|
group, err := decodeInventoryGroupValue(all)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("Inventory.UnmarshalYAML", "decode all group", err)
|
||||||
|
}
|
||||||
|
root = group
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, value := range raw {
|
||||||
|
if name == "all" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "hosts", "children", "vars":
|
||||||
|
rootInput[name] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := decodeInventoryGroupValue(value)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("Inventory.UnmarshalYAML", "decode group "+name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.Children == nil {
|
||||||
|
root.Children = make(map[string]*InventoryGroup)
|
||||||
|
}
|
||||||
|
root.Children[name] = group
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rootInput) > 0 {
|
||||||
|
extra, err := decodeInventoryGroupValue(rootInput)
|
||||||
|
if err != nil {
|
||||||
|
return coreerr.E("Inventory.UnmarshalYAML", "decode root group", err)
|
||||||
|
}
|
||||||
|
mergeInventoryGroups(root, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
i.All = root
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeInventoryGroupValue(value any) (*InventoryGroup, error) {
|
||||||
|
if value == nil {
|
||||||
|
return &InventoryGroup{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := yaml.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var group InventoryGroup
|
||||||
|
if err := yaml.Unmarshal(data, &group); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeInventoryGroups(dst, src *InventoryGroup) {
|
||||||
|
if dst == nil || src == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dst.Hosts == nil && len(src.Hosts) > 0 {
|
||||||
|
dst.Hosts = make(map[string]*Host, len(src.Hosts))
|
||||||
|
}
|
||||||
|
for name, host := range src.Hosts {
|
||||||
|
dst.Hosts[name] = host
|
||||||
|
}
|
||||||
|
|
||||||
|
if dst.Children == nil && len(src.Children) > 0 {
|
||||||
|
dst.Children = make(map[string]*InventoryGroup, len(src.Children))
|
||||||
|
}
|
||||||
|
for name, child := range src.Children {
|
||||||
|
dst.Children[name] = child
|
||||||
|
}
|
||||||
|
|
||||||
|
if dst.Vars == nil && len(src.Vars) > 0 {
|
||||||
|
dst.Vars = make(map[string]any, len(src.Vars))
|
||||||
|
}
|
||||||
|
for key, value := range src.Vars {
|
||||||
|
dst.Vars[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// InventoryGroup represents a group in inventory.
|
// InventoryGroup represents a group in inventory.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// group := InventoryGroup{Hosts: map[string]*Host{"db1": {AnsibleHost: "10.0.1.10"}}}
|
||||||
type InventoryGroup struct {
|
type InventoryGroup struct {
|
||||||
Hosts map[string]*Host `yaml:"hosts,omitempty"`
|
Hosts map[string]*Host `yaml:"hosts,omitempty"`
|
||||||
Children map[string]*InventoryGroup `yaml:"children,omitempty"`
|
Children map[string]*InventoryGroup `yaml:"children,omitempty"`
|
||||||
|
|
@ -143,6 +346,10 @@ type InventoryGroup struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Host represents a host in inventory.
|
// Host represents a host in inventory.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// host := Host{AnsibleHost: "192.168.1.10", AnsibleUser: "deploy"}
|
||||||
type Host struct {
|
type Host struct {
|
||||||
AnsibleHost string `yaml:"ansible_host,omitempty"`
|
AnsibleHost string `yaml:"ansible_host,omitempty"`
|
||||||
AnsiblePort int `yaml:"ansible_port,omitempty"`
|
AnsiblePort int `yaml:"ansible_port,omitempty"`
|
||||||
|
|
@ -157,20 +364,32 @@ type Host struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Facts holds gathered facts about a host.
|
// Facts holds gathered facts about a host.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// facts := Facts{Hostname: "web1", Distribution: "Ubuntu", Kernel: "Linux"}
|
||||||
type Facts struct {
|
type Facts struct {
|
||||||
Hostname string `json:"ansible_hostname"`
|
Hostname string `json:"ansible_hostname"`
|
||||||
FQDN string `json:"ansible_fqdn"`
|
FQDN string `json:"ansible_fqdn"`
|
||||||
OS string `json:"ansible_os_family"`
|
OS string `json:"ansible_os_family"`
|
||||||
Distribution string `json:"ansible_distribution"`
|
Distribution string `json:"ansible_distribution"`
|
||||||
Version string `json:"ansible_distribution_version"`
|
Version string `json:"ansible_distribution_version"`
|
||||||
Architecture string `json:"ansible_architecture"`
|
Architecture string `json:"ansible_architecture"`
|
||||||
Kernel string `json:"ansible_kernel"`
|
Kernel string `json:"ansible_kernel"`
|
||||||
Memory int64 `json:"ansible_memtotal_mb"`
|
VirtualizationRole string `json:"ansible_virtualization_role"`
|
||||||
CPUs int `json:"ansible_processor_vcpus"`
|
VirtualizationType string `json:"ansible_virtualization_type"`
|
||||||
IPv4 string `json:"ansible_default_ipv4_address"`
|
Memory int64 `json:"ansible_memtotal_mb"`
|
||||||
|
CPUs int `json:"ansible_processor_vcpus"`
|
||||||
|
IPv4 string `json:"ansible_default_ipv4_address"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Known Ansible modules
|
// KnownModules lists the Ansible module names recognized by the parser.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if slices.Contains(KnownModules, "ansible.builtin.command") {
|
||||||
|
// // parser accepts command tasks
|
||||||
|
// }
|
||||||
var KnownModules = []string{
|
var KnownModules = []string{
|
||||||
// Builtin
|
// Builtin
|
||||||
"ansible.builtin.shell",
|
"ansible.builtin.shell",
|
||||||
|
|
@ -181,6 +400,7 @@ var KnownModules = []string{
|
||||||
"ansible.builtin.template",
|
"ansible.builtin.template",
|
||||||
"ansible.builtin.file",
|
"ansible.builtin.file",
|
||||||
"ansible.builtin.lineinfile",
|
"ansible.builtin.lineinfile",
|
||||||
|
"ansible.builtin.replace",
|
||||||
"ansible.builtin.blockinfile",
|
"ansible.builtin.blockinfile",
|
||||||
"ansible.builtin.stat",
|
"ansible.builtin.stat",
|
||||||
"ansible.builtin.slurp",
|
"ansible.builtin.slurp",
|
||||||
|
|
@ -192,6 +412,7 @@ var KnownModules = []string{
|
||||||
"ansible.builtin.apt_repository",
|
"ansible.builtin.apt_repository",
|
||||||
"ansible.builtin.yum",
|
"ansible.builtin.yum",
|
||||||
"ansible.builtin.dnf",
|
"ansible.builtin.dnf",
|
||||||
|
"ansible.builtin.rpm",
|
||||||
"ansible.builtin.package",
|
"ansible.builtin.package",
|
||||||
"ansible.builtin.pip",
|
"ansible.builtin.pip",
|
||||||
"ansible.builtin.service",
|
"ansible.builtin.service",
|
||||||
|
|
@ -205,14 +426,25 @@ var KnownModules = []string{
|
||||||
"ansible.builtin.debug",
|
"ansible.builtin.debug",
|
||||||
"ansible.builtin.fail",
|
"ansible.builtin.fail",
|
||||||
"ansible.builtin.assert",
|
"ansible.builtin.assert",
|
||||||
|
"ansible.builtin.ping",
|
||||||
"ansible.builtin.pause",
|
"ansible.builtin.pause",
|
||||||
"ansible.builtin.wait_for",
|
"ansible.builtin.wait_for",
|
||||||
|
"ansible.builtin.wait_for_connection",
|
||||||
"ansible.builtin.set_fact",
|
"ansible.builtin.set_fact",
|
||||||
"ansible.builtin.include_vars",
|
"ansible.builtin.include_vars",
|
||||||
"ansible.builtin.add_host",
|
"ansible.builtin.add_host",
|
||||||
"ansible.builtin.group_by",
|
"ansible.builtin.group_by",
|
||||||
"ansible.builtin.meta",
|
"ansible.builtin.meta",
|
||||||
"ansible.builtin.setup",
|
"ansible.builtin.setup",
|
||||||
|
"community.general.ufw",
|
||||||
|
"ansible.posix.authorized_key",
|
||||||
|
"ansible.builtin.docker_compose",
|
||||||
|
"ansible.builtin.docker_compose_v2",
|
||||||
|
"ansible.builtin.hostname",
|
||||||
|
"ansible.builtin.sysctl",
|
||||||
|
"ansible.builtin.reboot",
|
||||||
|
"community.docker.docker_compose",
|
||||||
|
"community.docker.docker_compose_v2",
|
||||||
|
|
||||||
// Short forms (legacy)
|
// Short forms (legacy)
|
||||||
"shell",
|
"shell",
|
||||||
|
|
@ -223,6 +455,7 @@ var KnownModules = []string{
|
||||||
"template",
|
"template",
|
||||||
"file",
|
"file",
|
||||||
"lineinfile",
|
"lineinfile",
|
||||||
|
"replace",
|
||||||
"blockinfile",
|
"blockinfile",
|
||||||
"stat",
|
"stat",
|
||||||
"slurp",
|
"slurp",
|
||||||
|
|
@ -234,6 +467,7 @@ var KnownModules = []string{
|
||||||
"apt_repository",
|
"apt_repository",
|
||||||
"yum",
|
"yum",
|
||||||
"dnf",
|
"dnf",
|
||||||
|
"rpm",
|
||||||
"package",
|
"package",
|
||||||
"pip",
|
"pip",
|
||||||
"service",
|
"service",
|
||||||
|
|
@ -247,12 +481,39 @@ var KnownModules = []string{
|
||||||
"debug",
|
"debug",
|
||||||
"fail",
|
"fail",
|
||||||
"assert",
|
"assert",
|
||||||
|
"ping",
|
||||||
"pause",
|
"pause",
|
||||||
"wait_for",
|
"wait_for",
|
||||||
|
"wait_for_connection",
|
||||||
"set_fact",
|
"set_fact",
|
||||||
"include_vars",
|
"include_vars",
|
||||||
"add_host",
|
"add_host",
|
||||||
"group_by",
|
"group_by",
|
||||||
"meta",
|
"meta",
|
||||||
"setup",
|
"setup",
|
||||||
|
"hostname",
|
||||||
|
"sysctl",
|
||||||
|
"reboot",
|
||||||
|
"authorized_key",
|
||||||
|
"ufw",
|
||||||
|
"docker_compose",
|
||||||
|
"docker_compose_v2",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleAliases maps accepted short-form module names to their canonical
|
||||||
|
// fully-qualified collection names.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// module := ModuleAliases["ansible.builtin.authorized_key"]
|
||||||
|
var ModuleAliases = map[string]string{
|
||||||
|
"authorized_key": "ansible.posix.authorized_key",
|
||||||
|
"ansible.builtin.authorized_key": "ansible.posix.authorized_key",
|
||||||
|
"ufw": "community.general.ufw",
|
||||||
|
"ansible.builtin.ufw": "community.general.ufw",
|
||||||
|
"docker_compose": "community.docker.docker_compose",
|
||||||
|
"docker_compose_v2": "community.docker.docker_compose_v2",
|
||||||
|
"ansible.builtin.docker_compose": "community.docker.docker_compose",
|
||||||
|
"ansible.builtin.docker_compose_v2": "community.docker.docker_compose_v2",
|
||||||
|
"rpm": "ansible.builtin.rpm",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
673
types_test.go
673
types_test.go
|
|
@ -10,7 +10,7 @@ import (
|
||||||
|
|
||||||
// --- RoleRef UnmarshalYAML ---
|
// --- RoleRef UnmarshalYAML ---
|
||||||
|
|
||||||
func TestRoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
|
func TestTypes_RoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
|
||||||
input := `common`
|
input := `common`
|
||||||
var ref RoleRef
|
var ref RoleRef
|
||||||
err := yaml.Unmarshal([]byte(input), &ref)
|
err := yaml.Unmarshal([]byte(input), &ref)
|
||||||
|
|
@ -19,7 +19,7 @@ func TestRoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
|
||||||
assert.Equal(t, "common", ref.Role)
|
assert.Equal(t, "common", ref.Role)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
|
func TestTypes_RoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
role: webserver
|
role: webserver
|
||||||
vars:
|
vars:
|
||||||
|
|
@ -36,7 +36,7 @@ tags:
|
||||||
assert.Equal(t, []string{"web"}, ref.Tags)
|
assert.Equal(t, []string{"web"}, ref.Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRoleRef_UnmarshalYAML_Good_NameField(t *testing.T) {
|
func TestTypes_RoleRef_UnmarshalYAML_Good_NameField(t *testing.T) {
|
||||||
// Some playbooks use "name:" instead of "role:"
|
// Some playbooks use "name:" instead of "role:"
|
||||||
input := `
|
input := `
|
||||||
name: myapp
|
name: myapp
|
||||||
|
|
@ -50,7 +50,7 @@ tasks_from: install.yml
|
||||||
assert.Equal(t, "install.yml", ref.TasksFrom)
|
assert.Equal(t, "install.yml", ref.TasksFrom)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRoleRef_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
func TestTypes_RoleRef_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
role: conditional_role
|
role: conditional_role
|
||||||
when: ansible_os_family == "Debian"
|
when: ansible_os_family == "Debian"
|
||||||
|
|
@ -63,9 +63,28 @@ when: ansible_os_family == "Debian"
|
||||||
assert.NotNil(t, ref.When)
|
assert.NotNil(t, ref.When)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTypes_RoleRef_UnmarshalYAML_Good_CustomRoleFiles(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: web
|
||||||
|
tasks_from: setup.yml
|
||||||
|
defaults_from: custom-defaults.yml
|
||||||
|
vars_from: custom-vars.yml
|
||||||
|
public: true
|
||||||
|
`
|
||||||
|
var ref RoleRef
|
||||||
|
err := yaml.Unmarshal([]byte(input), &ref)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "web", ref.Role)
|
||||||
|
assert.Equal(t, "setup.yml", ref.TasksFrom)
|
||||||
|
assert.Equal(t, "custom-defaults.yml", ref.DefaultsFrom)
|
||||||
|
assert.Equal(t, "custom-vars.yml", ref.VarsFrom)
|
||||||
|
assert.True(t, ref.Public)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Task UnmarshalYAML ---
|
// --- Task UnmarshalYAML ---
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Install nginx
|
name: Install nginx
|
||||||
apt:
|
apt:
|
||||||
|
|
@ -82,7 +101,7 @@ apt:
|
||||||
assert.Equal(t, "present", task.Args["state"])
|
assert.Equal(t, "present", task.Args["state"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_FreeFormModule(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_FreeFormModule(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Run command
|
name: Run command
|
||||||
shell: echo hello world
|
shell: echo hello world
|
||||||
|
|
@ -95,7 +114,7 @@ shell: echo hello world
|
||||||
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_ModuleNoArgs(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_ModuleNoArgs(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Gather facts
|
name: Gather facts
|
||||||
setup:
|
setup:
|
||||||
|
|
@ -108,7 +127,7 @@ setup:
|
||||||
assert.NotNil(t, task.Args)
|
assert.NotNil(t, task.Args)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_WithRegister(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_WithRegister(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Check file
|
name: Check file
|
||||||
stat:
|
stat:
|
||||||
|
|
@ -123,7 +142,7 @@ register: stat_result
|
||||||
assert.Equal(t, "stat", task.Module)
|
assert.Equal(t, "stat", task.Module)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_WithWhen(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Conditional task
|
name: Conditional task
|
||||||
debug:
|
debug:
|
||||||
|
|
@ -137,7 +156,24 @@ when: some_var is defined
|
||||||
assert.NotNil(t, task.When)
|
assert.NotNil(t, task.When)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_WithLoop(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_WithCheckModeAndDiff(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Force a dry run
|
||||||
|
shell: echo hello
|
||||||
|
check_mode: false
|
||||||
|
diff: true
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.CheckMode)
|
||||||
|
require.NotNil(t, task.Diff)
|
||||||
|
assert.False(t, *task.CheckMode)
|
||||||
|
assert.True(t, *task.Diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithLoop(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Install packages
|
name: Install packages
|
||||||
apt:
|
apt:
|
||||||
|
|
@ -156,7 +192,7 @@ loop:
|
||||||
assert.Len(t, items, 3)
|
assert.Len(t, items, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_WithItems(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_WithItems(t *testing.T) {
|
||||||
// with_items should be converted to loop
|
// with_items should be converted to loop
|
||||||
input := `
|
input := `
|
||||||
name: Old-style loop
|
name: Old-style loop
|
||||||
|
|
@ -176,7 +212,324 @@ with_items:
|
||||||
assert.Len(t, items, 2)
|
assert.Len(t, items, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_WithNotify(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_WithDict(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Old-style dict loop
|
||||||
|
debug:
|
||||||
|
msg: "{{ item.key }}={{ item.value }}"
|
||||||
|
with_dict:
|
||||||
|
alpha: one
|
||||||
|
beta: two
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
items, ok := task.Loop.([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, items, 2)
|
||||||
|
|
||||||
|
first, ok := items[0].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "alpha", first["key"])
|
||||||
|
assert.Equal(t, "one", first["value"])
|
||||||
|
|
||||||
|
second, ok := items[1].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "beta", second["key"])
|
||||||
|
assert.Equal(t, "two", second["value"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithIndexedItems(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Indexed loop
|
||||||
|
debug:
|
||||||
|
msg: "{{ item.0 }}={{ item.1 }}"
|
||||||
|
with_indexed_items:
|
||||||
|
- apple
|
||||||
|
- banana
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
items, ok := task.Loop.([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, items, 2)
|
||||||
|
|
||||||
|
first, ok := items[0].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 0, first[0])
|
||||||
|
assert.Equal(t, "apple", first[1])
|
||||||
|
|
||||||
|
second, ok := items[1].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 1, second[0])
|
||||||
|
assert.Equal(t, "banana", second[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithFile(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Read files
|
||||||
|
debug:
|
||||||
|
msg: "{{ item }}"
|
||||||
|
with_file:
|
||||||
|
- templates/a.txt
|
||||||
|
- templates/b.txt
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.WithFile)
|
||||||
|
files, ok := task.WithFile.([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, []any{"templates/a.txt", "templates/b.txt"}, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithFileGlob(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Read globbed files
|
||||||
|
debug:
|
||||||
|
msg: "{{ item }}"
|
||||||
|
with_fileglob:
|
||||||
|
- templates/*.txt
|
||||||
|
- files/*.yml
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.WithFileGlob)
|
||||||
|
files, ok := task.WithFileGlob.([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, []any{"templates/*.txt", "files/*.yml"}, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithSequence(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Read sequence values
|
||||||
|
debug:
|
||||||
|
msg: "{{ item }}"
|
||||||
|
with_sequence: "start=1 end=3 format=%02d"
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.WithSequence)
|
||||||
|
sequence, ok := task.WithSequence.(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "start=1 end=3 format=%02d", sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_ActionAlias(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Legacy action
|
||||||
|
action: command echo hello world
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "command", task.Module)
|
||||||
|
require.NotNil(t, task.Args)
|
||||||
|
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_ActionAliasFQCN(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Legacy action
|
||||||
|
ansible.builtin.action: command echo hello world
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "command", task.Module)
|
||||||
|
require.NotNil(t, task.Args)
|
||||||
|
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_ActionAliasKeyValue(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Legacy action with args
|
||||||
|
action: module=copy src=/tmp/source dest=/tmp/dest mode=0644
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "copy", task.Module)
|
||||||
|
require.NotNil(t, task.Args)
|
||||||
|
assert.Equal(t, "/tmp/source", task.Args["src"])
|
||||||
|
assert.Equal(t, "/tmp/dest", task.Args["dest"])
|
||||||
|
assert.Equal(t, "0644", task.Args["mode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_ActionAliasMixedArgs(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Legacy action with mixed args
|
||||||
|
action: command chdir=/tmp echo hello world
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "command", task.Module)
|
||||||
|
require.NotNil(t, task.Args)
|
||||||
|
assert.Equal(t, "/tmp", task.Args["chdir"])
|
||||||
|
assert.Equal(t, "echo hello world", task.Args["_raw_params"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_LocalAction(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Legacy local action
|
||||||
|
local_action: shell echo local
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "shell", task.Module)
|
||||||
|
assert.Equal(t, "localhost", task.Delegate)
|
||||||
|
require.NotNil(t, task.Args)
|
||||||
|
assert.Equal(t, "echo local", task.Args["_raw_params"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_LocalActionFQCN(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Legacy local action
|
||||||
|
ansible.legacy.local_action: shell echo local
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "shell", task.Module)
|
||||||
|
assert.Equal(t, "localhost", task.Delegate)
|
||||||
|
require.NotNil(t, task.Args)
|
||||||
|
assert.Equal(t, "echo local", task.Args["_raw_params"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_LocalActionKeyValue(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Legacy local action with args
|
||||||
|
local_action: module=command chdir=/tmp
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "command", task.Module)
|
||||||
|
assert.Equal(t, "localhost", task.Delegate)
|
||||||
|
require.NotNil(t, task.Args)
|
||||||
|
assert.Equal(t, "/tmp", task.Args["chdir"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_LocalActionMixedArgs(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Legacy local action with mixed args
|
||||||
|
local_action: command chdir=/var/tmp echo local
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "command", task.Module)
|
||||||
|
assert.Equal(t, "localhost", task.Delegate)
|
||||||
|
require.NotNil(t, task.Args)
|
||||||
|
assert.Equal(t, "/var/tmp", task.Args["chdir"])
|
||||||
|
assert.Equal(t, "echo local", task.Args["_raw_params"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithNested(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Nested loop values
|
||||||
|
debug:
|
||||||
|
msg: "{{ item.0 }} {{ item.1 }}"
|
||||||
|
with_nested:
|
||||||
|
- - red
|
||||||
|
- blue
|
||||||
|
- - small
|
||||||
|
- large
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
items, ok := task.Loop.([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, items, 4)
|
||||||
|
|
||||||
|
first, ok := items[0].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, []any{"red", "small"}, first)
|
||||||
|
|
||||||
|
second, ok := items[1].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, []any{"red", "large"}, second)
|
||||||
|
|
||||||
|
third, ok := items[2].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, []any{"blue", "small"}, third)
|
||||||
|
|
||||||
|
fourth, ok := items[3].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, []any{"blue", "large"}, fourth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithTogether(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Together loop values
|
||||||
|
debug:
|
||||||
|
msg: "{{ item.0 }} {{ item.1 }}"
|
||||||
|
with_together:
|
||||||
|
- - red
|
||||||
|
- blue
|
||||||
|
- - small
|
||||||
|
- large
|
||||||
|
- medium
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.WithTogether)
|
||||||
|
|
||||||
|
items, ok := task.Loop.([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, items, 2)
|
||||||
|
|
||||||
|
first, ok := items[0].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, []any{"red", "small"}, first)
|
||||||
|
|
||||||
|
second, ok := items[1].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, []any{"blue", "large"}, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithSubelements(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Subelement loop values
|
||||||
|
debug:
|
||||||
|
msg: "{{ item.0.name }} {{ item.1 }}"
|
||||||
|
with_subelements:
|
||||||
|
- users
|
||||||
|
- authorized
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.WithSubelements)
|
||||||
|
values, ok := task.WithSubelements.([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, []any{"users", "authorized"}, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithNotify(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Install package
|
name: Install package
|
||||||
apt:
|
apt:
|
||||||
|
|
@ -190,7 +543,116 @@ notify: restart nginx
|
||||||
assert.Equal(t, "restart nginx", task.Notify)
|
assert.Equal(t, "restart nginx", task.Notify)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_WithNotifyList(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_WithListen(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Restart service
|
||||||
|
debug:
|
||||||
|
msg: "handler"
|
||||||
|
listen: reload nginx
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "reload nginx", task.Listen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_ShortFormSystemModules(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantModule string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "hostname",
|
||||||
|
input: `
|
||||||
|
name: Set hostname
|
||||||
|
hostname:
|
||||||
|
name: web01
|
||||||
|
`,
|
||||||
|
wantModule: "hostname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sysctl",
|
||||||
|
input: `
|
||||||
|
name: Tune kernel
|
||||||
|
sysctl:
|
||||||
|
name: net.ipv4.ip_forward
|
||||||
|
value: "1"
|
||||||
|
`,
|
||||||
|
wantModule: "sysctl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reboot",
|
||||||
|
input: `
|
||||||
|
name: Reboot host
|
||||||
|
reboot:
|
||||||
|
`,
|
||||||
|
wantModule: "reboot",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(tc.input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.wantModule, task.Module)
|
||||||
|
assert.NotNil(t, task.Args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_ShortFormCommunityModules(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantModule string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "authorized_key",
|
||||||
|
input: `
|
||||||
|
name: Install SSH key
|
||||||
|
authorized_key:
|
||||||
|
user: deploy
|
||||||
|
key: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD
|
||||||
|
`,
|
||||||
|
wantModule: "authorized_key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ufw",
|
||||||
|
input: `
|
||||||
|
name: Allow SSH
|
||||||
|
ufw:
|
||||||
|
rule: allow
|
||||||
|
port: "22"
|
||||||
|
`,
|
||||||
|
wantModule: "ufw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "docker_compose",
|
||||||
|
input: `
|
||||||
|
name: Start stack
|
||||||
|
docker_compose:
|
||||||
|
project_src: /opt/app
|
||||||
|
`,
|
||||||
|
wantModule: "docker_compose",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(tc.input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.wantModule, task.Module)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_WithNotifyList(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Install package
|
name: Install package
|
||||||
apt:
|
apt:
|
||||||
|
|
@ -208,7 +670,7 @@ notify:
|
||||||
assert.Len(t, notifyList, 2)
|
assert.Len(t, notifyList, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Include tasks
|
name: Include tasks
|
||||||
include_tasks: other-tasks.yml
|
include_tasks: other-tasks.yml
|
||||||
|
|
@ -220,12 +682,76 @@ include_tasks: other-tasks.yml
|
||||||
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
|
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_IncludeRole(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasksFQCN(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Include tasks
|
||||||
|
ansible.builtin.include_tasks: other-tasks.yml
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "other-tasks.yml", task.IncludeTasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasksApply(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Include tasks
|
||||||
|
include_tasks: other-tasks.yml
|
||||||
|
apply:
|
||||||
|
tags:
|
||||||
|
- deploy
|
||||||
|
become: true
|
||||||
|
become_user: root
|
||||||
|
delegate_facts: true
|
||||||
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.Apply)
|
||||||
|
assert.Equal(t, []string{"deploy"}, task.Apply.Tags)
|
||||||
|
require.NotNil(t, task.Apply.Become)
|
||||||
|
assert.True(t, *task.Apply.Become)
|
||||||
|
assert.Equal(t, "root", task.Apply.BecomeUser)
|
||||||
|
assert.True(t, task.Apply.DelegateFacts)
|
||||||
|
assert.Equal(t, "production", task.Apply.Environment["APP_ENV"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_DelegateFacts(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Gather delegated facts
|
||||||
|
delegate_to: delegate1
|
||||||
|
delegate_facts: true
|
||||||
|
setup:
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, task.DelegateFacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_IncludeRole(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Include role
|
name: Include role
|
||||||
include_role:
|
include_role:
|
||||||
name: common
|
name: common
|
||||||
tasks_from: setup.yml
|
tasks_from: setup.yml
|
||||||
|
defaults_from: defaults.yml
|
||||||
|
vars_from: vars.yml
|
||||||
|
handlers_from: handlers.yml
|
||||||
|
public: true
|
||||||
|
apply:
|
||||||
|
tags:
|
||||||
|
- deploy
|
||||||
|
when: apply_enabled
|
||||||
|
become: true
|
||||||
|
become_user: root
|
||||||
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
`
|
`
|
||||||
var task Task
|
var task Task
|
||||||
err := yaml.Unmarshal([]byte(input), &task)
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
@ -234,9 +760,75 @@ include_role:
|
||||||
require.NotNil(t, task.IncludeRole)
|
require.NotNil(t, task.IncludeRole)
|
||||||
assert.Equal(t, "common", task.IncludeRole.Name)
|
assert.Equal(t, "common", task.IncludeRole.Name)
|
||||||
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
|
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
|
||||||
|
assert.Equal(t, "defaults.yml", task.IncludeRole.DefaultsFrom)
|
||||||
|
assert.Equal(t, "vars.yml", task.IncludeRole.VarsFrom)
|
||||||
|
assert.Equal(t, "handlers.yml", task.IncludeRole.HandlersFrom)
|
||||||
|
assert.True(t, task.IncludeRole.Public)
|
||||||
|
require.NotNil(t, task.IncludeRole.Apply)
|
||||||
|
assert.Equal(t, []string{"deploy"}, task.IncludeRole.Apply.Tags)
|
||||||
|
assert.Equal(t, "apply_enabled", task.IncludeRole.Apply.When)
|
||||||
|
require.NotNil(t, task.IncludeRole.Apply.Become)
|
||||||
|
assert.True(t, *task.IncludeRole.Apply.Become)
|
||||||
|
assert.Equal(t, "root", task.IncludeRole.Apply.BecomeUser)
|
||||||
|
assert.Equal(t, "production", task.IncludeRole.Apply.Environment["APP_ENV"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_BecomeFields(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_IncludeRoleStringForm(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Include role
|
||||||
|
include_role: common
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.IncludeRole)
|
||||||
|
assert.Equal(t, "common", task.IncludeRole.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_IncludeRoleFQCN(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Include role
|
||||||
|
ansible.builtin.include_role:
|
||||||
|
name: common
|
||||||
|
tasks_from: setup.yml
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.IncludeRole)
|
||||||
|
assert.Equal(t, "common", task.IncludeRole.Role)
|
||||||
|
assert.Equal(t, "setup.yml", task.IncludeRole.TasksFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_ImportRoleStringForm(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Import role
|
||||||
|
import_role: common
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.ImportRole)
|
||||||
|
assert.Equal(t, "common", task.ImportRole.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_ImportRoleFQCN(t *testing.T) {
|
||||||
|
input := `
|
||||||
|
name: Import role
|
||||||
|
ansible.builtin.import_role: common
|
||||||
|
`
|
||||||
|
var task Task
|
||||||
|
err := yaml.Unmarshal([]byte(input), &task)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, task.ImportRole)
|
||||||
|
assert.Equal(t, "common", task.ImportRole.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypes_Task_UnmarshalYAML_Good_BecomeFields(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Privileged task
|
name: Privileged task
|
||||||
shell: systemctl restart nginx
|
shell: systemctl restart nginx
|
||||||
|
|
@ -252,7 +844,7 @@ become_user: root
|
||||||
assert.Equal(t, "root", task.BecomeUser)
|
assert.Equal(t, "root", task.BecomeUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTask_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
|
func TestTypes_Task_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
name: Might fail
|
name: Might fail
|
||||||
shell: some risky command
|
shell: some risky command
|
||||||
|
|
@ -267,7 +859,7 @@ ignore_errors: true
|
||||||
|
|
||||||
// --- Inventory data structure ---
|
// --- Inventory data structure ---
|
||||||
|
|
||||||
func TestInventory_UnmarshalYAML_Good_Complex(t *testing.T) {
|
func TestTypes_Inventory_UnmarshalYAML_Good_Complex(t *testing.T) {
|
||||||
input := `
|
input := `
|
||||||
all:
|
all:
|
||||||
vars:
|
vars:
|
||||||
|
|
@ -317,31 +909,35 @@ all:
|
||||||
|
|
||||||
// --- Facts ---
|
// --- Facts ---
|
||||||
|
|
||||||
func TestFacts_Struct(t *testing.T) {
|
func TestTypes_Facts_Good_Struct(t *testing.T) {
|
||||||
facts := Facts{
|
facts := Facts{
|
||||||
Hostname: "web1",
|
Hostname: "web1",
|
||||||
FQDN: "web1.example.com",
|
FQDN: "web1.example.com",
|
||||||
OS: "Debian",
|
OS: "Debian",
|
||||||
Distribution: "ubuntu",
|
Distribution: "ubuntu",
|
||||||
Version: "24.04",
|
Version: "24.04",
|
||||||
Architecture: "x86_64",
|
Architecture: "x86_64",
|
||||||
Kernel: "6.8.0",
|
Kernel: "6.8.0",
|
||||||
Memory: 16384,
|
VirtualizationRole: "guest",
|
||||||
CPUs: 4,
|
VirtualizationType: "docker",
|
||||||
IPv4: "10.0.0.1",
|
Memory: 16384,
|
||||||
|
CPUs: 4,
|
||||||
|
IPv4: "10.0.0.1",
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, "web1", facts.Hostname)
|
assert.Equal(t, "web1", facts.Hostname)
|
||||||
assert.Equal(t, "web1.example.com", facts.FQDN)
|
assert.Equal(t, "web1.example.com", facts.FQDN)
|
||||||
assert.Equal(t, "ubuntu", facts.Distribution)
|
assert.Equal(t, "ubuntu", facts.Distribution)
|
||||||
assert.Equal(t, "x86_64", facts.Architecture)
|
assert.Equal(t, "x86_64", facts.Architecture)
|
||||||
|
assert.Equal(t, "guest", facts.VirtualizationRole)
|
||||||
|
assert.Equal(t, "docker", facts.VirtualizationType)
|
||||||
assert.Equal(t, int64(16384), facts.Memory)
|
assert.Equal(t, int64(16384), facts.Memory)
|
||||||
assert.Equal(t, 4, facts.CPUs)
|
assert.Equal(t, 4, facts.CPUs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TaskResult ---
|
// --- TaskResult ---
|
||||||
|
|
||||||
func TestTaskResult_Struct(t *testing.T) {
|
func TestTypes_TaskResult_Good_Struct(t *testing.T) {
|
||||||
result := TaskResult{
|
result := TaskResult{
|
||||||
Changed: true,
|
Changed: true,
|
||||||
Failed: false,
|
Failed: false,
|
||||||
|
|
@ -358,7 +954,7 @@ func TestTaskResult_Struct(t *testing.T) {
|
||||||
assert.Equal(t, 0, result.RC)
|
assert.Equal(t, 0, result.RC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskResult_WithLoopResults(t *testing.T) {
|
func TestTypes_TaskResult_Good_WithLoopResults(t *testing.T) {
|
||||||
result := TaskResult{
|
result := TaskResult{
|
||||||
Changed: true,
|
Changed: true,
|
||||||
Results: []TaskResult{
|
Results: []TaskResult{
|
||||||
|
|
@ -375,7 +971,7 @@ func TestTaskResult_WithLoopResults(t *testing.T) {
|
||||||
|
|
||||||
// --- KnownModules ---
|
// --- KnownModules ---
|
||||||
|
|
||||||
func TestKnownModules_ContainsExpected(t *testing.T) {
|
func TestTypes_KnownModules_Good_ContainsExpected(t *testing.T) {
|
||||||
// Verify both FQCN and short forms are present
|
// Verify both FQCN and short forms are present
|
||||||
fqcnModules := []string{
|
fqcnModules := []string{
|
||||||
"ansible.builtin.shell",
|
"ansible.builtin.shell",
|
||||||
|
|
@ -385,8 +981,19 @@ func TestKnownModules_ContainsExpected(t *testing.T) {
|
||||||
"ansible.builtin.apt",
|
"ansible.builtin.apt",
|
||||||
"ansible.builtin.service",
|
"ansible.builtin.service",
|
||||||
"ansible.builtin.systemd",
|
"ansible.builtin.systemd",
|
||||||
|
"ansible.builtin.rpm",
|
||||||
"ansible.builtin.debug",
|
"ansible.builtin.debug",
|
||||||
"ansible.builtin.set_fact",
|
"ansible.builtin.set_fact",
|
||||||
|
"ansible.builtin.ping",
|
||||||
|
"community.general.ufw",
|
||||||
|
"ansible.posix.authorized_key",
|
||||||
|
"ansible.builtin.docker_compose",
|
||||||
|
"ansible.builtin.docker_compose_v2",
|
||||||
|
"ansible.builtin.hostname",
|
||||||
|
"ansible.builtin.sysctl",
|
||||||
|
"ansible.builtin.reboot",
|
||||||
|
"community.docker.docker_compose",
|
||||||
|
"community.docker.docker_compose_v2",
|
||||||
}
|
}
|
||||||
for _, mod := range fqcnModules {
|
for _, mod := range fqcnModules {
|
||||||
assert.Contains(t, KnownModules, mod, "expected FQCN module %s", mod)
|
assert.Contains(t, KnownModules, mod, "expected FQCN module %s", mod)
|
||||||
|
|
@ -394,7 +1001,7 @@ func TestKnownModules_ContainsExpected(t *testing.T) {
|
||||||
|
|
||||||
shortModules := []string{
|
shortModules := []string{
|
||||||
"shell", "command", "copy", "file", "apt", "service",
|
"shell", "command", "copy", "file", "apt", "service",
|
||||||
"systemd", "debug", "set_fact", "template", "user", "group",
|
"systemd", "rpm", "debug", "set_fact", "ping", "template", "user", "group",
|
||||||
}
|
}
|
||||||
for _, mod := range shortModules {
|
for _, mod := range shortModules {
|
||||||
assert.Contains(t, KnownModules, mod, "expected short-form module %s", mod)
|
assert.Contains(t, KnownModules, mod, "expected short-form module %s", mod)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue