Compare commits

...

260 commits
v0.2.0 ... dev

Author SHA1 Message Date
Snider
ef0dbdb824 fix: tidy deps after dappco.re migration
Some checks failed
CI / test (push) Has been cancelled
CI / auto-fix (push) Has been cancelled
CI / auto-merge (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:25:03 +01:00
Snider
9013a33382 fix: migrate module paths from forge.lthn.ai to dappco.re
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:11 +01:00
Virgil
a80daf5494 fix(ansible): scope playbook_dir for nested imports
Some checks are pending
CI / auto-merge (push) Waiting to run
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
2026-04-03 16:10:15 +00:00
Virgil
b91ad5d485 feat(ansible): support wait_for sleep
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 16:06:00 +00:00
Virgil
56d532885d feat(ansible): add virtual setup facts support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:40:59 +00:00
Virgil
d34aed9feb feat(ansible): load extensionless include_vars files
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 15:34:05 +00:00
Virgil
29b433fdbf feat(ansible): preserve blockinfile backups on remove
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:29:46 +00:00
Virgil
508248a722 feat(ansible): support blockinfile marker bounds
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:24:49 +00:00
Virgil
65917f028c feat(ansible): support proxy disabling for uri and get_url
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:24:33 +00:00
Virgil
79c01ab325 feat(ansible): skip get_url when destination exists
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:18:30 +00:00
Virgil
83c0bfe52a feat(ansible): add multipart uri body support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:15:22 +00:00
Virgil
f637f0df98 feat(ansible): support uri src request bodies
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:13:22 +00:00
Virgil
9b7f411763 feat(ansible): add uri unix socket support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:10:40 +00:00
Virgil
12b0ed95a8 feat(ansible): support get_url checksum file URLs
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:08:07 +00:00
Virgil
37ae077d85 feat(ansible): expand get_url checksum support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:04:30 +00:00
Virgil
bff80d44d6 feat(ansible): add checksum validation to get_url
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 15:01:00 +00:00
Virgil
8b1876b615 feat(ansible): support uri follow_redirects
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:58:15 +00:00
Virgil
56457c6f42 feat(ansible): refine AX docs and naming
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:55:15 +00:00
Virgil
c276c343bc feat(ansible): support seeded password lookups
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:51:44 +00:00
Virgil
28ef1f3d85 feat(ansible): add lineinfile search_string support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:48:28 +00:00
Virgil
92eaab75ba feat(ansible): support wait_for_connection connect_timeout
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:43:54 +00:00
Virgil
a2b7cfe228 feat(ansible): add wait_for_connection module
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:40:29 +00:00
Virgil
a0a9e832ee feat(ansible): add first_found lookup support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 14:36:58 +00:00
Virgil
b5bfe4a875 feat(ansible): add cron backup support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:31:22 +00:00
Virgil
821211e671 feat(ansible): recurse file module group and mode
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:27:30 +00:00
Virgil
1e5bdc08dd feat(ansible): add lineinfile backup support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:24:26 +00:00
Virgil
3eca6b15cb feat(ansible): add template lookup support
Some checks are pending
CI / auto-merge (push) Waiting to run
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:19:16 +00:00
Virgil
bae7fa0a39 feat(ansible): support hard file links
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:15:47 +00:00
Virgil
cd0d258768 feat(ansible): support authorized key options
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:11:57 +00:00
Virgil
153bf5b863 feat(ansible): support password lookups
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:07:04 +00:00
Virgil
1c637a2199 feat(ansible): support fileglob lookups
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:03:50 +00:00
Virgil
470592e253 feat(ansible): support pipe lookups
Some checks are pending
CI / auto-merge (push) Waiting to run
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 14:00:44 +00:00
Virgil
541d16b5a6 feat(ansible): support uri basic auth flags
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 13:56:25 +00:00
Virgil
2927fb4c78 feat(ansible): support cron special_time schedules
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 13:51:58 +00:00
Virgil
fd6b8b0d2f feat(ansible): expose ansible_limit magic var
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:48:15 +00:00
Virgil
0e3c126723 feat(ansible): expose play name magic var
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:45:43 +00:00
Virgil
3f601ff7b5 feat(ansible): expose play magic vars
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:43:15 +00:00
Virgil
80fb75baab feat(ansible): honour task check mode overrides
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:38:19 +00:00
Virgil
1b13b33821 feat(ansible): support sysctl ignoreerrors
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:33:51 +00:00
Virgil
5609471945 Add replace module support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:31:06 +00:00
Virgil
cffc35a973 Expose check and diff mode magic vars
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:26:38 +00:00
Virgil
dac108cab5 feat(ansible): support local user management
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:23:29 +00:00
Virgil
65cd1b9e01 refactor(ansible): clarify CLI helper naming
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 13:18:36 +00:00
Virgil
2dc29d1592 feat(ansible): support group local mode
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:15:48 +00:00
Virgil
472c45ba85 feat(ansible): support user group append
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:12:51 +00:00
Virgil
031e41be19 Add play module defaults support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:08:52 +00:00
Virgil
bbe110c1c0 feat(ansible): accept hostname alias
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:04:55 +00:00
Virgil
17691b9ff0 feat(ansible): support custom reboot commands
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 13:02:11 +00:00
Virgil
b75ba32cc2 Add shell executable support
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:58:03 +00:00
Virgil
4ccb8fc93b feat(ansible): support delegated facts
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:54:45 +00:00
Virgil
9dfd5b3af1 feat(ansible): support ufw rule deletion
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:50:19 +00:00
Virgil
1e7deda933 feat(ansible): honour setup gather_timeout
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:47:29 +00:00
Virgil
d8798ec56f feat(ansible): return include_vars file list
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 12:44:24 +00:00
Virgil
7833c872a4 Support default filters in loop expressions
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:40:33 +00:00
Virgil
71a50b0d2b Add inventory and role magic vars
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:33:43 +00:00
Virgil
5420321e22 fix(ansible): expose set_fact values via ansible_facts
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 12:29:11 +00:00
Virgil
1e99665f6e Tighten structured module results
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:24:19 +00:00
Virgil
7cbb53dbc8 refactor(ansible): document local client constructor
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:19:47 +00:00
Virgil
dcddb0b510 test(ansible): cover executor run entrypoint
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:16:02 +00:00
Virgil
e95c35c097 refactor(ansible): align AX naming examples
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:12:40 +00:00
Virgil
ac45fd9830 Support compose project flags
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:07:24 +00:00
Virgil
251206748a Add automatic playbook_dir handling
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 12:02:21 +00:00
Virgil
4b3cfbef8d Support mixed legacy action syntax
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:57:28 +00:00
Virgil
c70f02cb09 fix(ansible): merge repeated list flags
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:52:36 +00:00
Virgil
22e689bbfa fix(ansible): fail fast on reboot initiation errors
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:49:21 +00:00
Virgil
610294db6b feat(ansible): type extra vars scalars
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:44:56 +00:00
Virgil
82c7c73d50 fix(ansible): clear become state when disabled
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 11:41:04 +00:00
Virgil
fe0ed9b2ee feat(ansible): add diff output for file edits
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:35:42 +00:00
Virgil
324411bb95 fix(ansible): include diff path in CLI output
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:30:48 +00:00
Virgil
e5b891e7d7 fix(ansible): trim comma-separated CLI inputs
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:28:08 +00:00
Virgil
4d1e46b933 Refine ansible CLI option handling
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:24:10 +00:00
Virgil
909aac859a feat(ansible): support list-valued package targets
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:19:59 +00:00
Virgil
2b32f453db Fix block inheritance in executor
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:14:37 +00:00
Virgil
8ebfafd6cc refactor(ansible): rename cli helpers for clarity
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 11:11:17 +00:00
Virgil
103b5ed255 fix(ansible): propagate rescue block failures
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 11:07:17 +00:00
Virgil
c65ca1cfd9 Improve templating filter chaining
Some checks are pending
CI / auto-merge (push) Waiting to run
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
2026-04-03 11:03:04 +00:00
Virgil
c52d539d3c feat(parser): support FQCN action directives
Some checks are pending
CI / auto-merge (push) Waiting to run
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 10:59:46 +00:00
Virgil
78eac4e8f2 docs(ansible): add AX usage examples
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 10:56:05 +00:00
Virgil
762a47f11f feat(ansible): tighten file module idempotency
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:51:55 +00:00
Virgil
dc89e88e00 feat(ansible): make hostname idempotent
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:47:35 +00:00
Virgil
70ec0dbba4 feat(ansible): make add_host idempotent
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:43:56 +00:00
Virgil
8f6bd48cf8 feat(ansible): support meta end_role
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:39:45 +00:00
Virgil
7d71ff21a4 Tighten host limit matching
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:35:49 +00:00
Virgil
1d90b93f5b Fix legacy loop unmarshalling
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:31:25 +00:00
Virgil
2edc43b3fb fix(ansible): re-evaluate imported task when clauses
Some checks are pending
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
CI / test (push) Waiting to run
2026-04-03 10:28:45 +00:00
Virgil
a87899c2d4 Add sysctl_file support to sysctl module
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:22:09 +00:00
Virgil
0cb9cc5b28 Fix play-scoped vars and loop when conditions
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:17:08 +00:00
Virgil
e8a58e26ba Implement any_errors_fatal play handling
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:07:58 +00:00
Virgil
dd9ccc777c fix(ansible): accept FQCN include directives
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-03 10:04:06 +00:00
Virgil
8699d00933 fix(ansible): register additional builtin modules
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 10:00:18 +00:00
Virgil
6613718d8c feat(ansible): support force_handlers plays
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 09:57:38 +00:00
Virgil
6f5d1659cd fix(ansible): support include_tasks apply defaults
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-03 09:52:23 +00:00
Virgil
0e813a93ca fix(ansible): isolate template arg scope and resolve template paths 2026-04-03 07:35:12 +00:00
Virgil
9cef8c9a03 feat(ansible): template loop items recursively
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
2026-04-02 14:54:28 +00:00
Virgil
9db3c62054 feat(ansible): template import_playbook paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:50:41 +00:00
Virgil
1a54e98612 feat(ansible): preserve task-level become password
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:46:35 +00:00
Virgil
c8abab3034 feat(ansible): unify template file rendering
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:42:04 +00:00
Virgil
b04e68fdbf feat(ansible): template play vars_files paths 2026-04-02 14:38:23 +00:00
Virgil
8cc2257ac7 feat(ansible): normalise docker compose v2 alias
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 14:35:19 +00:00
Virgil
cb1ffa8b64 feat(ansible): expose inventory become password
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:31:01 +00:00
Virgil
3e8a150375 feat(ansible): expose hostvars and groups in templates
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:27:11 +00:00
Virgil
fa5f2bb5ba feat(ansible): clarify unsupported module errors
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:22:10 +00:00
Virgil
563eebf40e Add CLI coverage and UK English cleanup 2026-04-02 14:17:29 +00:00
Virgil
b2d33c5b91 fix(ansible): honour chdir for command skips 2026-04-02 14:11:29 +00:00
Virgil
e14659dcb0 feat(ansible): render include task vars and inherit tags
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 14:06:22 +00:00
Virgil
9925b7d2e8 feat(ansible): add host context template vars
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:01:48 +00:00
Virgil
0560bccb8b feat(ansible): normalize ansible.legacy modules
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:57:01 +00:00
Virgil
7f5c5d05e3 Scope set_fact to hosts 2026-04-02 13:53:06 +00:00
Virgil
772a9c393e chore(ansible): align agent-facing module counts 2026-04-02 13:46:49 +00:00
Virgil
199cb1d087 feat(ansible): add pip requirements and virtualenv support
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:43:16 +00:00
Virgil
bced0d3cdc feat(ansible): honour include task conditions
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:38:45 +00:00
Virgil
1b633d41db Refresh cached play become state 2026-04-02 13:25:00 +00:00
Virgil
bde3c18e19 feat(ansible): expand extra-vars parsing 2026-04-02 13:21:01 +00:00
Virgil
ff2a8e7731 Support serial list batching 2026-04-02 03:32:40 +00:00
Virgil
ab9d9725be feat(ansible): honour include_role tags and when
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:28:45 +00:00
Virgil
e8669f6f7c feat(ansible): expose ansible_facts map 2026-04-02 03:25:10 +00:00
Virgil
e42e0452ad feat(ansible): resolve nested condition vars
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:20:39 +00:00
Virgil
d1682f6345 feat(ansible): accept inventory directories
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:15:52 +00:00
Virgil
2c0b68627d Load role vars in ParseRole 2026-04-02 03:12:11 +00:00
Virgil
f0c2333a75 feat(ansible): support uri dest downloads
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:08:14 +00:00
Virgil
290e9b47b1 feat(ansible): apply role when defaults to tasks
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:04:35 +00:00
Virgil
6ef54d3e56 feat(ansible): support action shorthand key-value args
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:00:58 +00:00
Virgil
c5712c696d Support role shorthand in include/import tasks 2026-04-02 02:57:51 +00:00
Virgil
1fa2b78fed feat(ansible): support disabled cron jobs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:54:18 +00:00
Virgil
4387cab0cb feat(ansible): add blockinfile backups
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:50:19 +00:00
Virgil
2b12b8f860 feat(ansible): support blockinfile newline padding
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 02:47:08 +00:00
Virgil
a95b6ec5d8 feat(ansible): support custom role handlers files 2026-04-02 02:43:15 +00:00
Virgil
e4e72bc52a Support stacked ansible verbosity flags 2026-04-02 02:39:22 +00:00
Virgil
5e6cd67400 feat(ansible): support stopped state for docker compose
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:36:45 +00:00
Virgil
8c1f4af11e feat(ansible): add uri timeout and cert controls
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:32:49 +00:00
Virgil
fbfc2a6c7e feat(ansible): add builtin ping module
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:29:36 +00:00
Virgil
f4d8ae1851 Load role handlers in Ansible roles 2026-04-02 02:25:28 +00:00
Virgil
afa8efbdbf feat(ansible): accept string numeric args in wait_for
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:21:29 +00:00
Virgil
75bafd10c8 feat(ansible): support form-urlencoded uri bodies
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:17:38 +00:00
Virgil
f9d8b3bc51 test(ansible): add CLI registration coverage
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 02:12:48 +00:00
Virgil
84451b2bd8 feat(ansible): support top-level inventory groups
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 02:07:09 +00:00
Virgil
e6be1e5f5a feat(ansible): support vars lookup
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:01:49 +00:00
Virgil
35014b52fc feat(ansible): expose loop label without extended metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:58:31 +00:00
Virgil
f5c4f16d42 feat(ansible): add b64encode filter support
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 01:54:40 +00:00
Virgil
b3f2cc3fc6 feat(ansible): add regex_replace filter support
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:51:39 +00:00
Virgil
6c1c7d9bd4 feat(ansible): normalise builtin community aliases
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:47:29 +00:00
Virgil
1d864ebe41 feat(ansible): support lineinfile firstmatch 2026-04-02 01:42:56 +00:00
Virgil
ea048b0fec feat(ansible): support lineinfile insert positions
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:39:10 +00:00
Virgil
7f7cc55479 feat(ansible): support public role vars
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:29:42 +00:00
Virgil
ac8f7a36b5 feat(ansible): support wait_for drained state
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:25:37 +00:00
Virgil
988c0e53ca feat(ansible): support authorized_key path options
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:52:53 +00:00
Virgil
ce60a583f3 feat(ansible): support stdin for command and shell modules
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:47:30 +00:00
Virgil
a475924e6f Add rpm module dispatch support 2026-04-02 00:43:32 +00:00
Virgil
8e21d5dff8 feat(ansible): support wait_for port state stopped
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:40:01 +00:00
Virgil
a81e05a078 feat(ansible): support skip_missing for with_subelements
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:36:51 +00:00
Virgil
4e0a5f714c feat(ansible): add with_subelements loop support 2026-04-02 00:32:37 +00:00
Virgil
9f86b5cb95 feat(ansible): add short flag aliases 2026-04-02 00:27:36 +00:00
Virgil
f678b97a74 feat(ansible): add backup support for file modules
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:24:23 +00:00
Virgil
4823bd68f1 Resolve file lookups against base path 2026-04-02 00:19:59 +00:00
Virgil
807751ebe7 feat(ansible): support include_role apply defaults
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:17:04 +00:00
Virgil
57bc50002e feat(ansible): support ufw logging mode 2026-04-02 00:12:13 +00:00
Virgil
4b884f67d6 Templated include_role resolution 2026-04-02 00:08:24 +00:00
Virgil
4bba5ef00e fix(ansible): scope inventory_hostname for task templating 2026-04-02 00:05:23 +00:00
Virgil
e1e2b6402e feat(ansible): expand templated loop sources 2026-04-01 23:59:45 +00:00
Virgil
8012570663 Fix task-level become override 2026-04-01 23:55:05 +00:00
Virgil
39fe9d9ca7 Add wait_for initial delay support 2026-04-01 23:51:27 +00:00
Virgil
8005562895 feat(ansible): support include_vars ignore_files
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:48:25 +00:00
Virgil
bd1932e90e feat(ansible): support include_vars extensions 2026-04-01 23:45:48 +00:00
Virgil
c1f0af5d5a feat(ansible): support include_vars files_matching filter
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:41:20 +00:00
Virgil
5bb3a2f636 feat(ansible): support with_together legacy loops
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:38:25 +00:00
Virgil
2655775a8f Fix loop task finalisation 2026-04-01 23:34:33 +00:00
Virgil
0e3a362269 feat(ansible): support legacy action shorthands 2026-04-01 23:31:40 +00:00
Virgil
bfa9a8d0ba feat(ansible): recurse include_vars directories 2026-04-01 23:27:19 +00:00
Virgil
efa2ac3ea1 Add ansible test key alias 2026-04-01 23:24:17 +00:00
Virgil
5f6205011c fix(ansible): honour play var precedence 2026-04-01 23:21:50 +00:00
Virgil
05df5b5bb8 feat(ansible): support with_nested loops
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:18:46 +00:00
Virgil
2e54726977 Support custom role defaults and vars files 2026-04-01 23:15:08 +00:00
Virgil
187f157435 feat(ansible): support remote_src in copy module
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:11:42 +00:00
Virgil
e7a0298db8 feat(ansible): improve pause and wait_for support 2026-04-01 23:09:00 +00:00
Virgil
9e7219782a feat(ansible): support play vars_files 2026-04-01 23:05:24 +00:00
Virgil
a80c2a2096 Add force support to copy and template modules 2026-04-01 23:02:03 +00:00
Virgil
134ea64e92 Add script module creates and chdir support 2026-04-01 22:59:12 +00:00
Virgil
e793321e1e Add lineinfile create support 2026-04-01 22:56:14 +00:00
Virgil
b67d9419a4 feat(ansible): expose full extended loop metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:53:34 +00:00
Virgil
defcd18f44 Fix default template fallback for missing vars 2026-04-01 22:47:37 +00:00
Virgil
41d74b0ac6 Support with_sequence task loops 2026-04-01 22:44:12 +00:00
Virgil
8c73ff922b Support builtin docker compose alias 2026-04-01 22:39:42 +00:00
Virgil
23659c185b Add command module argv support 2026-04-01 22:36:28 +00:00
Virgil
d28e5a0ac7 feat(ansible): support exclusive authorized_key entries
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:32:56 +00:00
Virgil
3c51fd40ad Add handler listen alias support 2026-04-01 22:29:44 +00:00
Virgil
a43a447f55 feat(ansible): add with_fileglob loop support
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:27:17 +00:00
Virgil
c3aa73e065 Fix authorized_key key handling 2026-04-01 22:24:33 +00:00
Virgil
11bb1b6a7f Add reboot wait and test flow 2026-04-01 22:21:40 +00:00
Virgil
92634bf561 feat(ansible): support indexed loop items
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:18:31 +00:00
Virgil
8130be049a Support logical when expressions 2026-04-01 22:07:30 +00:00
Virgil
d969cc9205 Resolve local paths from playbook base 2026-04-01 22:03:43 +00:00
Virgil
3a4118ada8 Fix include task variable inheritance 2026-04-01 22:00:12 +00:00
Virgil
c77a32f24f Templated include task paths per host 2026-04-01 21:57:05 +00:00
Virgil
87cd890ea1 Add wait_for search_regex support 2026-04-01 21:53:41 +00:00
Virgil
f97f042a3c Handle always-tagged tasks in tag filtering 2026-04-01 21:51:20 +00:00
Virgil
89a87ad1f4 Add wait_for port-absent support 2026-04-01 21:49:22 +00:00
Virgil
5951f74f27 Resolve include task paths relative to playbooks 2026-04-01 21:46:52 +00:00
Virgil
ca6dc7912e feat(ansible): reload sysctl changes 2026-04-01 21:44:08 +00:00
Virgil
6805aeb410 fix(ansible): apply role defaults and vars during execution
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:40:18 +00:00
Virgil
7e1edf86dc Add setup gather_subset filtering 2026-04-01 21:35:47 +00:00
Virgil
6fb90524ce Add URI body_format support 2026-04-01 21:32:36 +00:00
Virgil
695453cfe4 Add URI return_content support 2026-04-01 21:28:53 +00:00
Virgil
23047aaa6f Handle meta end_batch actions 2026-04-01 21:25:32 +00:00
Virgil
89eee7b964 Redact no_log task results 2026-04-01 21:22:32 +00:00
Virgil
f233605542 Add local connection execution support 2026-04-01 21:19:00 +00:00
Virgil
9ba6cdc5a4 feat(ansible): refresh inventory on meta action 2026-04-01 21:15:11 +00:00
Virgil
3596845838 Support repeated ansible extra-vars 2026-04-01 21:12:05 +00:00
Virgil
093676ff1a Honor play tags during task execution 2026-04-01 21:10:02 +00:00
Virgil
097aeec0d2 Add with_file loop support 2026-04-01 21:07:04 +00:00
Virgil
df8a400553 Add host pattern resolution 2026-04-01 21:02:51 +00:00
Virgil
66af49ec7f Support short-form community modules 2026-04-01 21:00:32 +00:00
Virgil
2cd724614a Support multiple URI status codes 2026-04-01 20:57:15 +00:00
Virgil
f80825783c fix(ansible): support import_playbook expansion 2026-04-01 20:54:36 +00:00
Virgil
f71e8642e9 Apply task and play environment vars 2026-04-01 20:51:57 +00:00
Virgil
4245e1e530 Add command module creates/removes gating 2026-04-01 20:48:09 +00:00
Virgil
ef908a7b35 feat(ansible): support meta clear_host_errors 2026-04-01 20:46:00 +00:00
Virgil
f5e66b556b fix(ansible): detect up-to-date docker compose runs 2026-04-01 20:43:28 +00:00
Virgil
fba61dbc5a Register community Ansible modules 2026-04-01 20:41:08 +00:00
Virgil
eb03970129 feat(ansible): support meta end_host
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:38:08 +00:00
Virgil
3ae6ada028 Add short-form system module aliases 2026-04-01 20:34:59 +00:00
Virgil
d99acf3dd1 Fix role-level when host evaluation 2026-04-01 20:32:50 +00:00
Virgil
0993a0e851 Apply role tags during task execution 2026-04-01 20:30:15 +00:00
Virgil
8a98a69efe Add setup fact filtering 2026-04-01 20:27:53 +00:00
Virgil
bfbbd31f09 Add diff-aware file module output 2026-04-01 20:22:39 +00:00
Virgil
2965d93ca8 Add docker compose v2 alias 2026-04-01 20:19:38 +00:00
Virgil
cab43816a0 Implement max fail percentage handling 2026-04-01 20:17:29 +00:00
Virgil
5b66334d44 Implement Jinja b64decode filter 2026-04-01 20:14:40 +00:00
Virgil
af7c360fbb Add wait_for path support 2026-04-01 20:12:52 +00:00
Virgil
02cb9273c5 feat(ansible): reset cached ssh connections
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:10:56 +00:00
Virgil
f1a52e777e Normalize with_dict legacy loops 2026-04-01 20:08:34 +00:00
Virgil
716ad80951 feat(ansible): expose extended loop metadata 2026-04-01 20:06:14 +00:00
Virgil
6cc987ea74 Implement meta clear_facts handling 2026-04-01 20:03:11 +00:00
Virgil
eb3b9cca07 feat(ansible): support delegate_to task execution
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-01 20:00:45 +00:00
Virgil
692c2cf58a feat(ansible): support meta end_play
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-01 19:57:12 +00:00
Virgil
709b1f5dc4 Implement play serial batching 2026-04-01 19:54:13 +00:00
Virgil
d9d16e8092 Implement meta flush handlers support 2026-04-01 19:51:23 +00:00
Virgil
abf27ad7f7 feat(ansible): add yum and dnf package support 2026-04-01 19:48:36 +00:00
Virgil
1581046a5c Fix pause module timing 2026-04-01 19:45:37 +00:00
Virgil
39d4de9e8f fix(ansible): honour loop_control pause
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:43:10 +00:00
Virgil
df0e79939c Add archive module support 2026-04-01 19:37:33 +00:00
Virgil
e1db473011 feat(ansible): add task retries and until support 2026-04-01 19:34:28 +00:00
Virgil
6eee9866e5 fix(ansible): add lineinfile backrefs support
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:31:26 +00:00
Virgil
6fb5ebe920 feat(ansible): honour check mode for mutating tasks 2026-04-01 19:28:25 +00:00
Virgil
acf0a16349 feat(ansible): implement run_once task handling
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-01 19:25:16 +00:00
Virgil
a973604e95 feat(ansible): add group_by module
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:21:57 +00:00
Virgil
8321e16969 fix(ansible): apply task result conditions
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-01 19:18:52 +00:00
Virgil
f27fb19bed feat(ansible): implement setup fact gathering 2026-04-01 19:12:44 +00:00
Virgil
2739e52d52 feat(ansible): add add_host inventory module 2026-04-01 19:09:11 +00:00
Virgil
34229558fb fix(ansible): implement include_vars loading 2026-04-01 19:05:24 +00:00
Virgil
35b0cf03d9 chore: verification pass
Some checks failed
CI / auto-fix (push) Failing after 0s
CI / test (push) Failing after 2s
CI / auto-merge (push) Failing after 0s
2026-03-27 03:00:31 +00:00
Virgil
f127ac2fcb chore: polish ax v0.8.0 compliance
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 16:39:59 +00:00
Virgil
4f33c15d6c refactor(ansible): upgrade core to v0.8.0-alpha.1
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-26 14:47:37 +00:00
55a0f4fcfb Delete docs/security-attack-vector-mapping.md
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 1s
CI / auto-merge (push) Failing after 1s
2026-03-23 20:38:22 +00:00
23dc42835b Merge pull request '[agent/codex] API contract extraction. For every exported type, function, ...' (#10) from agent/api-contract-extraction--for-every-expor into dev
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s
CI / auto-fix (pull_request) Failing after 0s
CI / test (pull_request) Failing after 2s
CI / auto-merge (pull_request) Failing after 0s
2026-03-23 14:20:28 +00:00
Virgil
7138ecd8d6 docs: add API contract extraction 2026-03-23 14:20:11 +00:00
5a913a5414 Merge pull request '[agent/codex] Convention drift check. stdlib→core.*, UK English, missing...' (#9) from agent/convention-drift-check--stdlib-core----u into dev
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s
2026-03-23 14:18:57 +00:00
Virgil
0c9a933b9c docs: add convention drift report 2026-03-23 14:17:54 +00:00
fb6b706993 Merge pull request '[agent/codex] Security attack vector mapping. Read CLAUDE.md. Map every ex...' (#6) from agent/security-attack-vector-mapping--read-cla into dev
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s
2026-03-23 13:08:39 +00:00
Virgil
07f777a280 docs: map ansible external input attack vectors 2026-03-23 13:08:23 +00:00
ac0633c60b Merge pull request '[agent/codex] Full audit per issue #3. Read CLAUDE.md. Report ALL findings...' (#4) from agent/full-audit-per-issue--3--read-claude-md into dev
Some checks failed
CI / test (push) Failing after 1s
CI / auto-fix (push) Failing after 0s
CI / auto-merge (push) Failing after 0s
2026-03-22 18:15:56 +00:00
34 changed files with 19733 additions and 1784 deletions

View file

@ -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.

View 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`

View file

@ -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
View 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>")
}

View file

@ -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},
),
}) })
} }

View 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
View 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
View 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. |

View file

@ -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)

View file

@ -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/

View file

@ -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.

File diff suppressed because it is too large Load diff

137
executor_become_test.go Normal file
View 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

File diff suppressed because it is too large Load diff

2
go.mod
View file

@ -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
View file

@ -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=

View file

@ -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
View 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
View 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)
}

File diff suppressed because it is too large Load diff

3903
modules.go

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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)

966
parser.go

File diff suppressed because it is too large Load diff

View file

@ -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
View file

@ -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
} }

View file

@ -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
View 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
View file

@ -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",
} }

View file

@ -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)