Compare commits

...

268 commits
v0.1.5 ... 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
2a887a99a9 Merge pull request '[agent/claude] Migrate module path to dappco.re/go/core/ansible. Update go....' (#2) from agent/migrate-module-path-to-dappco-re-go-core into main
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 1s
CI / auto-merge (push) Failing after 0s
2026-03-22 01:51:17 +00:00
Snider
b3eed66230 refactor(ansible): migrate module path to dappco.re/go/core/ansible
Some checks failed
CI / test (pull_request) Failing after 2s
CI / auto-fix (pull_request) Failing after 0s
CI / auto-merge (pull_request) Failing after 0s
Update go.mod module line and all dependencies from forge.lthn.ai to
dappco.re paths (core v0.5.0, log v0.1.0, io v0.2.0). Update all .go
import paths. Rewrite cmd/ansible/ for new core.Command API replacing
the cobra-based CLI integration. Update documentation references.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 01:50:56 +00:00
Snider
1f2535bcfa Merge remote-tracking branch 'github/dev'
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 1s
CI / auto-merge (push) Failing after 0s
2026-03-22 00:57:38 +00:00
Snider
65960a9b31 Merge remote-tracking branch 'origin/main' 2026-03-22 00:57:37 +00:00
Snider
ba62c26954 chore: sync dependencies for v0.1.6
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:53:59 +00:00
Snider
75ef4e4ca7 ci: add Core ecosystem CI workflow with CodeRabbit auto-fix
Uses dAppCore/build actions for test, auto-fix on CodeRabbit changes,
and auto-merge on CodeRabbit approval.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 14:05:54 +00:00
da6c3cae21 Merge pull request '[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate...' (#1) from agent/dx-audit-and-fix--1--review-claude-md into main 2026-03-17 08:26:20 +00:00
Snider
d2f786ce61 fix(dx): audit CLAUDE.md, fix cmd errors, add tests
- CLAUDE.md: fix module count (174→41+3), KnownModules count (68→80),
  update error handling convention to reflect coreerr.E() migration
- cmd/ansible: replace fmt.Errorf with coreerr.E(), os.Stat with
  coreio.Local.Exists()/IsDir(), os.Getwd with filepath.Abs()
- Add 38 tests for untested critical paths: non-SSH module handlers
  (debug, fail, assert, set_fact, include_vars, meta), handleLookup,
  SetInventory, iterator functions, resolveExpr with registered vars/
  facts/task vars/host vars/filters

Coverage: 23.4% → 27.7%

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 08:26:07 +00:00
38 changed files with 20359 additions and 1905 deletions

54
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
pull_request_review:
types: [submitted]
jobs:
test:
if: github.event_name != 'pull_request_review'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dAppCore/build/actions/build/core@dev
with:
go-version: "1.26"
run-vet: "true"
auto-fix:
if: >
github.event_name == 'pull_request_review' &&
github.event.review.user.login == 'coderabbitai' &&
github.event.review.state == 'changes_requested'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- uses: dAppCore/build/actions/fix@dev
with:
go-version: "1.26"
auto-merge:
if: >
github.event_name == 'pull_request_review' &&
github.event.review.user.login == 'coderabbitai' &&
github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Merge PR
run: gh pr merge ${{ github.event.pull_request.number }} --merge --delete-branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View file

@ -1,2 +1,4 @@
.core/
.idea/
.vscode/
*.log
.core/

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
`core/go-ansible` is a pure Go Ansible playbook engine. It parses YAML playbooks, inventories, and roles, then executes tasks on remote hosts via SSH. 174 module implementations, Jinja2-compatible templating, privilege escalation (become), and event-driven callbacks. 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
@ -29,10 +29,10 @@ Playbook YAML ──► Parser ──► []Play ──► Executor ──► Mod
Inventory YAML ──► Parser ──► Inventory Callbacks (OnPlayStart, OnTaskEnd, ...)
```
- **`types.go`** — Core structs (`Playbook`, `Play`, `Task`, `TaskResult`, `Inventory`, `Host`, `Facts`) and `KnownModules` registry (68 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.
- **`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.
- **`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.
@ -63,6 +63,6 @@ If adding new YAML keys to `Task`, update the `knownKeys` map in `Task.Unmarshal
- **UK English** in comments and documentation (colour, organisation, centre)
- Test naming: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (edge cases/panics)
- Use `log.E(scope, message, err)` from `go-log` for errors in SSH/parser code; `fmt.Errorf` with `%w` in executor code
- Use `coreerr.E(scope, message, err)` from `go-log` for all errors in production code (never `fmt.Errorf`)
- Tests use `testify/assert` (soft) and `testify/require` (hard)
- Licence: EUPL-1.2

7
CONSUMERS.md Normal file
View file

@ -0,0 +1,7 @@
# Consumers of go-ansible
These modules import `dappco.re/go/core/ansible`:
- go-infra
**Breaking change risk: 1 consumers.**

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,163 +1,394 @@
package anscmd
package ansiblecmd
import (
"context"
"fmt"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"time"
ansible "forge.lthn.ai/core/go-ansible"
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core"
"dappco.re/go/core/ansible"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
var (
ansibleInventory string
ansibleLimit string
ansibleTags string
ansibleSkipTags string
ansibleVars []string
ansibleVerbose int
ansibleCheck bool
)
var ansibleCmd = &cli.Command{
Use: "ansible <playbook>",
Short: "Run Ansible playbooks natively (no Python required)",
Long: `Execute Ansible playbooks using a pure Go implementation.
This command parses Ansible YAML playbooks and executes them natively,
without requiring Python or ansible-playbook to be installed.
Supported modules:
- shell, command, raw, script
- copy, template, file, lineinfile, stat, slurp, fetch, get_url
- apt, apt_key, apt_repository, package, pip
- service, systemd
- user, group
- uri, wait_for, git, unarchive
- debug, fail, assert, set_fact, pause
Examples:
core ansible playbooks/coolify/create.yml -i inventory/
core ansible site.yml -l production
core ansible deploy.yml -e "version=1.2.3" -e "env=prod"`,
Args: cli.ExactArgs(1),
RunE: runAnsible,
type playbookCommandOptions struct {
playbookPath string
basePath string
limit string
tags []string
skipTags []string
extraVars map[string]any
verbose int
checkMode bool
diff bool
}
var ansibleTestCmd = &cli.Command{
Use: "test <host>",
Short: "Test SSH connectivity to a host",
Long: `Test SSH connection and gather facts from a host.
Examples:
core ansible test linux.snider.dev -u claude -p claude
core ansible test server.example.com -i ~/.ssh/id_rsa`,
Args: cli.ExactArgs(1),
RunE: runAnsibleTest,
func splitCommaSeparatedOption(value string) []string {
if value == "" {
return nil
}
var out []string
for _, item := range split(value, ",") {
if trimmed := trimSpace(item); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
var (
testUser string
testPassword string
testKeyFile string
testPort int
)
func init() {
// ansible command flags
ansibleCmd.Flags().StringVarP(&ansibleInventory, "inventory", "i", "", "Inventory file or directory")
ansibleCmd.Flags().StringVarP(&ansibleLimit, "limit", "l", "", "Limit to specific hosts")
ansibleCmd.Flags().StringVarP(&ansibleTags, "tags", "t", "", "Only run plays and tasks tagged with these values")
ansibleCmd.Flags().StringVar(&ansibleSkipTags, "skip-tags", "", "Skip plays and tasks tagged with these values")
ansibleCmd.Flags().StringArrayVarP(&ansibleVars, "extra-vars", "e", nil, "Set additional variables (key=value)")
ansibleCmd.Flags().CountVarP(&ansibleVerbose, "verbose", "v", "Increase verbosity")
ansibleCmd.Flags().BoolVar(&ansibleCheck, "check", false, "Don't make any changes (dry run)")
// test command flags
ansibleTestCmd.Flags().StringVarP(&testUser, "user", "u", "root", "SSH user")
ansibleTestCmd.Flags().StringVarP(&testPassword, "password", "p", "", "SSH password")
ansibleTestCmd.Flags().StringVarP(&testKeyFile, "key", "i", "", "SSH private key file")
ansibleTestCmd.Flags().IntVar(&testPort, "port", 22, "SSH port")
// Add subcommands
ansibleCmd.AddCommand(ansibleTestCmd)
// 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 s, ok := o.Value.(string); ok {
out = append(out, s)
}
}
}
return out
}
func runAnsible(cmd *cli.Command, args []string) error {
playbookPath := args[0]
// firstStringOption returns the first non-empty string for any of the provided keys.
func firstStringOption(opts core.Options, keys ...string) string {
for _, key := range keys {
if value := opts.String(key); value != "" {
return value
}
}
return ""
}
// Resolve playbook path
if !filepath.IsAbs(playbookPath) {
cwd, _ := os.Getwd()
playbookPath = filepath.Join(cwd, playbookPath)
// firstBoolOption returns true when any of the provided keys is set to true.
func firstBoolOption(opts core.Options, keys ...string) bool {
for _, key := range keys {
if opts.Bool(key) {
return true
}
}
return false
}
// collectStringOptionValues returns every string value for any of the provided
// keys, preserving the original option order.
func collectStringOptionValues(opts core.Options, keys ...string) []string {
var out []string
for _, o := range opts.Items() {
matched := false
for _, key := range keys {
if o.Key == key {
matched = true
break
}
}
if !matched {
continue
}
switch v := o.Value.(type) {
case string:
out = append(out, v)
case []string:
out = append(out, v...)
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
}
}
if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
return fmt.Errorf("playbook not found: %s", playbookPath)
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 ""
}
// Create executor
basePath := filepath.Dir(playbookPath)
executor := ansible.NewExecutor(basePath)
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()
// Set options
executor.Limit = ansibleLimit
executor.CheckMode = ansibleCheck
executor.Verbose = ansibleVerbose
executor.Limit = settings.limit
executor.CheckMode = settings.checkMode
executor.Diff = settings.diff
executor.Verbose = settings.verbose
executor.Tags = settings.tags
executor.SkipTags = settings.skipTags
if ansibleTags != "" {
executor.Tags = strings.Split(ansibleTags, ",")
}
if ansibleSkipTags != "" {
executor.SkipTags = strings.Split(ansibleSkipTags, ",")
}
// Parse extra vars
for _, v := range ansibleVars {
parts := strings.SplitN(v, "=", 2)
if len(parts) == 2 {
executor.SetVar(parts[0], parts[1])
}
for key, value := range settings.extraVars {
executor.SetVar(key, value)
}
// Load inventory
if ansibleInventory != "" {
invPath := ansibleInventory
if !filepath.IsAbs(invPath) {
cwd, _ := os.Getwd()
invPath = filepath.Join(cwd, invPath)
if inventoryPath := firstStringOption(opts, "inventory", "i"); inventoryPath != "" {
if !pathIsAbs(inventoryPath) {
inventoryPath = absPath(inventoryPath)
}
// Check if it's a directory
info, err := os.Stat(invPath)
if err != nil {
return fmt.Errorf("inventory not found: %s", invPath)
if !coreio.Local.Exists(inventoryPath) {
return core.Result{Value: coreerr.E("runPlaybookCommand", sprintf("inventory not found: %s", inventoryPath), nil)}
}
if info.IsDir() {
// Look for inventory.yml or hosts.yml
if coreio.Local.IsDir(inventoryPath) {
for _, name := range []string{"inventory.yml", "hosts.yml", "inventory.yaml", "hosts.yaml"} {
p := filepath.Join(invPath, name)
if _, err := os.Stat(p); err == nil {
invPath = p
candidatePath := joinPath(inventoryPath, name)
if coreio.Local.Exists(candidatePath) {
inventoryPath = candidatePath
break
}
}
}
if err := executor.SetInventory(invPath); err != nil {
return fmt.Errorf("load inventory: %w", err)
if err := executor.SetInventory(inventoryPath); err != nil {
return core.Result{Value: coreerr.E("runPlaybookCommand", "load inventory", err)}
}
}
// Set up callbacks
executor.OnPlayStart = func(play *ansible.Play) {
fmt.Printf("\n%s %s\n", cli.TitleStyle.Render("PLAY"), cli.BoldStyle.Render("["+play.Name+"]"))
fmt.Println(strings.Repeat("*", 70))
print("")
print("PLAY [%s]", play.Name)
print("%s", repeat("*", 70))
}
executor.OnTaskStart = func(host string, task *ansible.Task) {
@ -165,85 +396,92 @@ func runAnsible(cmd *cli.Command, args []string) error {
if taskName == "" {
taskName = task.Module
}
fmt.Printf("\n%s %s\n", cli.TitleStyle.Render("TASK"), cli.BoldStyle.Render("["+taskName+"]"))
if ansibleVerbose > 0 {
fmt.Printf("%s\n", cli.DimStyle.Render("host: "+host))
print("")
print("TASK [%s]", taskName)
if executor.Verbose > 0 {
print("host: %s", host)
}
}
executor.OnTaskEnd = func(host string, task *ansible.Task, result *ansible.TaskResult) {
status := "ok"
style := cli.SuccessStyle
if result.Failed {
status = "failed"
style = cli.ErrorStyle
} else if result.Skipped {
status = "skipping"
style = cli.DimStyle
} else if result.Changed {
status = "changed"
style = cli.WarningStyle
}
fmt.Printf("%s: [%s]", style.Render(status), host)
if result.Msg != "" && ansibleVerbose > 0 {
fmt.Printf(" => %s", result.Msg)
line := sprintf("%s: [%s]", status, host)
if result.Msg != "" && executor.Verbose > 0 {
line = sprintf("%s => %s", line, result.Msg)
}
if result.Duration > 0 && ansibleVerbose > 1 {
fmt.Printf(" (%s)", result.Duration.Round(time.Millisecond))
if result.Duration > 0 && executor.Verbose > 1 {
line = sprintf("%s (%s)", line, result.Duration.Round(time.Millisecond))
}
fmt.Println()
print("%s", line)
if result.Failed && result.Stderr != "" {
fmt.Printf("%s\n", cli.ErrorStyle.Render(result.Stderr))
print("%s", result.Stderr)
}
if ansibleVerbose > 1 {
if executor.Verbose > 1 {
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) {
fmt.Println()
print("")
}
// Run playbook
ctx := context.Background()
start := time.Now()
fmt.Printf("%s Running playbook: %s\n", cli.BoldStyle.Render("▶"), playbookPath)
print("Running playbook: %s", settings.playbookPath)
if err := executor.Run(ctx, playbookPath); err != nil {
return fmt.Errorf("playbook failed: %w", err)
if err := executor.Run(ctx, settings.playbookPath); err != nil {
return core.Result{Value: coreerr.E("runPlaybookCommand", "playbook failed", err)}
}
fmt.Printf("\n%s Playbook completed in %s\n",
cli.SuccessStyle.Render("✓"),
time.Since(start).Round(time.Millisecond))
print("")
print("Playbook completed in %s", time.Since(start).Round(time.Millisecond))
return nil
return core.Result{OK: true}
}
func runAnsibleTest(cmd *cli.Command, args []string) error {
host := args[0]
func runSSHTestCommand(opts core.Options) core.Result {
positional := positionalArgs(opts)
if len(positional) < 1 {
return core.Result{Value: coreerr.E("runSSHTestCommand", "usage: ansible test <host>", nil)}
}
host := positional[0]
fmt.Printf("Testing SSH connection to %s...\n", cli.BoldStyle.Render(host))
print("Testing SSH connection to %s...", host)
cfg := ansible.SSHConfig{
config := ansible.SSHConfig{
Host: host,
Port: testPort,
User: testUser,
Password: testPassword,
KeyFile: testKeyFile,
Port: opts.Int("port"),
User: firstStringOption(opts, "user", "u"),
Password: opts.String("password"),
KeyFile: resolveSSHTestKeyFile(opts),
Timeout: 30 * time.Second,
}
client, err := ansible.NewSSHClient(cfg)
client, err := ansible.NewSSHClient(config)
if err != nil {
return fmt.Errorf("create client: %w", err)
return core.Result{Value: coreerr.E("runSSHTestCommand", "create client", err)}
}
defer func() { _ = client.Close() }()
@ -253,58 +491,52 @@ func runAnsibleTest(cmd *cli.Command, args []string) error {
// Test connection
start := time.Now()
if err := client.Connect(ctx); err != nil {
return fmt.Errorf("connect failed: %w", err)
return core.Result{Value: coreerr.E("runSSHTestCommand", "connect failed", err)}
}
connectTime := time.Since(start)
fmt.Printf("%s Connected in %s\n", cli.SuccessStyle.Render("✓"), connectTime.Round(time.Millisecond))
print("Connected in %s", connectTime.Round(time.Millisecond))
// Gather facts
fmt.Println("\nGathering facts...")
print("")
print("Gathering facts...")
// Hostname
stdout, _, _, _ := client.Run(ctx, "hostname -f 2>/dev/null || hostname")
fmt.Printf(" Hostname: %s\n", cli.BoldStyle.Render(strings.TrimSpace(stdout)))
print(" Hostname: %s", trimSpace(stdout))
// OS
stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
if stdout != "" {
fmt.Printf(" OS: %s\n", strings.TrimSpace(stdout))
print(" OS: %s", trimSpace(stdout))
}
// Kernel
stdout, _, _, _ = client.Run(ctx, "uname -r")
fmt.Printf(" Kernel: %s\n", strings.TrimSpace(stdout))
print(" Kernel: %s", trimSpace(stdout))
// Architecture
stdout, _, _, _ = client.Run(ctx, "uname -m")
fmt.Printf(" Architecture: %s\n", strings.TrimSpace(stdout))
print(" Architecture: %s", trimSpace(stdout))
// Memory
stdout, _, _, _ = client.Run(ctx, "free -h | grep Mem | awk '{print $2}'")
fmt.Printf(" Memory: %s\n", strings.TrimSpace(stdout))
print(" Memory: %s", trimSpace(stdout))
// Disk
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))
// Docker
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null")
if err == nil {
fmt.Printf(" Docker: %s\n", cli.SuccessStyle.Render(strings.TrimSpace(stdout)))
print(" Docker: %s", trimSpace(stdout))
} else {
fmt.Printf(" Docker: %s\n", cli.DimStyle.Render("not installed"))
print(" Docker: not installed")
}
// Check if Coolify is running
stdout, _, _, _ = client.Run(ctx, "docker ps 2>/dev/null | grep -q coolify && echo 'running' || echo 'not running'")
if strings.TrimSpace(stdout) == "running" {
fmt.Printf(" Coolify: %s\n", cli.SuccessStyle.Render("running"))
if trimSpace(stdout) == "running" {
print(" Coolify: running")
} else {
fmt.Printf(" Coolify: %s\n", cli.DimStyle.Render("not installed"))
print(" Coolify: not installed")
}
fmt.Printf("\n%s SSH test passed\n", cli.SuccessStyle.Render("✓"))
print("")
print("SSH test passed")
return nil
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,14 +1,46 @@
package anscmd
package ansiblecmd
import (
"forge.lthn.ai/core/cli/pkg/cli"
"dappco.re/go/core"
)
func init() {
cli.RegisterCommands(AddAnsibleCommands)
}
// Register registers the `ansible` command and its `ansible/test` subcommand.
//
// Example:
//
// var app core.Core
// Register(&app)
func Register(c *core.Core) {
c.Command("ansible", core.Command{
Description: "Run Ansible playbooks natively (no Python required)",
Action: runPlaybookCommand,
Flags: core.NewOptions(
core.Option{Key: "inventory", Value: ""},
core.Option{Key: "i", Value: ""},
core.Option{Key: "limit", Value: ""},
core.Option{Key: "l", Value: ""},
core.Option{Key: "tags", Value: ""},
core.Option{Key: "t", Value: ""},
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},
),
})
// AddAnsibleCommands registers the 'ansible' command and all subcommands.
func AddAnsibleCommands(root *cli.Command) {
root.AddCommand(ansibleCmd)
c.Command("ansible/test", core.Command{
Description: "Test SSH connectivity to a host",
Action: runSSHTestCommand,
Flags: core.NewOptions(
core.Option{Key: "user", Value: "root"},
core.Option{Key: "u", Value: "root"},
core.Option{Key: "password", Value: ""},
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`
- 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`
- 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)

View file

@ -155,13 +155,13 @@ func TestModuleHostname_Bad_MissingName(t *testing.T) {
```
go-ansible/
go.mod Module definition (forge.lthn.ai/core/go-ansible)
go.mod Module definition (dappco.re/go/core/ansible)
go.sum Dependency checksums
CLAUDE.md AI assistant context file
types.go Core data types and KnownModules registry
parser.go YAML parsing (playbooks, inventories, roles)
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)
*_test.go Test files (see table above)
cmd/

View file

@ -5,12 +5,12 @@ description: A pure Go Ansible playbook engine -- parses YAML playbooks, invento
# go-ansible
`forge.lthn.ai/core/go-ansible` is a pure Go implementation of an Ansible playbook engine. It parses standard Ansible YAML playbooks, inventories, and roles, then executes tasks against remote hosts over SSH -- with no dependency on Python or the upstream `ansible-playbook` binary.
`dappco.re/go/core/ansible` is a pure Go implementation of an Ansible playbook engine. It parses standard Ansible YAML playbooks, inventories, and roles, then executes tasks against remote hosts over SSH -- with no dependency on Python or the upstream `ansible-playbook` binary.
## Module Path
```
forge.lthn.ai/core/go-ansible
dappco.re/go/core/ansible
```
Requires **Go 1.26+**.
@ -26,7 +26,7 @@ import (
"context"
"fmt"
ansible "forge.lthn.ai/core/go-ansible"
ansible "dappco.re/go/core/ansible"
)
func main() {
@ -110,7 +110,7 @@ go-ansible/
types.go Core data types: Playbook, Play, Task, Inventory, Host, Facts
parser.go YAML parser for playbooks, inventories, tasks, roles
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
types_test.go Tests for data types and YAML unmarshalling
parser_test.go Tests for the YAML parser
@ -126,20 +126,20 @@ go-ansible/
## 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 |
|----------|---------|
| **Command execution** | `shell`, `command`, `raw`, `script` |
| **File operations** | `copy`, `template`, `file`, `lineinfile`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` |
| **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip` |
| **File operations** | `copy`, `template`, `file`, `lineinfile`, `replace`, `blockinfile`, `stat`, `slurp`, `fetch`, `get_url` |
| **Package management** | `apt`, `apt_key`, `apt_repository`, `package`, `pip`, `rpm` |
| **Service management** | `service`, `systemd` |
| **User and group** | `user`, `group` |
| **HTTP** | `uri` |
| **Source control** | `git` |
| **Archive** | `unarchive` |
| **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` |
Both fully-qualified collection names (e.g. `ansible.builtin.shell`) and short-form names (e.g. `shell`) are accepted.
@ -148,8 +148,8 @@ Both fully-qualified collection names (e.g. `ansible.builtin.shell`) and short-f
| Module | Purpose |
|--------|---------|
| `forge.lthn.ai/core/cli` | CLI framework (command registration, flags, styled output) |
| `forge.lthn.ai/core/go-log` | Structured logging and contextual error helper (`log.E()`) |
| `dappco.re/go/core` | Core framework (command registration, flags) |
| `dappco.re/go/core/log` | Structured logging and contextual error helper (`log.E()`) |
| `golang.org/x/crypto` | SSH protocol implementation (`crypto/ssh`, `crypto/ssh/knownhosts`) |
| `gopkg.in/yaml.v3` | YAML parsing for playbooks, inventories, and role files |
| `github.com/stretchr/testify` | Test assertions (test-only) |

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])
}

1816
executor_extra_test.go Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

36
go.mod
View file

@ -1,45 +1,19 @@
module forge.lthn.ai/core/go-ansible
module dappco.re/go/core/ansible
go 1.26.0
require (
forge.lthn.ai/core/cli v0.3.6
forge.lthn.ai/core/go-io v0.1.5
forge.lthn.ai/core/go-log v0.0.4
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/io v0.2.0
dappco.re/go/core/log v0.1.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.49.0
gopkg.in/yaml.v3 v3.0.1
)
require (
forge.lthn.ai/core/go v0.3.2 // indirect
forge.lthn.ai/core/go-i18n v0.1.6 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

70
go.sum
View file

@ -1,87 +1,29 @@
forge.lthn.ai/core/cli v0.3.6 h1:qYAn+6iMd2py7Wu2CYgXCRQvin1/QG72lH8skR7kqsE=
forge.lthn.ai/core/cli v0.3.6/go.mod h1:+a0m7dFYo2IQ8pFsT6ZlHNdDinqtMXFe7/E1fN8SdaA=
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
forge.lthn.ai/core/go-i18n v0.1.6 h1:Z9h6sEZsgJmWlkkq3ZPZyfgWipeeqN5lDCpzltpamHU=
forge.lthn.ai/core/go-i18n v0.1.6/go.mod h1:C6CbwdN7sejTx/lbutBPrxm77b8paMHBO6uHVLHOdqQ=
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E=
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI=
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
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/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

51
kb/Executor.md Normal file
View file

@ -0,0 +1,51 @@
# Executor
Module: `dappco.re/go/core/ansible`
The `Executor` is the main playbook runner. It manages SSH connections, variable resolution, conditional evaluation, loops, blocks, roles, handlers, and module execution.
## Execution Flow
1. Parse playbook YAML into `[]Play`
2. For each play:
- Resolve target hosts from inventory (apply `Limit` filter)
- Merge play variables
- Gather facts (SSH into hosts, collect OS/hostname/kernel info)
- Execute `pre_tasks`, `roles`, `tasks`, `post_tasks`
- Run notified handlers
3. Each task goes through:
- Tag matching (`Tags`, `SkipTags`)
- Block/rescue/always handling
- Include/import resolution
- `when` condition evaluation
- Loop expansion
- Module execution via SSH
- Result registration and handler notification
## Templating
Jinja2-like `{{ var }}` syntax is supported:
- Variable resolution from play vars, task vars, host vars, facts, registered results
- Dotted access: `{{ result.stdout }}`, `{{ result.rc }}`
- Filters: `| default(value)`, `| bool`, `| trim`
- Lookups: `lookup('env', 'HOME')`, `lookup('file', '/path')`
## Conditionals
`when` supports:
- 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`
- Negation: `not condition`
- Variable truthiness checks
## SSH Client Features
- Key-based and password authentication
- Known hosts verification
- Privilege escalation (`become`/`sudo`) with password support
- File upload via `cat` (no SCP dependency)
- File download, stat, exists checks
- Context-based timeout and cancellation

64
kb/Home.md Normal file
View file

@ -0,0 +1,64 @@
# go-ansible
Module: `dappco.re/go/core/ansible`
Pure Go Ansible executor that parses and runs Ansible playbooks without requiring the Python ansible binary. Supports SSH-based remote execution, inventory parsing, Jinja2-like templating, module execution, roles, handlers, loops, blocks, and conditionals.
## Architecture
| File | Purpose |
|------|---------|
| `types.go` | Data types: `Playbook`, `Play`, `Task`, `TaskResult`, `Inventory`, `Host`, `Facts`, `KnownModules` |
| `parser.go` | YAML parsing for playbooks, inventory, roles, and task files |
| `executor.go` | Playbook execution engine with SSH client management, templating, conditionals |
| `ssh.go` | `SSHClient` for remote command execution, file upload/download |
| `modules.go` | Ansible module implementations (shell, copy, template, file, service, etc.) |
CLI registration in `cmd/ansible/`.
## Key Types
### Core Types
- **`Executor`** — Runs playbooks: `Run()`, `SetInventory()`, `SetVar()`. Supports callbacks: `OnPlayStart`, `OnTaskStart`, `OnTaskEnd`, `OnPlayEnd`. Options: `Limit`, `Tags`, `SkipTags`, `CheckMode`, `Diff`, `Verbose`
- **`Parser`** — Parses YAML: `ParsePlaybook()`, `ParseInventory()`, `ParseRole()`, `ParseTasks()`
- **`SSHClient`** — SSH operations: `Connect()`, `Run()`, `RunScript()`, `Upload()`, `Download()`, `FileExists()`, `Stat()`, `SetBecome()`
- **`SSHConfig`** — Connection config: `Host`, `Port`, `User`, `Password`, `KeyFile`, `Become`, `BecomeUser`, `BecomePass`, `Timeout`
### Playbook Types
- **`Play`** — Single play: `Name`, `Hosts`, `Become`, `Vars`, `PreTasks`, `Tasks`, `PostTasks`, `Roles`, `Handlers`
- **`Task`** — Single task: `Name`, `Module`, `Args`, `Register`, `When`, `Loop`, `LoopControl`, `Block`, `Rescue`, `Always`, `Notify`, `IncludeTasks`, `ImportTasks`
- **`TaskResult`** — Execution result: `Changed`, `Failed`, `Skipped`, `Msg`, `Stdout`, `Stderr`, `RC`, `Results` (for loops)
- **`RoleRef`** — Role reference with vars and conditions
### Inventory Types
- **`Inventory`** — Top-level with `All` group
- **`InventoryGroup`** — `Hosts`, `Children`, `Vars`
- **`Host`** — Connection details: `AnsibleHost`, `AnsiblePort`, `AnsibleUser`, `AnsibleSSHPrivateKeyFile`
- **`Facts`** — Gathered facts: `Hostname`, `FQDN`, `OS`, `Distribution`, `Architecture`, `Kernel`, `Memory`, `CPUs`
## Usage
```go
import "dappco.re/go/core/ansible"
executor := ansible.NewExecutor("/path/to/playbooks")
executor.SetInventory("inventory/hosts.yml")
executor.SetVar("deploy_version", "1.2.3")
executor.OnTaskStart = func(host string, task *ansible.Task) {
fmt.Printf("[%s] %s\n", host, task.Name)
}
err := executor.Run(ctx, "deploy.yml")
defer executor.Close()
```
## Dependencies
- `dappco.re/go/core/log` — Structured logging and errors
- `golang.org/x/crypto/ssh` — SSH client
- `golang.org/x/crypto/ssh/knownhosts` — Host key verification
- `gopkg.in/yaml.v3` — YAML parsing

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

3907
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
import (
"os"
"path/filepath"
"context"
"testing"
"github.com/stretchr/testify/assert"
@ -15,7 +14,7 @@ import (
// --- MockSSHClient basic tests ---
func TestMockSSHClient_Good_RunRecordsExecution(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_RunRecordsExecution(t *testing.T) {
mock := NewMockSSHClient()
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)
}
func TestMockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommand("set -e", "ok", "", 0)
@ -43,7 +42,7 @@ func TestMockSSHClient_Good_RunScriptRecordsExecution(t *testing.T) {
assert.Equal(t, "RunScript", mock.lastCommand().Method)
}
func TestMockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
mock := NewMockSSHClient()
// No expectations registered — should return empty success
@ -55,7 +54,7 @@ func TestMockSSHClient_Good_DefaultSuccessResponse(t *testing.T) {
assert.Equal(t, 0, rc)
}
func TestMockSSHClient_Good_LastMatchWins(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_LastMatchWins(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommand("echo", "first", "", 0)
mock.expectCommand("echo", "second", "", 0)
@ -65,7 +64,7 @@ func TestMockSSHClient_Good_LastMatchWins(t *testing.T) {
assert.Equal(t, "second", stdout)
}
func TestMockSSHClient_Good_FileOperations(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_FileOperations(t *testing.T) {
mock := NewMockSSHClient()
// File does not exist initially
@ -91,7 +90,7 @@ func TestMockSSHClient_Good_FileOperations(t *testing.T) {
assert.Error(t, err)
}
func TestMockSSHClient_Good_StatWithExplicit(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_StatWithExplicit(t *testing.T) {
mock := NewMockSSHClient()
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"])
}
func TestMockSSHClient_Good_StatFallback(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_StatFallback(t *testing.T) {
mock := NewMockSSHClient()
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"])
}
func TestMockSSHClient_Good_BecomeTracking(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_BecomeTracking(t *testing.T) {
mock := NewMockSSHClient()
assert.False(t, mock.become)
@ -128,7 +127,26 @@ func TestMockSSHClient_Good_BecomeTracking(t *testing.T) {
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.Run(nil, "systemctl restart nginx")
_, _, _, _ = mock.Run(nil, "apt-get update")
@ -138,7 +156,7 @@ func TestMockSSHClient_Good_HasExecuted(t *testing.T) {
assert.False(t, mock.hasExecuted("yum"))
}
func TestMockSSHClient_Good_HasExecutedMethod(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_HasExecutedMethod(t *testing.T) {
mock := NewMockSSHClient()
_, _, _, _ = mock.Run(nil, "echo run")
_, _, _, _ = mock.RunScript(nil, "echo script")
@ -149,7 +167,7 @@ func TestMockSSHClient_Good_HasExecutedMethod(t *testing.T) {
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.Run(nil, "echo hello")
assert.Equal(t, 1, mock.commandCount())
@ -158,7 +176,7 @@ func TestMockSSHClient_Good_Reset(t *testing.T) {
assert.Equal(t, 0, mock.commandCount())
}
func TestMockSSHClient_Good_ErrorExpectation(t *testing.T) {
func TestModulesCmd_MockSSHClient_Good_ErrorExpectation(t *testing.T) {
mock := NewMockSSHClient()
mock.expectCommandError("bad cmd", assert.AnError)
@ -168,7 +186,7 @@ func TestMockSSHClient_Good_ErrorExpectation(t *testing.T) {
// --- command module ---
func TestModuleCommand_Good_BasicCommand(t *testing.T) {
func TestModulesCmd_ModuleCommand_Good_BasicCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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", ".*"))
}
func TestModuleCommand_Good_CmdArg(t *testing.T) {
func TestModulesCmd_ModuleCommand_Good_CmdArg(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("whoami", "root\n", "", 0)
@ -201,7 +219,26 @@ func TestModuleCommand_Good_CmdArg(t *testing.T) {
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")
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")
}
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")
mock := NewMockSSHClient()
@ -229,7 +285,7 @@ func TestModuleCommand_Bad_NoCommand(t *testing.T) {
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")
mock.expectCommand("false", "", "error occurred", 1)
@ -243,7 +299,7 @@ func TestModuleCommand_Good_NonZeroRC(t *testing.T) {
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")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
@ -257,7 +313,7 @@ func TestModuleCommand_Good_SSHError(t *testing.T) {
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")
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"))
}
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 ---
func TestModuleShell_Good_BasicShell(t *testing.T) {
func TestModulesCmd_ModuleShell_Good_BasicShell(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo hello", "hello\n", "", 0)
@ -291,7 +385,7 @@ func TestModuleShell_Good_BasicShell(t *testing.T) {
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")
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"))
}
func TestModuleShell_Good_WithChdir(t *testing.T) {
func TestModulesCmd_ModuleShell_Good_WithChdir(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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")
}
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")
mock := NewMockSSHClient()
@ -331,7 +445,7 @@ func TestModuleShell_Bad_NoCommand(t *testing.T) {
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")
mock.expectCommand("exit 2", "", "failed", 2)
@ -344,7 +458,24 @@ func TestModuleShell_Good_NonZeroRC(t *testing.T) {
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")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
@ -357,7 +488,7 @@ func TestModuleShell_Good_SSHError(t *testing.T) {
assert.True(t, result.Failed)
}
func TestModuleShell_Good_PipelineCommand(t *testing.T) {
func TestModulesCmd_ModuleShell_Good_PipelineCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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 ---
func TestModuleRaw_Good_BasicRaw(t *testing.T) {
func TestModulesCmd_ModuleRaw_Good_BasicRaw(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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", ".*"))
}
func TestModuleRaw_Bad_NoCommand(t *testing.T) {
func TestModulesCmd_ModuleRaw_Bad_NoCommand(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -400,7 +531,7 @@ func TestModuleRaw_Bad_NoCommand(t *testing.T) {
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
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
@ -418,7 +549,7 @@ func TestModuleRaw_Good_NoChdir(t *testing.T) {
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")
mock.expectCommand("invalid", "", "not found", 127)
@ -432,7 +563,7 @@ func TestModuleRaw_Good_NonZeroRC(t *testing.T) {
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")
mock := NewMockSSHClient()
mock.expectCommandError(".*", assert.AnError)
@ -445,7 +576,7 @@ func TestModuleRaw_Good_SSHError(t *testing.T) {
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
e, mock := newTestExecutorWithMock("host1")
complexCmd := `/usr/bin/python3 -c 'import sys; print(sys.version)'`
@ -463,12 +594,12 @@ func TestModuleRaw_Good_ExactCommandPassthrough(t *testing.T) {
// --- script module ---
func TestModuleScript_Good_BasicScript(t *testing.T) {
func TestModulesCmd_ModuleScript_Good_BasicScript(t *testing.T) {
// Create a temporary script file
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "setup.sh")
scriptPath := joinPath(tmpDir, "setup.sh")
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")
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)
}
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")
mock := NewMockSSHClient()
@ -500,7 +697,7 @@ func TestModuleScript_Bad_NoScript(t *testing.T) {
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")
mock := NewMockSSHClient()
@ -512,10 +709,10 @@ func TestModuleScript_Bad_FileNotFound(t *testing.T) {
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()
scriptPath := filepath.Join(tmpDir, "fail.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("exit 1"), 0755))
scriptPath := joinPath(tmpDir, "fail.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("exit 1"), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("exit 1", "", "script failed", 1)
@ -529,11 +726,11 @@ func TestModuleScript_Good_NonZeroRC(t *testing.T) {
assert.Equal(t, 1, result.RC)
}
func TestModuleScript_Good_MultiLineScript(t *testing.T) {
func TestModulesCmd_ModuleScript_Good_MultiLineScript(t *testing.T) {
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"
require.NoError(t, os.WriteFile(scriptPath, []byte(scriptContent), 0755))
require.NoError(t, writeTestFile(scriptPath, []byte(scriptContent), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("apt-get", "done\n", "", 0)
@ -551,10 +748,10 @@ func TestModuleScript_Good_MultiLineScript(t *testing.T) {
assert.Equal(t, scriptContent, last.Cmd)
}
func TestModuleScript_Good_SSHError(t *testing.T) {
func TestModulesCmd_ModuleScript_Good_SSHError(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "ok.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("echo ok"), 0755))
scriptPath := joinPath(tmpDir, "ok.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("echo ok"), 0755))
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -570,7 +767,7 @@ func TestModuleScript_Good_SSHError(t *testing.T) {
// --- Cross-module differentiation tests ---
func TestModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
func TestModulesCmd_ModuleDifferentiation_Good_CommandUsesRun(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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()")
}
func TestModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
func TestModulesCmd_ModuleDifferentiation_Good_ShellUsesRunScript(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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()")
}
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")
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()")
}
func TestModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
func TestModulesCmd_ModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "test.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("echo test"), 0755))
scriptPath := joinPath(tmpDir, "test.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("echo test"), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("echo test", "test\n", "", 0)
@ -620,7 +833,7 @@ func TestModuleDifferentiation_Good_ScriptUsesRunScript(t *testing.T) {
// --- executeModuleWithMock dispatch tests ---
func TestExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchCommand(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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)
}
func TestExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("ps aux", "root.*bash\n", "", 0)
@ -651,7 +864,7 @@ func TestExecuteModuleWithMock_Good_DispatchShell(t *testing.T) {
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchRaw(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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)
}
func TestExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
func TestModulesCmd_ExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "deploy.sh")
require.NoError(t, os.WriteFile(scriptPath, []byte("echo deploying"), 0755))
scriptPath := joinPath(tmpDir, "deploy.sh")
require.NoError(t, writeTestFile(scriptPath, []byte("echo deploying"), 0755))
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand("deploying", "deploying\n", "", 0)
@ -686,7 +899,7 @@ func TestExecuteModuleWithMock_Good_DispatchScript(t *testing.T) {
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
func TestModulesCmd_ExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
task := &Task{
@ -698,11 +911,12 @@ func TestExecuteModuleWithMock_Bad_UnsupportedModule(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported module")
assert.Contains(t, err.Error(), "ansible.builtin.hostname")
}
// --- Template integration tests ---
func TestModuleCommand_Good_TemplatedArgs(t *testing.T) {
func TestModulesCmd_ModuleCommand_Good_TemplatedArgs(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
e.SetVar("service_name", "nginx")
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 ---
func TestModuleService_Good_Start(t *testing.T) {
func TestModulesSvc_ModuleService_Good_Start(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start nginx`, "Started", "", 0)
@ -29,7 +29,7 @@ func TestModuleService_Good_Start(t *testing.T) {
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")
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`))
}
func TestModuleService_Good_Restart(t *testing.T) {
func TestModulesSvc_ModuleService_Good_Restart(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModuleService_Good_Reload(t *testing.T) {
func TestModulesSvc_ModuleService_Good_Reload(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModuleService_Good_Enable(t *testing.T) {
func TestModulesSvc_ModuleService_Good_Enable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModuleService_Good_Disable(t *testing.T) {
func TestModulesSvc_ModuleService_Good_Disable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModuleService_Good_StartAndEnable(t *testing.T) {
func TestModulesSvc_ModuleService_Good_StartAndEnable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start 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`))
}
func TestModuleService_Good_RestartAndDisable(t *testing.T) {
func TestModulesSvc_ModuleService_Good_RestartAndDisable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl restart 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`))
}
func TestModuleService_Bad_MissingName(t *testing.T) {
func TestModulesSvc_ModuleService_Bad_MissingName(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -154,7 +154,7 @@ func TestModuleService_Bad_MissingName(t *testing.T) {
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
e, mock := newTestExecutorWithMock("host1")
@ -168,7 +168,7 @@ func TestModuleService_Good_NoStateNoEnabled(t *testing.T) {
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")
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)
}
func TestModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
func TestModulesSvc_ModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
// When state command fails, enable should not run
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl start`, "", "unit not found", 5)
@ -203,7 +203,7 @@ func TestModuleService_Good_FirstCommandFailsSkipsRest(t *testing.T) {
// --- systemd module ---
func TestModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadThenStart(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 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")
}
func TestModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
func TestModulesSvc_ModuleSystemd_Good_DaemonReloadOnly(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModuleSystemd_Good_DelegationToService(t *testing.T) {
func TestModulesSvc_ModuleSystemd_Good_DelegationToService(t *testing.T) {
// Without daemon_reload, systemd delegates entirely to service
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl restart docker`, "", "", 0)
@ -261,7 +261,7 @@ func TestModuleSystemd_Good_DelegationToService(t *testing.T) {
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")
mock.expectCommand(`systemctl daemon-reload`, "", "", 0)
mock.expectCommand(`systemctl enable myapp`, "", "", 0)
@ -281,7 +281,7 @@ func TestModuleSystemd_Good_DaemonReloadWithEnable(t *testing.T) {
// --- apt module ---
func TestModuleApt_Good_InstallPresent(t *testing.T) {
func TestModulesSvc_ModuleApt_Good_InstallPresent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModuleApt_Good_InstallInstalled(t *testing.T) {
func TestModulesSvc_ModuleApt_Good_InstallInstalled(t *testing.T) {
// state=installed is an alias for present
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq curl`, "", "", 0)
@ -312,7 +312,7 @@ func TestModuleApt_Good_InstallInstalled(t *testing.T) {
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")
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`))
}
func TestModuleApt_Good_RemoveRemoved(t *testing.T) {
func TestModulesSvc_ModuleApt_Good_RemoveRemoved(t *testing.T) {
// state=removed is an alias for absent
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get remove -y -qq nginx`, "", "", 0)
@ -342,7 +342,7 @@ func TestModuleApt_Good_RemoveRemoved(t *testing.T) {
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")
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`))
}
func TestModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
func TestModulesSvc_ModuleApt_Good_UpdateCacheBeforeInstall(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get update`, "", "", 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")
}
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
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModuleApt_Good_CommandFailure(t *testing.T) {
func TestModulesSvc_ModuleApt_Good_CommandFailure(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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)
}
func TestModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
func TestModulesSvc_ModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
// If no state is given, default is "present" (install)
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`apt-get install -y -qq vim`, "", "", 0)
@ -424,9 +424,22 @@ func TestModuleApt_Good_DefaultStateIsPresent(t *testing.T) {
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 ---
func TestModuleAptKey_Good_AddWithKeyring(t *testing.T) {
func TestModulesSvc_ModuleAptKey_Good_AddWithKeyring(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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"))
}
func TestModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
func TestModulesSvc_ModuleAptKey_Good_AddWithoutKeyring(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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 -`))
}
func TestModuleAptKey_Good_RemoveKey(t *testing.T) {
func TestModulesSvc_ModuleAptKey_Good_RemoveKey(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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"))
}
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
e, mock := newTestExecutorWithMock("host1")
@ -485,7 +498,7 @@ func TestModuleAptKey_Good_RemoveWithoutKeyring(t *testing.T) {
assert.Equal(t, 0, mock.commandCount())
}
func TestModuleAptKey_Bad_MissingURL(t *testing.T) {
func TestModulesSvc_ModuleAptKey_Bad_MissingURL(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -497,7 +510,7 @@ func TestModuleAptKey_Bad_MissingURL(t *testing.T) {
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")
mock.expectCommand(`curl`, "", "curl: (22) 404 Not Found", 22)
@ -513,7 +526,7 @@ func TestModuleAptKey_Good_CommandFailure(t *testing.T) {
// --- apt_repository module ---
func TestModuleAptRepository_Good_AddRepository(t *testing.T) {
func TestModulesSvc_ModuleAptRepository_Good_AddRepository(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo.*sources\.list\.d`, "", "", 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"))
}
func TestModuleAptRepository_Good_RemoveRepository(t *testing.T) {
func TestModulesSvc_ModuleAptRepository_Good_RemoveRepository(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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"))
}
func TestModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
func TestModulesSvc_ModuleAptRepository_Good_AddWithUpdateCache(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 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`))
}
func TestModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
func TestModulesSvc_ModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
@ -582,7 +595,7 @@ func TestModuleAptRepository_Good_AddWithoutUpdateCache(t *testing.T) {
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")
mock.expectCommand(`echo`, "", "", 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"))
}
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
e, mock := newTestExecutorWithMock("host1")
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/"))
}
func TestModuleAptRepository_Bad_MissingRepo(t *testing.T) {
func TestModulesSvc_ModuleAptRepository_Bad_MissingRepo(t *testing.T) {
e, _ := newTestExecutorWithMock("host1")
mock := NewMockSSHClient()
@ -625,7 +638,7 @@ func TestModuleAptRepository_Bad_MissingRepo(t *testing.T) {
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")
mock.expectCommand(`echo`, "", "permission denied", 1)
@ -641,7 +654,7 @@ func TestModuleAptRepository_Good_WriteFailure(t *testing.T) {
// --- package module ---
func TestModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
func TestModulesSvc_ModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
// First command: which apt-get returns the path
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
@ -660,7 +673,7 @@ func TestModulePackage_Good_DetectAptAndDelegate(t *testing.T) {
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
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModulePackage_Good_RemovePackage(t *testing.T) {
func TestModulesSvc_ModulePackage_Good_RemovePackage(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
mock.expectCommand(`apt-get remove -y -qq nano`, "", "", 0)
@ -692,9 +705,113 @@ func TestModulePackage_Good_RemovePackage(t *testing.T) {
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 ---
func TestModulePip_Good_InstallPresent(t *testing.T) {
func TestModulesSvc_ModulePip_Good_InstallPresent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModulePip_Good_UninstallAbsent(t *testing.T) {
func TestModulesSvc_ModulePip_Good_UninstallAbsent(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModulePip_Good_UpgradeLatest(t *testing.T) {
func TestModulesSvc_ModulePip_Good_UpgradeLatest(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModulePip_Good_CustomExecutable(t *testing.T) {
func TestModulesSvc_ModulePip_Good_CustomExecutable(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
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")
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`))
}
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")
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")
}
func TestModulePip_Good_InstalledAlias(t *testing.T) {
func TestModulesSvc_ModulePip_Good_InstalledAlias(t *testing.T) {
// state=installed is an alias for present
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestModulePip_Good_RemovedAlias(t *testing.T) {
func TestModulesSvc_ModulePip_Good_RemovedAlias(t *testing.T) {
// state=removed is an alias for absent
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 uninstall -y boto3`, "", "", 0)
@ -814,7 +974,7 @@ func TestModulePip_Good_RemovedAlias(t *testing.T) {
// --- Cross-module dispatch tests ---
func TestExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchService(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchSystemd(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`systemctl daemon-reload`, "", "", 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`))
}
func TestExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchApt(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
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`))
}
func TestExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`curl.*gpg`, "", "", 0)
@ -892,7 +1052,7 @@ func TestExecuteModuleWithMock_Good_DispatchAptKey(t *testing.T) {
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`echo`, "", "", 0)
mock.expectCommand(`apt-get update`, "", "", 0)
@ -911,7 +1071,7 @@ func TestExecuteModuleWithMock_Good_DispatchAptRepository(t *testing.T) {
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`which apt-get`, "/usr/bin/apt-get", "", 0)
mock.expectCommand(`apt-get install -y -qq git`, "", "", 0)
@ -930,7 +1090,7 @@ func TestExecuteModuleWithMock_Good_DispatchPackage(t *testing.T) {
assert.True(t, result.Changed)
}
func TestExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
func TestModulesSvc_ExecuteModuleWithMock_Good_DispatchPip(t *testing.T) {
e, mock := newTestExecutorWithMock("host1")
mock.expectCommand(`pip3 install ansible`, "", "", 0)

970
parser.go

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,6 @@ package ansible
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@ -11,9 +10,9 @@ import (
// --- ParsePlaybook ---
func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
func TestParser_ParsePlaybook_Good_SimplePlay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: Configure webserver
@ -25,7 +24,7 @@ func TestParsePlaybook_Good_SimplePlay(t *testing.T) {
name: nginx
state: present
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
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"])
}
func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
func TestParser_ParsePlaybook_Good_MultiplePlays(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: Play one
@ -62,7 +61,7 @@ func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
debug:
msg: "Goodbye"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -76,9 +75,156 @@ func TestParsePlaybook_Good_MultiplePlays(t *testing.T) {
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()
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 := `---
- name: With vars
@ -91,7 +237,7 @@ func TestParsePlaybook_Good_WithVars(t *testing.T) {
debug:
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)
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"])
}
func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
func TestParser_ParsePlaybook_Good_PrePostTasks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: Full lifecycle
@ -122,7 +268,7 @@ func TestParsePlaybook_Good_PrePostTasks(t *testing.T) {
debug:
msg: "post"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
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)
}
func TestParsePlaybook_Good_Handlers(t *testing.T) {
func TestParser_ParsePlaybook_Good_Handlers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: With handlers
@ -155,7 +301,7 @@ func TestParsePlaybook_Good_Handlers(t *testing.T) {
name: nginx
state: restarted
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
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)
}
func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
func TestParser_ParsePlaybook_Good_ShellFreeForm(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: Shell tasks
@ -180,7 +326,7 @@ func TestParsePlaybook_Good_ShellFreeForm(t *testing.T) {
- name: Run raw command
command: ls -la /tmp
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
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"])
}
func TestParsePlaybook_Good_WithTags(t *testing.T) {
func TestParser_ParsePlaybook_Good_WithTags(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: Tagged play
@ -210,7 +356,7 @@ func TestParsePlaybook_Good_WithTags(t *testing.T) {
- debug
- always
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
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)
}
func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
func TestParser_ParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: With blocks
@ -241,7 +387,7 @@ func TestParsePlaybook_Good_BlockRescueAlways(t *testing.T) {
debug:
msg: "always"
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
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)
}
func TestParsePlaybook_Good_WithLoop(t *testing.T) {
func TestParser_ParsePlaybook_Good_WithLoop(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: Loop test
@ -273,7 +419,7 @@ func TestParsePlaybook_Good_WithLoop(t *testing.T) {
- curl
- git
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -286,9 +432,9 @@ func TestParsePlaybook_Good_WithLoop(t *testing.T) {
assert.Len(t, items, 3)
}
func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
func TestParser_ParsePlaybook_Good_RoleRefs(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: With roles
@ -301,7 +447,7 @@ func TestParsePlaybook_Good_RoleRefs(t *testing.T) {
tags:
- web
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
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)
}
func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
func TestParser_ParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: FQCN modules
@ -329,7 +475,7 @@ func TestParsePlaybook_Good_FullyQualifiedModules(t *testing.T) {
- name: Run shell
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)
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"])
}
func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
func TestParser_ParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: Conditional play
@ -358,7 +504,7 @@ func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
msg: "File 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)
plays, err := p.ParsePlaybook(path)
@ -368,11 +514,11 @@ func TestParsePlaybook_Good_RegisterAndWhen(t *testing.T) {
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()
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)
plays, err := p.ParsePlaybook(path)
@ -381,11 +527,11 @@ func TestParsePlaybook_Good_EmptyPlaybook(t *testing.T) {
assert.Empty(t, plays)
}
func TestParsePlaybook_Bad_InvalidYAML(t *testing.T) {
func TestParser_ParsePlaybook_Bad_InvalidYAML(t *testing.T) {
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)
_, err := p.ParsePlaybook(path)
@ -394,7 +540,7 @@ func TestParsePlaybook_Bad_InvalidYAML(t *testing.T) {
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())
_, err := p.ParsePlaybook("/nonexistent/playbook.yml")
@ -402,9 +548,9 @@ func TestParsePlaybook_Bad_FileNotFound(t *testing.T) {
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()
path := filepath.Join(dir, "playbook.yml")
path := joinPath(dir, "playbook.yml")
yaml := `---
- name: No facts
@ -412,7 +558,7 @@ func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
gather_facts: false
tasks: []
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
plays, err := p.ParsePlaybook(path)
@ -422,11 +568,33 @@ func TestParsePlaybook_Good_GatherFactsDisabled(t *testing.T) {
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 ---
func TestParseInventory_Good_SimpleInventory(t *testing.T) {
func TestParser_ParseInventory_Good_SimpleInventory(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
path := joinPath(dir, "inventory.yml")
yaml := `---
all:
@ -436,7 +604,7 @@ all:
web2:
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)
inv, err := p.ParseInventory(path)
@ -448,9 +616,32 @@ all:
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()
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 := `---
all:
@ -466,7 +657,7 @@ all:
db1:
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)
inv, err := p.ParseInventory(path)
@ -478,9 +669,44 @@ all:
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()
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 := `---
all:
@ -495,7 +721,7 @@ all:
ansible_host: 10.0.0.1
ansible_port: 2222
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
inv, err := p.ParseInventory(path)
@ -506,11 +732,11 @@ all:
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()
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)
_, err := p.ParseInventory(path)
@ -519,7 +745,7 @@ func TestParseInventory_Bad_InvalidYAML(t *testing.T) {
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())
_, err := p.ParseInventory("/nonexistent/inventory.yml")
@ -529,9 +755,9 @@ func TestParseInventory_Bad_FileNotFound(t *testing.T) {
// --- ParseTasks ---
func TestParseTasks_Good_TaskFile(t *testing.T) {
func TestParser_ParseTasks_Good_TaskFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.yml")
path := joinPath(dir, "tasks.yml")
yaml := `---
- name: First task
@ -541,7 +767,7 @@ func TestParseTasks_Good_TaskFile(t *testing.T) {
src: /tmp/a
dest: /tmp/b
`
require.NoError(t, os.WriteFile(path, []byte(yaml), 0644))
require.NoError(t, writeTestFile(path, []byte(yaml), 0644))
p := NewParser(dir)
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"])
}
func TestParseTasks_Bad_InvalidYAML(t *testing.T) {
func TestParser_ParseTasks_Bad_InvalidYAML(t *testing.T) {
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)
_, err := p.ParseTasks(path)
@ -566,9 +792,40 @@ func TestParseTasks_Bad_InvalidYAML(t *testing.T) {
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 ---
func TestGetHosts_Good_AllPattern(t *testing.T) {
func TestParser_GetHosts_Good_AllPattern(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{
@ -584,13 +841,13 @@ func TestGetHosts_Good_AllPattern(t *testing.T) {
assert.Contains(t, hosts, "host2")
}
func TestGetHosts_Good_LocalhostPattern(t *testing.T) {
func TestParser_GetHosts_Good_LocalhostPattern(t *testing.T) {
inv := &Inventory{All: &InventoryGroup{}}
hosts := GetHosts(inv, "localhost")
assert.Equal(t, []string{"localhost"}, hosts)
}
func TestGetHosts_Good_GroupPattern(t *testing.T) {
func TestParser_GetHosts_Good_GroupPattern(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Children: map[string]*InventoryGroup{
@ -615,7 +872,7 @@ func TestGetHosts_Good_GroupPattern(t *testing.T) {
assert.Contains(t, hosts, "web2")
}
func TestGetHosts_Good_SpecificHost(t *testing.T) {
func TestParser_GetHosts_Good_SpecificHost(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Children: map[string]*InventoryGroup{
@ -632,7 +889,39 @@ func TestGetHosts_Good_SpecificHost(t *testing.T) {
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{
All: &InventoryGroup{
Hosts: map[string]*Host{"top": {}},
@ -650,7 +939,7 @@ func TestGetHosts_Good_AllIncludesChildren(t *testing.T) {
assert.Contains(t, hosts, "child1")
}
func TestGetHosts_Bad_NoMatch(t *testing.T) {
func TestParser_GetHosts_Bad_NoMatch(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"host1": {}},
@ -661,7 +950,7 @@ func TestGetHosts_Bad_NoMatch(t *testing.T) {
assert.Empty(t, hosts)
}
func TestGetHosts_Bad_NilGroup(t *testing.T) {
func TestParser_GetHosts_Bad_NilGroup(t *testing.T) {
inv := &Inventory{All: nil}
hosts := GetHosts(inv, "all")
assert.Empty(t, hosts)
@ -669,15 +958,16 @@ func TestGetHosts_Bad_NilGroup(t *testing.T) {
// --- GetHostVars ---
func TestGetHostVars_Good_DirectHost(t *testing.T) {
func TestParser_GetHostVars_Good_DirectHost(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Vars: map[string]any{"global_var": "global"},
Hosts: map[string]*Host{
"myhost": {
AnsibleHost: "10.0.0.1",
AnsiblePort: 2222,
AnsibleUser: "deploy",
AnsibleHost: "10.0.0.1",
AnsiblePort: 2222,
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, 2222, vars["ansible_port"])
assert.Equal(t, "deploy", vars["ansible_user"])
assert.Equal(t, "secret", vars["ansible_become_password"])
assert.Equal(t, "global", vars["global_var"])
}
func TestGetHostVars_Good_InheritedGroupVars(t *testing.T) {
func TestParser_GetHostVars_Good_InheritedGroupVars(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Vars: map[string]any{"level": "all"},
@ -712,7 +1003,7 @@ func TestGetHostVars_Good_InheritedGroupVars(t *testing.T) {
assert.Equal(t, "prod", vars["env"])
}
func TestGetHostVars_Good_HostNotFound(t *testing.T) {
func TestParser_GetHostVars_Good_HostNotFound(t *testing.T) {
inv := &Inventory{
All: &InventoryGroup{
Hosts: map[string]*Host{"other": {}},
@ -725,7 +1016,7 @@ func TestGetHostVars_Good_HostNotFound(t *testing.T) {
// --- 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("command"))
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("service"))
assert.True(t, isModule("systemd"))
assert.True(t, isModule("rpm"))
assert.True(t, isModule("debug"))
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.copy"))
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
assert.True(t, isModule("community.general.ufw"))
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("foobar"))
}
// --- 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.copy", NormalizeModule("copy"))
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, "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 ---
func TestNewParser_Good(t *testing.T) {
func TestParser_NewParser_Good(t *testing.T) {
p := NewParser("/some/path")
assert.NotNil(t, p)
assert.Equal(t, "/some/path", p.basePath)

184
ssh.go
View file

@ -3,22 +3,23 @@ package ansible
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// SSHClient handles SSH connections to remote hosts.
//
// Example:
//
// client, _ := NewSSHClient(SSHConfig{Host: "web1"})
type SSHClient struct {
host string
port int
@ -34,6 +35,10 @@ type SSHClient struct {
}
// SSHConfig holds SSH connection configuration.
//
// Example:
//
// config := SSHConfig{Host: "web1", User: "deploy", Port: 22}
type SSHConfig struct {
Host string
Port int
@ -47,33 +52,41 @@ type SSHConfig struct {
}
// NewSSHClient creates a new SSH client.
func NewSSHClient(cfg SSHConfig) (*SSHClient, error) {
if cfg.Port == 0 {
cfg.Port = 22
//
// Example:
//
// client, err := NewSSHClient(SSHConfig{Host: "web1", User: "deploy"})
func NewSSHClient(config SSHConfig) (*SSHClient, error) {
if config.Port == 0 {
config.Port = 22
}
if cfg.User == "" {
cfg.User = "root"
if config.User == "" {
config.User = "root"
}
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
if config.Timeout == 0 {
config.Timeout = 30 * time.Second
}
client := &SSHClient{
host: cfg.Host,
port: cfg.Port,
user: cfg.User,
password: cfg.Password,
keyFile: cfg.KeyFile,
become: cfg.Become,
becomeUser: cfg.BecomeUser,
becomePass: cfg.BecomePass,
timeout: cfg.Timeout,
host: config.Host,
port: config.Port,
user: config.User,
password: config.Password,
keyFile: config.KeyFile,
become: config.Become,
becomeUser: config.BecomeUser,
becomePass: config.BecomePass,
timeout: config.Timeout,
}
return client, nil
}
// Connect establishes the SSH connection.
//
// Example:
//
// _ = client.Connect(context.Background())
func (c *SSHClient) Connect(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
@ -87,9 +100,8 @@ func (c *SSHClient) Connect(ctx context.Context) error {
// Try key-based auth first
if c.keyFile != "" {
keyPath := c.keyFile
if strings.HasPrefix(keyPath, "~") {
home, _ := os.UserHomeDir()
keyPath = filepath.Join(home, keyPath[1:])
if corexHasPrefix(keyPath, "~") {
keyPath = joinPath(env("DIR_HOME"), keyPath[1:])
}
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
if len(authMethods) == 0 {
home, _ := os.UserHomeDir()
home := env("DIR_HOME")
defaultKeys := []string{
filepath.Join(home, ".ssh", "id_ed25519"),
filepath.Join(home, ".ssh", "id_rsa"),
joinPath(home, ".ssh", "id_ed25519"),
joinPath(home, ".ssh", "id_rsa"),
}
for _, keyPath := range defaultKeys {
if key, err := coreio.Local.Read(keyPath); err == nil {
@ -135,15 +147,15 @@ func (c *SSHClient) Connect(ctx context.Context) error {
// Host key verification
var hostKeyCallback ssh.HostKeyCallback
home, err := os.UserHomeDir()
if err != nil {
return coreerr.E("ssh.Connect", "failed to get user home dir", err)
home := env("DIR_HOME")
if home == "" {
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
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)
}
if err := coreio.Local.Write(knownHostsPath, ""); err != nil {
@ -164,19 +176,19 @@ func (c *SSHClient) Connect(ctx context.Context) error {
Timeout: c.timeout,
}
addr := fmt.Sprintf("%s:%d", c.host, c.port)
addr := sprintf("%s:%d", c.host, c.port)
// Connect with context timeout
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return 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)
if err != nil {
// 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)
@ -184,6 +196,10 @@ func (c *SSHClient) Connect(ctx context.Context) error {
}
// Close closes the SSH connection.
//
// Example:
//
// _ = client.Close()
func (c *SSHClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
@ -196,7 +212,22 @@ func (c *SSHClient) Close() error {
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.
//
// 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) {
if err := c.Connect(ctx); err != nil {
return "", "", -1, err
@ -219,33 +250,33 @@ func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string,
becomeUser = "root"
}
// Escape single quotes in the command
escapedCmd := strings.ReplaceAll(cmd, "'", "'\\''")
escapedCmd := replaceAll(cmd, "'", "'\\''")
if c.becomePass != "" {
// Use sudo with password via stdin (-S flag)
// We launch a goroutine to write the password to stdin
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
stdin, err := session.StdinPipe()
if err != nil {
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
}
go func() {
defer func() { _ = stdin.Close() }()
_, _ = io.WriteString(stdin, c.becomePass+"\n")
writeString(stdin, c.becomePass+"\n")
}()
} else if c.password != "" {
// Try using connection password for sudo
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
cmd = sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
stdin, err := session.StdinPipe()
if err != nil {
return "", "", -1, coreerr.E("ssh.Run", "stdin pipe", err)
}
go func() {
defer func() { _ = stdin.Close() }()
_, _ = io.WriteString(stdin, c.password+"\n")
writeString(stdin, c.password+"\n")
}()
} else {
// 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.
//
// 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) {
// 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)
}
// 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 {
return err
}
// Read content
content, err := io.ReadAll(local)
content, err := readAllString(local)
if err != nil {
return coreerr.E("ssh.Upload", "read content", err)
}
// Create parent directory
dir := filepath.Dir(remote)
dirCmd := fmt.Sprintf("mkdir -p %q", dir)
dir := pathDir(remote)
dirCmd := sprintf("mkdir -p %q", dir)
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 {
return coreerr.E("ssh.Upload", "create parent dir", err)
}
// Use cat to write the file (simpler than SCP)
writeCmd := fmt.Sprintf("cat > %q && chmod %o %q", remote, mode, remote)
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
// 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 != "" {
// 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)
} else {
// Use passwordless sudo (sudo -n) to avoid consuming file content as password
writeCmd = fmt.Sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
writeCmd = sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
becomeUser, remote, mode, remote)
}
@ -350,9 +389,9 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
go func() {
defer func() { _ = stdin.Close() }()
if pass != "" {
_, _ = io.WriteString(stdin, pass+"\n")
writeString(stdin, pass+"\n")
}
_, _ = stdin.Write(content)
_, _ = stdin.Write([]byte(content))
}()
} else {
// Normal write
@ -362,39 +401,47 @@ func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string,
go func() {
defer func() { _ = stdin.Close() }()
_, _ = stdin.Write(content)
_, _ = stdin.Write([]byte(content))
}()
}
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
}
// 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) {
if err := c.Connect(ctx); err != nil {
return nil, err
}
cmd := fmt.Sprintf("cat %q", remote)
cmd := sprintf("cat %q", remote)
stdout, stderr, exitCode, err := c.Run(ctx, cmd)
if err != nil {
return nil, err
}
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
}
// 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) {
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)
if err != nil {
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
return false, nil
}
return strings.TrimSpace(stdout) == "yes", nil
return corexTrimSpace(stdout) == "yes", nil
}
// 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) {
// Simple approach - get basic file info
cmd := fmt.Sprintf(`
cmd := sprintf(`
if [ -e %q ]; then
if [ -d %q ]; then
echo "exists=true isdir=true"
@ -427,9 +478,9 @@ fi
}
result := make(map[string]any)
parts := strings.Fields(strings.TrimSpace(stdout))
parts := fields(corexTrimSpace(stdout))
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
kv := splitN(part, "=", 2)
if len(kv) == 2 {
result[kv[0]] = kv[1] == "true"
}
@ -439,10 +490,19 @@ fi
}
// SetBecome enables privilege escalation.
//
// Example:
//
// client.SetBecome(true, "root", "")
func (c *SSHClient) 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
}

View file

@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestNewSSHClient(t *testing.T) {
func TestSSH_NewSSHClient_Good_CustomConfig(t *testing.T) {
cfg := SSHConfig{
Host: "localhost",
Port: 2222,
@ -23,7 +23,7 @@ func TestNewSSHClient(t *testing.T) {
assert.Equal(t, 30*time.Second, client.timeout)
}
func TestSSHConfig_Defaults(t *testing.T) {
func TestSSH_NewSSHClient_Good_Defaults(t *testing.T) {
cfg := SSHConfig{
Host: "localhost",
}
@ -34,3 +34,19 @@ func TestSSHConfig_Defaults(t *testing.T) {
assert.Equal(t, "root", client.user)
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 (
"time"
coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// Playbook represents an Ansible playbook.
//
// Example:
//
// playbook := Playbook{Plays: []Play{{Name: "Bootstrap", Hosts: "all"}}}
type Playbook struct {
Plays []Play `yaml:",inline"`
}
// Play represents a single play in a playbook.
//
// Example:
//
// play := Play{Name: "Configure web", Hosts: "webservers", Become: true}
type Play struct {
Name string `yaml:"name"`
Hosts string `yaml:"hosts"`
Connection string `yaml:"connection,omitempty"`
Become bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
GatherFacts *bool `yaml:"gather_facts,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
PreTasks []Task `yaml:"pre_tasks,omitempty"`
Tasks []Task `yaml:"tasks,omitempty"`
PostTasks []Task `yaml:"post_tasks,omitempty"`
Roles []RoleRef `yaml:"roles,omitempty"`
Handlers []Task `yaml:"handlers,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
Serial any `yaml:"serial,omitempty"` // int or string
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
Name string `yaml:"name"`
Hosts string `yaml:"hosts"`
ImportPlaybook string `yaml:"import_playbook,omitempty"`
Connection string `yaml:"connection,omitempty"`
Become bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
GatherFacts *bool `yaml:"gather_facts,omitempty"`
ForceHandlers bool `yaml:"force_handlers,omitempty"`
AnyErrorsFatal bool `yaml:"any_errors_fatal,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
VarsFiles any `yaml:"vars_files,omitempty"` // string or []string
ModuleDefaults map[string]map[string]any `yaml:"module_defaults,omitempty"`
PreTasks []Task `yaml:"pre_tasks,omitempty"`
Tasks []Task `yaml:"tasks,omitempty"`
PostTasks []Task `yaml:"post_tasks,omitempty"`
Roles []RoleRef `yaml:"roles,omitempty"`
Handlers []Task `yaml:"handlers,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
Serial any `yaml:"serial,omitempty"` // int or string
MaxFailPercent int `yaml:"max_fail_percentage,omitempty"`
}
// 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.
//
// Example:
//
// role := RoleRef{Role: "nginx", TasksFrom: "install.yml"}
type RoleRef struct {
Role string `yaml:"role,omitempty"`
Name string `yaml:"name,omitempty"` // Alternative to role
TasksFrom string `yaml:"tasks_from,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
When any `yaml:"when,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Role string `yaml:"role,omitempty"`
Name string `yaml:"name,omitempty"` // Alternative to role
TasksFrom string `yaml:"tasks_from,omitempty"`
DefaultsFrom string `yaml:"defaults_from,omitempty"`
VarsFrom string `yaml:"vars_from,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.
//
// Example:
//
// var ref RoleRef
// _ = yaml.Unmarshal([]byte("common"), &ref)
func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
// Try string first
var s string
@ -61,53 +112,77 @@ func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error {
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.
//
// Example:
//
// task := Task{Name: "Install nginx", Module: "apt", Args: map[string]any{"name": "nginx"}}
type Task struct {
Name string `yaml:"name,omitempty"`
Module string `yaml:"-"` // Derived from the module key
Args map[string]any `yaml:"-"` // Module arguments
Register string `yaml:"register,omitempty"`
When any `yaml:"when,omitempty"` // string or []string
Loop any `yaml:"loop,omitempty"` // string or []any
LoopControl *LoopControl `yaml:"loop_control,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
ChangedWhen any `yaml:"changed_when,omitempty"`
FailedWhen any `yaml:"failed_when,omitempty"`
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
NoLog bool `yaml:"no_log,omitempty"`
Become *bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
Delegate string `yaml:"delegate_to,omitempty"`
RunOnce bool `yaml:"run_once,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Block []Task `yaml:"block,omitempty"`
Rescue []Task `yaml:"rescue,omitempty"`
Always []Task `yaml:"always,omitempty"`
Notify any `yaml:"notify,omitempty"` // string or []string
Retries int `yaml:"retries,omitempty"`
Delay int `yaml:"delay,omitempty"`
Until string `yaml:"until,omitempty"`
Name string `yaml:"name,omitempty"`
Module string `yaml:"-"` // Derived from the module key
Args map[string]any `yaml:"-"` // Module arguments
Register string `yaml:"register,omitempty"`
When any `yaml:"when,omitempty"` // string or []string
CheckMode *bool `yaml:"check_mode,omitempty"`
Diff *bool `yaml:"diff,omitempty"`
Loop any `yaml:"loop,omitempty"` // string or []any
LoopControl *LoopControl `yaml:"loop_control,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
Environment map[string]string `yaml:"environment,omitempty"`
ChangedWhen any `yaml:"changed_when,omitempty"`
FailedWhen any `yaml:"failed_when,omitempty"`
IgnoreErrors bool `yaml:"ignore_errors,omitempty"`
NoLog bool `yaml:"no_log,omitempty"`
Become *bool `yaml:"become,omitempty"`
BecomeUser string `yaml:"become_user,omitempty"`
Delegate string `yaml:"delegate_to,omitempty"`
DelegateFacts bool `yaml:"delegate_facts,omitempty"`
RunOnce bool `yaml:"run_once,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Block []Task `yaml:"block,omitempty"`
Rescue []Task `yaml:"rescue,omitempty"`
Always []Task `yaml:"always,omitempty"`
Notify any `yaml:"notify,omitempty"` // string or []string
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
IncludeTasks string `yaml:"include_tasks,omitempty"`
ImportTasks string `yaml:"import_tasks,omitempty"`
IncludeRole *struct {
Name string `yaml:"name"`
TasksFrom string `yaml:"tasks_from,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
} `yaml:"include_role,omitempty"`
ImportRole *struct {
Name string `yaml:"name"`
TasksFrom string `yaml:"tasks_from,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
} `yaml:"import_role,omitempty"`
IncludeTasks string `yaml:"include_tasks,omitempty"`
ImportTasks string `yaml:"import_tasks,omitempty"`
Apply *TaskApply `yaml:"apply,omitempty"`
WithFile any `yaml:"with_file,omitempty"`
WithFileGlob any `yaml:"with_fileglob,omitempty"`
WithSequence any `yaml:"with_sequence,omitempty"`
WithTogether any `yaml:"with_together,omitempty"`
WithSubelements any `yaml:"with_subelements,omitempty"`
IncludeRole *RoleRef `yaml:"include_role,omitempty"`
ImportRole *RoleRef `yaml:"import_role,omitempty"`
// Raw YAML for module extraction
raw map[string]any
}
// LoopControl controls loop behavior.
// LoopControl controls loop behaviour.
//
// Example:
//
// loop := LoopControl{LoopVar: "item", IndexVar: "idx"}
type LoopControl struct {
LoopVar string `yaml:"loop_var,omitempty"`
IndexVar string `yaml:"index_var,omitempty"`
@ -116,7 +191,30 @@ type LoopControl struct {
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.
//
// Example:
//
// result := TaskResult{Changed: true, Stdout: "ok"}
type TaskResult struct {
Changed bool `json:"changed"`
Failed bool `json:"failed"`
@ -131,11 +229,116 @@ type TaskResult struct {
}
// Inventory represents Ansible inventory.
//
// Example:
//
// inventory := Inventory{All: &InventoryGroup{Hosts: map[string]*Host{"web1": {AnsibleHost: "10.0.0.1"}}}}
type Inventory struct {
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.
//
// Example:
//
// group := InventoryGroup{Hosts: map[string]*Host{"db1": {AnsibleHost: "10.0.1.10"}}}
type InventoryGroup struct {
Hosts map[string]*Host `yaml:"hosts,omitempty"`
Children map[string]*InventoryGroup `yaml:"children,omitempty"`
@ -143,6 +346,10 @@ type InventoryGroup struct {
}
// Host represents a host in inventory.
//
// Example:
//
// host := Host{AnsibleHost: "192.168.1.10", AnsibleUser: "deploy"}
type Host struct {
AnsibleHost string `yaml:"ansible_host,omitempty"`
AnsiblePort int `yaml:"ansible_port,omitempty"`
@ -157,20 +364,32 @@ type Host struct {
}
// Facts holds gathered facts about a host.
//
// Example:
//
// facts := Facts{Hostname: "web1", Distribution: "Ubuntu", Kernel: "Linux"}
type Facts struct {
Hostname string `json:"ansible_hostname"`
FQDN string `json:"ansible_fqdn"`
OS string `json:"ansible_os_family"`
Distribution string `json:"ansible_distribution"`
Version string `json:"ansible_distribution_version"`
Architecture string `json:"ansible_architecture"`
Kernel string `json:"ansible_kernel"`
Memory int64 `json:"ansible_memtotal_mb"`
CPUs int `json:"ansible_processor_vcpus"`
IPv4 string `json:"ansible_default_ipv4_address"`
Hostname string `json:"ansible_hostname"`
FQDN string `json:"ansible_fqdn"`
OS string `json:"ansible_os_family"`
Distribution string `json:"ansible_distribution"`
Version string `json:"ansible_distribution_version"`
Architecture string `json:"ansible_architecture"`
Kernel string `json:"ansible_kernel"`
VirtualizationRole string `json:"ansible_virtualization_role"`
VirtualizationType string `json:"ansible_virtualization_type"`
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{
// Builtin
"ansible.builtin.shell",
@ -181,6 +400,7 @@ var KnownModules = []string{
"ansible.builtin.template",
"ansible.builtin.file",
"ansible.builtin.lineinfile",
"ansible.builtin.replace",
"ansible.builtin.blockinfile",
"ansible.builtin.stat",
"ansible.builtin.slurp",
@ -192,6 +412,7 @@ var KnownModules = []string{
"ansible.builtin.apt_repository",
"ansible.builtin.yum",
"ansible.builtin.dnf",
"ansible.builtin.rpm",
"ansible.builtin.package",
"ansible.builtin.pip",
"ansible.builtin.service",
@ -205,14 +426,25 @@ var KnownModules = []string{
"ansible.builtin.debug",
"ansible.builtin.fail",
"ansible.builtin.assert",
"ansible.builtin.ping",
"ansible.builtin.pause",
"ansible.builtin.wait_for",
"ansible.builtin.wait_for_connection",
"ansible.builtin.set_fact",
"ansible.builtin.include_vars",
"ansible.builtin.add_host",
"ansible.builtin.group_by",
"ansible.builtin.meta",
"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)
"shell",
@ -223,6 +455,7 @@ var KnownModules = []string{
"template",
"file",
"lineinfile",
"replace",
"blockinfile",
"stat",
"slurp",
@ -234,6 +467,7 @@ var KnownModules = []string{
"apt_repository",
"yum",
"dnf",
"rpm",
"package",
"pip",
"service",
@ -247,12 +481,39 @@ var KnownModules = []string{
"debug",
"fail",
"assert",
"ping",
"pause",
"wait_for",
"wait_for_connection",
"set_fact",
"include_vars",
"add_host",
"group_by",
"meta",
"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 ---
func TestRoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
func TestTypes_RoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
input := `common`
var ref RoleRef
err := yaml.Unmarshal([]byte(input), &ref)
@ -19,7 +19,7 @@ func TestRoleRef_UnmarshalYAML_Good_StringForm(t *testing.T) {
assert.Equal(t, "common", ref.Role)
}
func TestRoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
func TestTypes_RoleRef_UnmarshalYAML_Good_StructForm(t *testing.T) {
input := `
role: webserver
vars:
@ -36,7 +36,7 @@ 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:"
input := `
name: myapp
@ -50,7 +50,7 @@ tasks_from: install.yml
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 := `
role: conditional_role
when: ansible_os_family == "Debian"
@ -63,9 +63,28 @@ when: ansible_os_family == "Debian"
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 ---
func TestTask_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
func TestTypes_Task_UnmarshalYAML_Good_ModuleWithArgs(t *testing.T) {
input := `
name: Install nginx
apt:
@ -82,7 +101,7 @@ apt:
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 := `
name: Run command
shell: echo hello world
@ -95,7 +114,7 @@ shell: echo hello world
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 := `
name: Gather facts
setup:
@ -108,7 +127,7 @@ setup:
assert.NotNil(t, task.Args)
}
func TestTask_UnmarshalYAML_Good_WithRegister(t *testing.T) {
func TestTypes_Task_UnmarshalYAML_Good_WithRegister(t *testing.T) {
input := `
name: Check file
stat:
@ -123,7 +142,7 @@ register: stat_result
assert.Equal(t, "stat", task.Module)
}
func TestTask_UnmarshalYAML_Good_WithWhen(t *testing.T) {
func TestTypes_Task_UnmarshalYAML_Good_WithWhen(t *testing.T) {
input := `
name: Conditional task
debug:
@ -137,7 +156,24 @@ when: some_var is defined
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 := `
name: Install packages
apt:
@ -156,7 +192,7 @@ loop:
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
input := `
name: Old-style loop
@ -176,7 +212,324 @@ with_items:
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 := `
name: Install package
apt:
@ -190,7 +543,116 @@ notify: restart nginx
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 := `
name: Install package
apt:
@ -208,7 +670,7 @@ notify:
assert.Len(t, notifyList, 2)
}
func TestTask_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
func TestTypes_Task_UnmarshalYAML_Good_IncludeTasks(t *testing.T) {
input := `
name: Include tasks
include_tasks: other-tasks.yml
@ -220,12 +682,76 @@ include_tasks: other-tasks.yml
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 := `
name: Include role
include_role:
name: common
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
err := yaml.Unmarshal([]byte(input), &task)
@ -234,9 +760,75 @@ include_role:
require.NotNil(t, task.IncludeRole)
assert.Equal(t, "common", task.IncludeRole.Name)
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 := `
name: Privileged task
shell: systemctl restart nginx
@ -252,7 +844,7 @@ become_user: root
assert.Equal(t, "root", task.BecomeUser)
}
func TestTask_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
func TestTypes_Task_UnmarshalYAML_Good_IgnoreErrors(t *testing.T) {
input := `
name: Might fail
shell: some risky command
@ -267,7 +859,7 @@ ignore_errors: true
// --- Inventory data structure ---
func TestInventory_UnmarshalYAML_Good_Complex(t *testing.T) {
func TestTypes_Inventory_UnmarshalYAML_Good_Complex(t *testing.T) {
input := `
all:
vars:
@ -317,31 +909,35 @@ all:
// --- Facts ---
func TestFacts_Struct(t *testing.T) {
func TestTypes_Facts_Good_Struct(t *testing.T) {
facts := Facts{
Hostname: "web1",
FQDN: "web1.example.com",
OS: "Debian",
Distribution: "ubuntu",
Version: "24.04",
Architecture: "x86_64",
Kernel: "6.8.0",
Memory: 16384,
CPUs: 4,
IPv4: "10.0.0.1",
Hostname: "web1",
FQDN: "web1.example.com",
OS: "Debian",
Distribution: "ubuntu",
Version: "24.04",
Architecture: "x86_64",
Kernel: "6.8.0",
VirtualizationRole: "guest",
VirtualizationType: "docker",
Memory: 16384,
CPUs: 4,
IPv4: "10.0.0.1",
}
assert.Equal(t, "web1", facts.Hostname)
assert.Equal(t, "web1.example.com", facts.FQDN)
assert.Equal(t, "ubuntu", facts.Distribution)
assert.Equal(t, "x86_64", facts.Architecture)
assert.Equal(t, "guest", facts.VirtualizationRole)
assert.Equal(t, "docker", facts.VirtualizationType)
assert.Equal(t, int64(16384), facts.Memory)
assert.Equal(t, 4, facts.CPUs)
}
// --- TaskResult ---
func TestTaskResult_Struct(t *testing.T) {
func TestTypes_TaskResult_Good_Struct(t *testing.T) {
result := TaskResult{
Changed: true,
Failed: false,
@ -358,7 +954,7 @@ func TestTaskResult_Struct(t *testing.T) {
assert.Equal(t, 0, result.RC)
}
func TestTaskResult_WithLoopResults(t *testing.T) {
func TestTypes_TaskResult_Good_WithLoopResults(t *testing.T) {
result := TaskResult{
Changed: true,
Results: []TaskResult{
@ -375,7 +971,7 @@ func TestTaskResult_WithLoopResults(t *testing.T) {
// --- KnownModules ---
func TestKnownModules_ContainsExpected(t *testing.T) {
func TestTypes_KnownModules_Good_ContainsExpected(t *testing.T) {
// Verify both FQCN and short forms are present
fqcnModules := []string{
"ansible.builtin.shell",
@ -385,8 +981,19 @@ func TestKnownModules_ContainsExpected(t *testing.T) {
"ansible.builtin.apt",
"ansible.builtin.service",
"ansible.builtin.systemd",
"ansible.builtin.rpm",
"ansible.builtin.debug",
"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 {
assert.Contains(t, KnownModules, mod, "expected FQCN module %s", mod)
@ -394,7 +1001,7 @@ func TestKnownModules_ContainsExpected(t *testing.T) {
shortModules := []string{
"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 {
assert.Contains(t, KnownModules, mod, "expected short-form module %s", mod)