Compare commits

..

129 commits
0.0.4 ... main

Author SHA1 Message Date
b149b2e5d7
Remove salt
All checks were successful
CI / test (push) Successful in 18m7s
Lint / test (push) Successful in 41s
2026-06-17 18:13:06 +10:00
ebc27e1111
Support for detecting Docker images
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-17 18:05:02 +10:00
e2be9a6239
Separate up the ansible renderer. Simplify the package management bits by using ansible.builtin.package
All checks were successful
CI / test (push) Successful in 22m12s
Lint / test (push) Successful in 44s
2026-06-17 16:40:36 +10:00
e448994470
No sudo needed in the CI test
All checks were successful
CI / test (push) Successful in 25m53s
Lint / test (push) Successful in 44s
2026-06-17 14:29:16 +10:00
845f8d9ad1
Refactor tests.sh
Some checks failed
CI / test (push) Failing after 8s
Lint / test (push) Successful in 44s
2026-06-17 14:25:41 +10:00
c7e3b94355
Add separate pytests.sh script for local use 2026-06-17 14:22:57 +10:00
ee08bf43ba
Support manifesting Salt 2026-06-17 14:19:25 +10:00
ceca3df83c
Fix hiera/fqdn support for Puppet
All checks were successful
CI / test (push) Successful in 16m41s
Lint / test (push) Successful in 48s
2026-06-17 11:47:47 +10:00
20cc48e1ce
More refactoring, support hiera and multi site mode for Puppet
All checks were successful
CI / test (push) Successful in 15m30s
Lint / test (push) Successful in 44s
2026-06-17 10:54:46 +10:00
ed9ec6893a
Try to resolve circular imports
All checks were successful
CI / test (push) Successful in 15m37s
Lint / test (push) Successful in 44s
2026-06-17 09:51:47 +10:00
de7531424d
Huge refactor to support extending a generic Config Manager class for different types (Ansible, Puppet... Salt soon?)
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-17 09:37:32 +10:00
5e6c8e6455
remove sudo call
All checks were successful
CI / test (push) Successful in 14m27s
Lint / test (push) Successful in 42s
2026-06-16 16:53:28 +10:00
3c84b3c070
Test puppet run
Some checks failed
CI / test (push) Failing after 3m29s
Lint / test (push) Successful in 42s
2026-06-16 16:47:47 +10:00
380a0b8ca2
Update the puppet reserved names
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-16 16:44:10 +10:00
33b9d44c55
Output the AppImage into the dir it's going into anyway
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-16 16:39:28 +10:00
f9e93cd6fd
Support manifesting Puppet :o 2026-06-16 16:39:18 +10:00
e682aae41e
Filter out more sysctl params that throw Invalid argument when executed on the fly 2026-06-16 16:30:33 +10:00
9546e1b8ed
Add sysctl detection 2026-06-16 14:23:44 +10:00
3c19ae54b2
Only capture user-specific .bashrc style files when using mode, in case they contain sensitive env vars.
All checks were successful
CI / test (push) Successful in 14m0s
Lint / test (push) Successful in 42s
2026-06-16 13:35:33 +10:00
8774d019d3
Fix tests
All checks were successful
CI / test (push) Successful in 14m26s
Lint / test (push) Successful in 43s
2026-06-14 19:21:32 +10:00
1e996f4a43
Group all package roles into Debian/RPM 'sections'
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
This includes managed config files and unit state.

This mode is not used if `--fqdn` or `--no-common-roles` is set,
in which case, the traditional behaviour of preserving one role
per package/unit is used instead.

This is a breaking change.
2026-06-14 19:19:59 +10:00
e2339616fb
remove flatpak tests which don't work great in CI 2026-06-14 18:49:26 +10:00
00329cdd33
install flatpak in test
Some checks failed
CI / test (push) Failing after 5m15s
Lint / test (push) Successful in 42s
2026-06-14 18:42:28 +10:00
9dfbd411de
Add some flatpak tests
Some checks failed
CI / test (push) Failing after 3m5s
Lint / test (push) Has been cancelled
2026-06-14 18:37:48 +10:00
8f425b595b
Remove newlines
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-14 18:34:22 +10:00
eb1d096c90
Add support for detecting flatpaks and snaps
Some checks failed
CI / test (push) Failing after 5m51s
Lint / test (push) Successful in 43s
2026-06-14 18:25:26 +10:00
11351cce87
Fix test 2026-06-14 16:23:06 +10:00
bbfc338734
Fix regression that enforced merge_simple_packages
Some checks failed
CI / test (push) Failing after 10m40s
Lint / test (push) Successful in 38s
2026-06-14 16:03:52 +10:00
76df10ee92
Add --merge-simple-packages to reduce the number of roles, for packages that have no config files or services to maintain.
Some checks failed
CI / test (push) Failing after 5m32s
Lint / test (push) Successful in 40s
2026-06-14 15:52:07 +10:00
a0fbed5ca5
Fix curl option
All checks were successful
CI / test (push) Successful in 11m55s
Lint / test (push) Successful in 41s
2026-06-07 14:44:36 +10:00
6c58beddfe
Attempt to install SOPS in tests
Some checks failed
CI / test (push) Failing after 2m11s
Lint / test (push) Successful in 41s
2026-06-07 14:40:08 +10:00
fbb06f1177
More coverage
Some checks failed
Lint / test (push) Successful in 45s
CI / test (push) Failing after 2m35s
2026-05-31 17:55:22 +10:00
62b2f2ffe6
More coverage
Some checks failed
CI / test (push) Failing after 1s
Lint / test (push) Failing after 1s
2026-05-31 17:21:45 +10:00
bf735c8328
More coverage
Some checks failed
CI / test (push) Failing after 1s
Lint / test (push) Failing after 1s
2026-05-31 17:15:22 +10:00
1544dc0295
more test coverage 2026-05-31 16:50:57 +10:00
b25dd1e314
* Add support for capturing ipset and iptables configuration files
All checks were successful
CI / test (push) Successful in 8m23s
Lint / test (push) Successful in 33s
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present (`firewall_runtime` role)
 * Dependency updates
2026-05-14 15:16:36 +10:00
3fcfefe644
0.5.0
All checks were successful
CI / test (push) Successful in 8m28s
Lint / test (push) Successful in 1m5s
2026-05-12 12:24:00 +10:00
618dd20e7c
Update deps 2026-05-12 12:23:52 +10:00
5695f4258e
Add support for ssh configs as templates, via JinjaTurtle 2026-05-12 12:23:41 +10:00
5c686d27cc
Remove trivy..
All checks were successful
CI / test (push) Successful in 8m16s
Lint / test (push) Successful in 33s
2026-03-23 11:20:56 +11:00
4ea7267b92
Update my GPG key
All checks were successful
CI / test (push) Successful in 8m26s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 25s
2026-03-11 12:02:39 +11:00
d403dcb918
0.4.4
All checks were successful
CI / test (push) Successful in 8m14s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2026-02-17 10:58:38 +11:00
778237740a
Add ability to gracefully handle an encrypted private key for SSH (can be forced or automated with an env var too)
All checks were successful
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2026-02-17 10:35:51 +11:00
87ddf52e81
Update cryptography dependency
All checks were successful
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 26s
2026-02-17 10:00:39 +11:00
5f6b0f49d9
Update dependencies
All checks were successful
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 26s
2026-01-16 10:59:22 +11:00
1856e3a79d
Add support for AddressFamily and ConnectTimeout in the .ssh/config when using --remote-ssh-config. 2026-01-16 10:58:39 +11:00
478b0e1b9d
Add README example for --remote-ssh-config
All checks were successful
CI / test (push) Successful in 8m19s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 25s
2026-01-13 22:03:58 +11:00
f5eaac9f75
Support --remote-ssh-config [path-to-ssh-config] as an argument in case extra params are required beyond --remote-port or --remote-user.
All checks were successful
CI / test (push) Successful in 8m18s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 25s
Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
2026-01-13 21:56:28 +11:00
5754ef1aad
Add interactive output when 'enroll diff --enforce' is invoking Ansible.
All checks were successful
CI / test (push) Successful in 8m18s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2026-01-11 10:01:16 +11:00
d172d848c4
Relax python3-jsonschema version for Fedora support
All checks were successful
CI / test (push) Successful in 8m16s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 24s
2026-01-10 11:44:51 +11:00
f84d795c49
Rename test file
All checks were successful
CI / test (push) Successful in 8m15s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 24s
2026-01-10 11:24:01 +11:00
95b784c1a0
Fix and add tests
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-10 11:16:28 +11:00
ebd30247d1
Add --enforce mode to enroll diff and add --ignore-package-versions
Some checks failed
CI / test (push) Failing after 1m48s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 22s
If there is diff detected between the two harvests, and it can
enforce restoring the state from the older harvest, it will
manifest the state and apply it with ansible. Only the specific
roles that had diffed will be applied (via the new tags capability).

`--ignore-package-versions` will skip reporting when packages are
upgraded/downgraded in the diff.
2026-01-10 10:51:41 +11:00
9a249cc973
Initial pass at an --enforce mode for enroll diff, to manifest and restore state of old harvest if ansible is on the PATH
All checks were successful
CI / test (push) Successful in 8m13s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 23s
2026-01-10 09:50:28 +11:00
9749190cd8
Fix test
All checks were successful
CI / test (push) Successful in 8m14s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 21s
2026-01-10 09:15:29 +11:00
ca3d958a96
Add --exclude-path to enroll diff command
Some checks failed
CI / test (push) Failing after 1m45s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
So that you can ignore certain churn from the diff

(stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
2026-01-10 08:56:35 +11:00
8be821c494
Update pynacl dependency to resolve CVE-2025-69277
All checks were successful
CI / test (push) Successful in 8m1s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-08 17:16:58 +11:00
8daed96b7c
Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
All checks were successful
CI / test (push) Successful in 8m13s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-06 12:47:12 +11:00
e0ef5ede98
Run validate in CLI tests
All checks were successful
CI / test (push) Successful in 8m28s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 26s
2026-01-05 21:30:14 +11:00
025f00f924
Fix tests
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-05 21:25:46 +11:00
66d032d981
Introduce 'enroll validate' to check a harvest meets the schema spec and isn't lacking artifacts or contains orphaned ones
Some checks failed
CI / test (push) Failing after 1m47s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-05 21:17:50 +11:00
45e0d9bb16
0.3.0
All checks were successful
CI / test (push) Successful in 8m25s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-05 17:13:43 +11:00
9f30c56e8a
Don't remove apache2 (it breaks the manifest run)
Some checks failed
CI / test (push) Successful in 8m22s
Lint / test (push) Successful in 31s
Trivy / test (push) Has been cancelled
2026-01-05 17:04:06 +11:00
7a9a0abcd1
Add tests for symlinks management
Some checks failed
CI / test (push) Failing after 7m32s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-05 16:54:39 +11:00
aea58c8684
Install Apache2 to test symlinks management in the ansible manifests
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-05 16:48:08 +11:00
ca4cf00e84
Changelog entry for symlinks
All checks were successful
CI / test (push) Successful in 7m36s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-05 16:30:14 +11:00
d3fdfc9ef7
Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-05 16:29:21 +11:00
bcf3dd7422
Fix tests
All checks were successful
CI / test (push) Successful in 7m18s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 22s
2026-01-05 15:52:25 +11:00
91ec1b8791
Ignore files ending in - in the /etc/ dir e.g /etc/shadow-
Some checks failed
CI / test (push) Failing after 1m43s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 23s
2026-01-05 15:48:17 +11:00
b5e32770a3
Ignore files that end with a tilde (probably backup files generated by editors) 2026-01-05 15:23:45 +11:00
e04b158c39
Fix non-interactive test
All checks were successful
CI / test (push) Successful in 7m19s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-05 15:06:20 +11:00
a1433d645f
Capture other files in the user's home directory
Some checks failed
CI / test (push) Failing after 1m57s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 27s
Such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
2026-01-05 15:02:22 +11:00
e68ec0bffc
More test coverage 2026-01-05 14:27:56 +11:00
24cedc8c8d
Centralise the cron and logrotate stuff into their respective roles.
All checks were successful
CI / test (push) Successful in 7m52s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
We had a bit of duplication between roles based on harvest discovery.

Arguably some crons/logrotate scripts are specific to other packages,
but it helps to go to one place to find them all. We'll apply these
roles last in the playbook, to give an opportunity for all other
packages / non-system users to have been installed already.
2026-01-05 12:01:25 +11:00
c9003d589d
Fix test. Update README
All checks were successful
CI / test (push) Successful in 8m1s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-05 10:23:15 +11:00
59674d4660
Introduce enroll explain
Some checks failed
CI / test (push) Failing after 1m45s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
A tool to analyze and explain what's in (or not in) a harvest and why.
2026-01-05 10:16:44 +11:00
56d0148614
Update README
All checks were successful
CI / test (push) Successful in 6m53s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 22s
2026-01-04 21:27:23 +11:00
04234e296f
0.2.3
All checks were successful
CI / test (push) Successful in 6m55s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-04 21:05:49 +11:00
a2be708a31
Support for remote hosts that require password for sudo.
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible.

It will also fall back to this prompt if a password is required but the arg wasn't passed in.

With thanks to slhck from HN for the initial patch, advice and feedback.
2026-01-04 20:49:10 +11:00
9df4dc862d
Add CONTRIBUTORS.md 2026-01-04 15:53:33 +11:00
fd55bcde9b
fix fedora release
All checks were successful
CI / test (push) Successful in 7m2s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-03 12:56:59 +11:00
1d3ce6191e
remove 'fc' from release root
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-03 12:49:14 +11:00
626d76c755
Update README for RPM repo URL
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-03 12:46:32 +11:00
f82fd894ca
More test coverage (71%)
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-01-03 12:34:39 +11:00
9a2516d858
Fix release date
All checks were successful
CI / test (push) Successful in 7m0s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-03 12:17:43 +11:00
6c3275b44a
Fix tests
All checks were successful
CI / test (push) Successful in 7m4s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 22s
2026-01-03 11:46:40 +11:00
824010b2ab
Several bug fixes and prep for 0.2.2
Some checks failed
CI / test (push) Failing after 1m40s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 24s
- Fix stat() of parent directory so that we set directory perms correct on --include paths.
 - Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
2026-01-03 11:39:57 +11:00
29b52d451d
0.2.1
Some checks failed
CI / test (push) Failing after 2m37s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-02 21:29:16 +11:00
c88405ef01
Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files 2026-01-02 21:10:32 +11:00
781efef467
Don't accidentally add extra_paths role to usr_local_custom list, resulting in extra_paths appearing twice in manifested playbook 2026-01-02 20:19:47 +11:00
09438246ae
Build for Fedora 43
All checks were successful
CI / test (push) Successful in 6m42s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-01 15:24:21 +11:00
e4887b7add
Update README.md
All checks were successful
CI / test (push) Successful in 6m39s
Lint / test (push) Successful in 31s
Trivy / test (push) Successful in 23s
2026-01-01 11:02:30 +11:00
e44e4aaf3a
0.2.0
All checks were successful
CI / test (push) Successful in 4m52s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s
2025-12-29 17:39:39 +11:00
f01603dac4
Better attribution of config files to parent service/role (not systemd helpers)
All checks were successful
CI / test (push) Successful in 4m51s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 15s
2025-12-29 17:19:59 +11:00
081739fd19
Fix tests
All checks were successful
CI / test (push) Successful in 5m7s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 18s
2025-12-29 16:35:21 +11:00
043802e800
Refactor state structure and capture versions of packages 2025-12-29 16:10:27 +11:00
984b0fa81b
Add ability to enroll RH-style systems (DNF5/DNF/RPM)
All checks were successful
CI / test (push) Successful in 5m9s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s
2025-12-29 14:59:34 +11:00
ad2abed612
Add version CLI arg 2025-12-29 14:29:11 +11:00
8c19473e18
Fix an attribution bug for certain files ending up in the wrong package/role.
All checks were successful
CI / test (push) Successful in 5m2s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 21s
2025-12-28 18:37:14 +11:00
921801caa6
0.1.6
All checks were successful
CI / test (push) Successful in 5m24s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 16s
2025-12-28 15:32:40 +11:00
3fc5aec5fc
0.1.5
All checks were successful
CI / test (push) Successful in 5m4s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 17s
2025-12-28 09:56:52 +11:00
8c6b51be3e
Manage apt stuff in its own role, not in etc_custom
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-28 09:39:14 +11:00
303c1b0dd8
Consolidate logrotate and cron files into their main service/package roles if they exist. Standardise on MAX_FILES_CAP in one place 2025-12-28 09:30:21 +11:00
cae6246177
Add Fedora install steps to README
All checks were successful
CI / test (push) Successful in 5m1s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 18s
2025-12-27 19:14:01 +11:00
40aad9e798
0.1.4
All checks were successful
CI / test (push) Successful in 5m0s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 18s
2025-12-27 19:04:00 +11:00
054a6192d1
Capture more singletons in /etc and avoid apt duplication
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-27 19:02:22 +11:00
4d2250f974
Add fedora rpm building
All checks were successful
CI / test (push) Successful in 4m45s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 17s
2025-12-27 16:56:30 +11:00
8c478249d9
Add build-deb action workflow
All checks were successful
CI / test (push) Successful in 4m48s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s
2025-12-23 17:22:50 +11:00
51196a0a2b
Fix trivy exit code
All checks were successful
CI / test (push) Successful in 4m48s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 17s
2025-12-22 17:28:10 +11:00
59239eb2d2
Fix formatting in README
All checks were successful
CI / test (push) Successful in 5m33s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 17s
2025-12-20 18:38:05 +11:00
cf819f755a
0.1.3
All checks were successful
CI / test (push) Successful in 5m35s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 20s
2025-12-20 18:26:04 +11:00
9641637d4d
Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember them all for repetitive executions.
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-20 18:24:46 +11:00
240e79706f
Allow the user to add extra paths to harvest, or
All checks were successful
CI / test (push) Successful in 5m31s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 19s
paths to ignore, using `--exclude-path` and
`--include-path` arguments.
2025-12-20 17:47:00 +11:00
25add369dc
README.md update
All checks were successful
CI / test (push) Successful in 5m3s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 18s
2025-12-18 17:24:45 +11:00
4660a0703e
Include files from /usr/local/bin and /usr/local/etc in harvest (assuming they aren't binaries or symlinks) and store in usr_local_custom role, similar to etc_custom.
All checks were successful
CI / test (push) Successful in 5m43s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 19s
2025-12-18 17:11:04 +11:00
b5d2b99174
Add diff mode
All checks were successful
CI / test (push) Successful in 5m14s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2025-12-18 14:59:51 +11:00
55e50ebf59
Fix end of file/whitespace per pre-commit
All checks were successful
CI / test (push) Successful in 5m11s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 17s
2025-12-18 13:50:00 +11:00
e94bd86c75
Add files param to bandit pre-commit
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-18 13:45:59 +11:00
bfa2f4a724
Add bandit to pre-commit 2025-12-18 13:44:26 +11:00
591ecaa235
Add pre-commit config
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-18 13:41:22 +11:00
a235028f3b
black
All checks were successful
CI / test (push) Successful in 5m38s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 21s
2025-12-18 13:34:37 +11:00
62ec8e8b1b
Silence bandit paranoia on certain lines
Some checks failed
CI / test (push) Successful in 5m24s
Lint / test (push) Failing after 29s
Trivy / test (push) Successful in 20s
2025-12-17 19:05:07 +11:00
9ebd8ff990
remove --out from harvest examples with remote mode, in README 2025-12-17 19:03:31 +11:00
33b1176800
Add --sops mode to encrypt harvest and manifest data at rest (especially useful if using --dangerous)
Some checks failed
CI / test (push) Successful in 5m35s
Lint / test (push) Failing after 29s
Trivy / test (push) Successful in 18s
2025-12-17 18:51:40 +11:00
6a36a9d2d5
Remote mode and dangerous flag, other tweaks
* Add remote mode for harvesting a remote machine via a local workstation (no need to install enroll remotely)
   Optionally use `--no-sudo` if you don't want the remote user to have passwordless sudo when conducting the
   harvest, albeit you'll end up with less useful data (same as if running `enroll harvest` on a machine without
   sudo)
 * Add `--dangerous` flag to capture even sensitive data (use at your own risk!)
 * Do a better job at capturing other config files in `/etc/<package>/` even if that package doesn't normally
   ship or manage those files.
2025-12-17 17:02:16 +11:00
026416d158
Fix tests
All checks were successful
CI / test (push) Successful in 5m36s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 21s
2025-12-16 20:48:08 +11:00
f40b9d834d
black and pyflakes3 2025-12-16 20:15:21 +11:00
f255ba566c
biiiiig refactor to support jinjaturtle and multi site mode 2025-12-16 20:14:20 +11:00
576649a49c
README.md adjustment
All checks were successful
CI / test (push) Successful in 5m1s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 18s
2025-12-15 17:13:06 +11:00
111 changed files with 31644 additions and 1667 deletions

View file

@ -0,0 +1,66 @@
name: CI
on:
push:
jobs:
test:
runs-on: docker
steps:
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
devscripts \
debhelper \
dh-python \
pybuild-plugin-pyproject \
python3-all \
python3-poetry-core \
python3-yaml \
python3-paramiko \
python3-jsonschema \
rsync \
ca-certificates
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Build deb
run: |
mkdir /out
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'dist' \
--exclude 'build' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude '.mypy_cache' \
./ /out/
cd /out/
export DEBEMAIL="mig@mig5.net"
export DEBFULLNAME="Miguel Jacq"
dch --distribution "trixie" --local "~trixie" "CI build for trixie"
dpkg-buildpackage -us -uc -b
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

View file

@ -15,7 +15,8 @@ jobs:
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ansible ansible-lint python3-venv pipx systemctl python3-apt
ansible ansible-lint python3-venv pipx systemctl python3-apt jq python3-jsonschema \
puppet hiera
- name: Install Poetry
run: |
@ -27,6 +28,11 @@ jobs:
run: |
poetry install --with dev
- name: Install sops
run: |
curl -L -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.13.1/sops-v3.13.1.linux.amd64
chmod +x /usr/local/bin/sops
- name: Run test script
run: |
./tests.sh

View file

@ -1,40 +0,0 @@
name: Trivy
on:
schedule:
- cron: '0 1 * * *'
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy
- name: Run trivy
run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ dist
*.pdf
*.csv
*.html
coverage.xml

25
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,25 @@
repos:
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
args: ["--select=F"]
types: [python]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/PyCQA/bandit
rev: 1.9.2
hooks:
- id: bandit
files: ^enroll/

View file

@ -1,3 +1,145 @@
# 0.7.0
* Add support for detecting flatpaks and snaps
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
* Support manifesting Puppet code, as well as Ansible!
* A lot of under-the-bonnet refactoring to make it easier to extend to cover other config managers (that don't suck) in future.
* Support for detecting Docker images
# 0.6.0
* Add support for capturing ipset and iptables configuration files
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present (`firewall_runtime` role)
* Dependency updates
# 0.5.0
* Add support for templating `sshd_config`, if a compatible version of JinjaTurtle is also present.
* Dependency updates
# 0.4.4
* Update cryptography dependency
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
# 0.4.3
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
* Update dependencies
# 0.4.2
* Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
# 0.4.1
* Add interactive output when 'enroll diff --enforce' is invoking Ansible.
# 0.4.0
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
* Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
* Update pynacl dependency to resolve CVE-2025-69277
* Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
* Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
* Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
* Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible. Only the specific roles that had diffed will be applied (via the new tags capability)
# 0.3.0
* Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
* Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.
* Capture other files in the user's home directory such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
* Ignore files that end with a tilde or - (probably backup files generated by editors or shadow file changes)
* Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
# 0.2.3
* Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible. It will also fall back to this prompt if a password is required but the arg wasn't passed in.
# 0.2.2
* Fix stat() of parent directory so that we set directory perms correct on --include paths.
* Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
# 0.2.1
* Don't accidentally add `extra_paths` role to `usr_local_custom` list, resulting in `extra_paths` appearing twice in manifested playbook
* Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files
# 0.2.0
* Add version CLI arg
* Add ability to enroll RH-style systems (DNF5/DNF/RPM)
* Refactor harvest state to track package versions
# 0.1.7
* Fix an attribution bug for certain files ending up in the wrong package/role.
# 0.1.6
* DRY up some code logic
* More test coverage
# 0.1.5
* Consolidate logrotate and cron files into their main service/package roles if they exist.
* Standardise on `MAX_FILES_CAP` in one place
* Manage apt stuff in its own role, not in `etc_custom`
# 0.1.4
* Attempt to capture more stuff from /etc that might not be attributable to a specific package. This includes common singletons and systemd timers
* Avoid duplicate apt data in package-specific roles.
# 0.1.3
* Allow the user to add extra paths to harvest, or paths to ignore, using `--exclude-path` and `--include-path`
arguments.
* Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember
them all for repetitive executions.
# 0.1.2
* Include files from `/usr/local/bin` and `/usr/local/etc` in harvest (assuming they aren't binaries or
symlinks) and store in `usr_local_custom` role, similar to `etc_custom`.
# 0.1.1
* Add `diff` subcommand which can compare two harvests and send email or webhook notifications in different
formats.
# 0.1.0
* Add remote mode for harvesting a remote machine via a local workstation (no need to install enroll remotely)
Optionally use `--no-sudo` if you don't want the remote user to have passwordless sudo when conducting the
harvest, albeit you'll end up with less useful data (same as if running `enroll harvest` on a machine without
sudo)
* Add `--dangerous` flag to capture even sensitive data (use at your own risk!)
* Add `--sops` flag which makes the harvest and the manifest 'out' data encrypted as a single SOPS data file.
This would make `--dangerous` a little bit safer, if your intention is just to store the Ansible manifest
in git or somewhere similar for disaster-recovery purposes (e.g encrypted at rest for safe-keeping).
* Do a better job at capturing other config files in `/etc/<package>/` even if that package doesn't normally
ship or manage those files.
* Don't collect files ending in `.log`
# 0.0.5
* Use JinjaTurtle to generate dynamic template/inventory if it's on the PATH
* Support --fqdn flag for site-specific inventory and an inventory hosts file.
This radically re-architects the roles to loop through abstract inventory
because otherwise different servers can collide with each other through use
of the same role. Use 'single site' mode (no `--fqdn`) if you want more readable,
self-contained roles (in which case, store each manifested output in its own
repo per server)
* Generate an ansible.cfg if not present, to support `host_vars` plugin and other params,
when using `--fqdn` mode
* Be more permissive with files that we previously thought contained secrets (ignore commented lines)
# 0.0.4
* Fix dash package detection issue
@ -11,10 +153,10 @@
# 0.0.2
* Merge pkg_ and roles created based on file/service detection
* Avoid idempotency issue with users (password_lock)
* Rename subcommands/args ('export' is now 'enroll', '--bundle' is now '--harvest')
* Avoid idempotency issue with users (`password_lock`)
* Rename subcommands/args ('export' is now 'enroll', '--bundle' is now '--harvest')
* Don't try and start systemd services that were Inactive at harvest time
* Capture miscellaneous files in /etc under their own etc_custom role, but not backup files
* Capture miscellaneous files in /etc under their own `etc_custom` role, but not backup files
* Add tests
* Various other bug fixes

5
CONTRIBUTORS.md Normal file
View file

@ -0,0 +1,5 @@
## Contributors
mig5 would like to thank the following people for their contributions to Enroll.
* [slhck](https://slhck.info/)

View file

@ -24,6 +24,9 @@ RUN set -eux; \
pybuild-plugin-pyproject \
python3-all \
python3-poetry-core \
python3-yaml \
python3-paramiko \
python3-jsonschema \
rsync \
ca-certificates \
; \

88
Dockerfile.rpmbuild Normal file
View file

@ -0,0 +1,88 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=fedora:42
FROM ${BASE_IMAGE}
RUN set -eux; \
dnf -y update; \
dnf -y install \
rpm-build \
rpmdevtools \
redhat-rpm-config \
gcc \
make \
findutils \
tar \
gzip \
rsync \
python3 \
python3-devel \
python3-setuptools \
python3-wheel \
pyproject-rpm-macros \
python3-rpm-macros \
python3-yaml \
python3-paramiko \
python3-jsonschema \
openssl-devel \
python3-poetry-core ; \
dnf -y clean all
# Build runner script (copies repo, tars, runs rpmbuild)
RUN set -eux; cat > /usr/local/bin/build-rpm <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
SRC="${SRC:-/src}"
WORKROOT="${WORKROOT:-/work}"
OUT="${OUT:-/out}"
VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)"
echo "Version ID is ${VERSION_ID}"
mkdir -p "${WORKROOT}" "${OUT}"
WORK="${WORKROOT}/src"
rm -rf "${WORK}"
mkdir -p "${WORK}"
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'dist' \
--exclude 'build' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude '.mypy_cache' \
"${SRC}/" "${WORK}/"
cd "${WORK}"
# Determine version from pyproject.toml unless provided
if [ -n "${VERSION:-}" ]; then
ver="${VERSION}"
else
ver="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)".*/\1/')"
fi
TOPDIR="${WORKROOT}/rpmbuild"
mkdir -p "${TOPDIR}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
tarball="${TOPDIR}/SOURCES/enroll-${ver}.tar.gz"
tar -czf "${tarball}" --transform "s#^#enroll/#" .
spec_src="rpm/enroll.spec"
cp -v "${spec_src}" "${TOPDIR}/SPECS/enroll.spec"
rpmbuild -ba "${TOPDIR}/SPECS/enroll.spec" \
--define "_topdir ${TOPDIR}" \
--define "upstream_version ${ver}"
shopt -s nullglob
cp -v "${TOPDIR}"/RPMS/*/*.rpm "${OUT}/" || true
cp -v "${TOPDIR}"/SRPMS/*.src.rpm "${OUT}/" || true
echo "Artifacts copied to ${OUT}"
EOF
RUN chmod +x /usr/local/bin/build-rpm
WORKDIR /work
ENTRYPOINT ["/usr/local/bin/build-rpm"]

642
README.md
View file

@ -1,25 +1,318 @@
# Enroll
# Enroll
<div align="center">
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
</div>
**enroll** inspects a Linux machine (currently Debian-only) and generates Ansible roles for things it finds running on the machine.
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates configuration-management code: Ansible roles/playbooks by default, or Puppet control-repo style output for what it finds.
It aims to be **optimistic and noninteractive**:
- Detects packages that have been installed
- Detects Debian package ownership of `/etc` files using dpkgs local database.
- Captures config that has **changed from packaged defaults** (dpkg conffile hashes + package md5sums when available).
- Detects packages that have been installed.
- Detects package ownership of `/etc` files where possible
- Captures config that has **changed from packaged defaults** where possible (e.g dpkg conffile hashes + package md5sums when available).
- Also captures **service-relevant custom/unowned files** under `/etc/<service>/...` (e.g. drop-in config includes).
- Defensively excludes likely secrets (path denylist + content sniff + size caps).
- Captures non-system users that exist on the system, and their SSH public keys
- Captures miscellaneous `/etc` files that it can't attribute to a package, and installs it in an `etc_custom` role
- Avoids trying to start systemd services that were detected as being Inactive during harvest
- Captures non-system users and their SSH public keys. In `--dangerous` mode, it also auto-harvests common shell dotfiles such as `.bashrc`, `.profile`, `.bash_logout`, and `.bash_aliases` when appropriate.
- Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role.
- When running as root/sudo, captures live writable sysctl state into a `sysctl` role that manages `/etc/sysctl.d/99-enroll.conf`.
- Captures live ipset and iptables runtime state into a fallback `firewall_runtime` role, when active ipsets/iptables rules are present *and* no corresponding persistent ipset/iptables *files* were found.
- Captures symlinks in common applications that rely on them, e.g apache2/nginx 'sites-enabled'
- Ditto for /usr/local/bin (for non-binary files) and /usr/local/etc
- Avoids trying to start systemd services that were detected as inactive during harvest.
## Install
---
### Ubuntu/Debian apt repository
## Mental model
`enroll` works in two phases:
1) **Harvest**: collect host facts + relevant files into a harvest bundle (`state.json` + harvested artifacts)
2) **Manifest**: turn that harvest into configuration-management code such as Ansible roles/playbooks or Puppet manifests
Additionally, some other functionalities exist:
- **Diff**: compare two harvests and report what changed (packages/services/users/files) since the previous snapshot.
- **Single-shot mode**: run both harvest and manifest at once.
---
## Output modes: single-site vs multi-site (`--fqdn`)
`enroll manifest` (and `enroll single-shot`) support multiple output targets. Ansible is the default target and supports two distinct output styles.
### Single-site mode (default: *no* `--fqdn`)
Use when enrolling **one server** (or generating a “golden” role set you intend to reuse).
**Characteristics**
- Roles are more self-contained.
- Raw config files live in the role's `files/`.
- Template variables live in the role's `defaults/main.yml`.
### Multi-site mode (`--fqdn`)
Use when enrolling **several existing servers** quickly, especially if they differ.
**Characteristics**
- Roles are shared, host-specific state lives in inventory.
- Host inventory drives what gets managed (files/packages/services).
- Non-templated raw files live per-host under `inventory/host_vars/<fqdn>/<role>/.files/...`.
**Rule of thumb**
- “Make this one server reproducible/provisionable” → start with **single-site**
- “Get multiple already-running servers under management quickly” → use **multi-site**
---
## Subcommands
### `enroll harvest`
Harvest state about a host and write a harvest bundle.
**What it captures (high level)**
- Detected services + service-relevant packages
- “Manual” packages
- Changed-from-default config (plus related custom/unowned files under service dirs)
- Non-system users + SSH public keys
- In `--dangerous` mode: common per-user shell dotfiles that are likely to represent deliberate account customisation
- Misc `/etc` that can't be attributed to a package (`etc_custom` role)
- Static firewall config files such as nftables, UFW, firewalld, `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, and `/etc/ipset*`
- Live writable sysctl state via `sysctl -a`, emitted as `/etc/sysctl.d/99-enroll.conf` at manifest time when running as root/sudo (`sysctl` role)
- Live kernel ipset/iptables state via `ipset save`, `iptables-save`, and `ip6tables-save` as a fallback, but only when the corresponding persistent config was not found (`firewall_runtime` role at manifest time)
- Optional user-specified extra files/dirs via `--include-path` (emitted as an `extra_paths` role at manifest time)
**Common flags**
- Remote harvesting:
- `--remote-host`, `--remote-user`, `--remote-port`, `--remote-ssh-config`
- `--no-sudo` (if you don't want/need sudo)
- Sensitive-data behaviour:
- default: tries to avoid likely secrets
- `--dangerous`: disables secret-safety checks (see “Sensitive data” below)
- Encrypt bundles at rest:
- `--sops <FINGERPRINT...>`: writes a single encrypted `harvest.tar.gz.sops` instead of a plaintext directory
- Path selection (include/exclude):
- `--include-path <PATTERN>` (repeatable): add extra files/dirs to harvest (even from locations normally ignored, like `/home`). Still subject to secret-safety checks unless `--dangerous`.
- `--exclude-path <PATTERN>` (repeatable): skip files/dirs even if they would normally be harvested.
- Pattern syntax:
- plain path: matches that file; directories match the directory + everything under it
- glob (default): supports `*` and `**` (prefix with `glob:` to force)
- regex: prefix with `re:` or `regex:`
- Precedence: excludes win over includes.
* Using remote mode and auth requires secrets?
* sudo password:
* `--ask-become-pass` (or `-K`) prompts for the sudo password.
* If you forget, and remote sudo requires a password, Enroll will still fall back to prompting in interactive mode (slightly slower due to retry).
* SSH private-key passphrase:
* `--ask-key-passphrase` prompts for the SSH key passphrase.
* `--ssh-key-passphrase-env ENV_VAR` reads the SSH key passphrase from an environment variable (useful for CI/non-interactive runs).
* If neither is provided, and Enroll detects an encrypted key in an interactive session, it will still fall back to prompting on-demand.
* In non-interactive sessions, pass `--ask-key-passphrase` or `--ssh-key-passphrase-env ENV_VAR` when using encrypted private keys.
* Note: `--ask-key-passphrase` and `--ssh-key-passphrase-env` are mutually exclusive.
Examples (encrypted SSH key)
```bash
# Interactive
enroll harvest --remote-host myhost.example.com --remote-user myuser --ask-key-passphrase --out /tmp/enroll-harvest
# Non-interactive / CI
export ENROLL_SSH_KEY_PASSPHRASE='correct horse battery staple'
enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-key-passphrase-env ENROLL_SSH_KEY_PASSPHRASE --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn myhost.example.com
```
---
### `enroll manifest`
Generate configuration-management output from an existing harvest bundle. Ansible remains the default; use `--target puppet` for Puppet output.
**Inputs**
- `--harvest /path/to/harvest` (directory)
or `--harvest /path/to/harvest.tar.gz.sops` (if using `--sops`)
**Output**
- In plaintext Ansible mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
- In plaintext Puppet mode: a Puppet control-repo style layout with `manifests/site.pp` and generated modules under `modules/`. By default, package and service resources are grouped by Debian Section/RPM Group where possible; `--fqdn` or `--no-common-roles` preserves one generated module per Enroll role/snapshot.
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
**Common flags**
- `--target ansible|puppet`: choose the manifest target (`ansible` is the default).
- `--fqdn <host>`: enables **multi-site** output style for Ansible or emits Puppet Hiera/node output. Without `--fqdn`, Puppet emits `node default { ... }`.
- `--no-common-roles`: disables the default grouping of package and systemd-unit roles into Debian Section/RPM Group roles, preserving one generated role per package/unit. `--fqdn` implies this behaviour.
**Role tags**
Generated playbooks tag each role so you can target just the parts you need:
- Tag format: `role_<role_name>` (e.g. `role_services`, `role_users`)
- Fallback/safe tag: `role_other`
Example:
```bash
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_services,role_users
```
---
### `enroll single-shot`
Convenience wrapper that runs **harvest → manifest** in one command.
Use this when you want “get me something workable ASAP”.
Supports the same general flags as harvest/manifest, including `--target`, `--fqdn`, `--no-common-roles`, remote harvest flags, and `--sops`.
---
### `enroll diff`
Compare two harvest bundles and report what changed.
**What it reports**
- Packages added/removed
- Services enabled added/removed, plus key state changes
- Users added/removed, plus field changes (uid/gid/home/shell/groups, etc.)
- Managed files added/removed/changed (metadata + content hash changes where available)
**Inputs**
- `--old <harvest>` and `--new <harvest>` (directories or `state.json` paths)
- `--sops` when comparing SOPS-encrypted harvest bundles
- `--exclude-path <PATTERN>` (repeatable) to ignore file/dir drift under matching paths (same pattern syntax as harvest)
- `--ignore-package-versions` to ignore package version-only drift (upgrades/downgrades)
- `--enforce` to apply the **old** harvest state locally (requires `ansible-playbook` on `PATH`)
**Noise suppression**
- `--exclude-path` is useful for things that change often but you still want in the harvest baseline (e.g. `/var/anacron`).
- `--ignore-package-versions` keeps routine upgrades from alerting; package add/remove drift is still reported.
**Enforcement (`--enforce`)**
If a diff exists and `ansible-playbook` is available, Enroll will:
1) generate a manifest from the **old** harvest into a temporary directory
2) run `ansible-playbook -i localhost, -c local <tmp>/playbook.yml` (often with `--tags role_<...>` to limit runtime)
3) record in the diff report that the old harvest was enforced
Enforcement is intentionally “safe”:
- reinstalls packages that were removed (`state: present`), but does **not** attempt downgrades/pinning
- restores users, files (contents + permissions/ownership), and service enable/start state
If `ansible-playbook` is not on `PATH`, Enroll returns an error and does not enforce.
**Output formats**
- `--format json` (default for webhooks)
- `--format markdown` / `--format text` (human-oriented)
**Notifications**
- Webhook:
- `--webhook <url>`
- `--webhook-format json|markdown|text`
- `--webhook-header 'Header-Name: value'` (repeatable)
- Email (optional):
- `--email-to <addr>` (plus optional SMTP/sendmail-related flags, depending on your install)
---
### `enroll explain`
Analyze a harvest and provide user-friendly explanations for what's in it and why.
This may also explain why something *wasn't* included (e.g a binary file, a file that was too large, unreadable due to permissions, or looked like a log file/secret.
Provide either the path to the harvest or the path to its state.json. It can also handle SOPS-encrypted harvests.
Output can be provided in plaintext or json.
---
### `enroll validate`
Validates a harvest by checking:
* state.json exists and is valid JSON
* state.json validates against a JSON Schema (by default the vendored one)
* Every `managed_file` entry has a corresponding artifact at: `artifacts/<role_name>/<src_rel>`
* That there are no **unreferenced files** sitting in `artifacts/` that aren't in the state.
#### Schema location + overrides
The master schema lives at: `enroll/schema/state.schema.json`.
You can override with a local file or URL:
```
enroll validate /path/to/harvest --schema ./state.schema.json
enroll validate /path/to/harvest --schema https://enroll.sh/schema/state.schema.json
```
Or skip schema checks (still does artifact consistency checks):
```
enroll validate /path/to/harvest --no-schema
```
#### CLI usage examples
Validate a local harvest:
```
enroll validate ./harvest
```
Validate a harvest tarball or a sops bundle:
```
enroll validate ./harvest.tar.gz
enroll validate ./harvest.sops --sops
```
JSON output + write to file:
```
enroll validate ./harvest --format json --out validate.json
```
Return exit code 1 for any warnings, not just errors (useful for CI):
```
enroll validate ./harvest --fail-on-warnings
```
---
## Sensitive data
By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage.
Automatic harvesting of per-user shell dotfiles is also disabled by default, even when those files differ from `/etc/skel`, because `.bashrc`, `.profile`, `.bash_aliases`, and similar files commonly contain exported tokens, credentials, or aliases/functions with embedded secrets. Use `--dangerous` for automatic shell-dotfile capture, or use targeted `--include-path` patterns for narrower safe-mode review.
If you opt in to collecting everything:
### `--dangerous`
**WARNING:** disables “likely secret” safety checks. This can copy private keys, TLS key material, API tokens, database passwords, and other credentials into the harvest output **in plaintext**.
If you intend to keep harvests/manifests long-term (especially in git), strongly consider encrypting them at rest.
### Encrypt bundles at rest with `--sops`
`--sops` encrypts the harvest and/or manifest outputs into a single `.tar.gz.sops` file (GPG). This is for **storage-at-rest**, not for direct “Ansible SOPS inventory” workflows.
⚠️ Important: `manifest --sops` produces one encrypted file. You must decrypt + extract it before running `ansible-playbook`.
---
## JinjaTurtle integration (both modes)
If [JinjaTurtle](https://git.mig5.net/mig5/jinjaturtle) is installed, `enroll` can generate Jinja2 templates for ini/json/xml/toml-style config.
- Templates live in `roles/<role>/templates/...`
- Variables live in:
- single-site: `roles/<role>/defaults/main.yml`
- multi-site: `inventory/host_vars/<fqdn>/<role>.yml`
You can force it on with `--jinjaturtle` or disable with `--no-jinjaturtle`.
---
## How multi-site avoids “shared role breaks a host”
In multi-site mode, roles are **data-driven**. The role tasks are generic (“deploy the files listed for this host”, “install the packages listed for this host”, “apply systemd enable/start state listed for this host”). Host inventory decides what applies per-host, avoiding the classic “host2 adds config, host1 breaks” failure mode.
---
# Install
## Ubuntu/Debian apt repository
```bash
sudo mkdir -p /usr/share/keyrings
curl -fsSL https://mig5.net/static/mig5.asc | sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
@ -28,74 +321,347 @@ sudo apt update
sudo apt install enroll
```
### AppImage
## Fedora
Download the AppImage file from the Releases page (verify with GPG if you wish, my fingerprint is [here](https://mig5.net/static/mig5.asc),
then make it executable and run it:
```bash
sudo rpm --import https://mig5.net/static/mig5.asc
sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF'
[mig5]
name=mig5 Repository
baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://mig5.net/static/mig5.asc
EOF
sudo dnf upgrade --refresh
sudo dnf install enroll
```
## AppImage
Download it from my Releases page, then:
```bash
chmod +x Enroll.AppImage
./Enroll.AppImage
```
### Pip/PipX
## Pip/PipX
```bash
pip install enroll
```
### Poetry
Clone this repository with git, then:
## Poetry (dev)
```bash
poetry install
poetry run enroll --help
```
## Usage
---
On the host (root recommended to harvest as much data as possible):
## Found a bug / have a suggestion?
### 1. Harvest state/information about the host
My Forgejo doesn't currently support federation, so I haven't opened registration/login for issues.
Instead, email me (see `pyproject.toml`) or contact me on the Fediverse:
https://goto.mig5.net/@mig5
---
# Examples
## Harvest
### Local harvest
```bash
enroll harvest --out /tmp/enroll-harvest
```
### 2. Generate Ansible manifests (roles/playbook) from that harvest
### Remote harvest over SSH
```bash
enroll harvest --remote-host myhost.example.com --remote-user myuser --out /tmp/enroll-harvest
```
### Remote harvest over SSH, where the SSH configuration is in ~/.ssh/config (e.g a different SSH key)
Note: you must still pass `--remote-host`, but in this case, its value can be the 'Host' alias of an entry in your `~/.ssh/config`.
```bash
enroll harvest --remote-host myhostalias --remote-ssh-config ~/.ssh/config --out /tmp/enroll-harvest
```
### Include paths (`--include-path`)
```bash
# Add a few dotfiles from /home (still secret-safe unless --dangerous)
enroll harvest --out /tmp/enroll-harvest --include-path '/home/*/.bashrc' --include-path '/home/*/.profile'
```
### Exclude paths (`--exclude-path`)
```bash
# Skip specific /usr/local/bin entries (or patterns)
enroll harvest --out /tmp/enroll-harvest --exclude-path '/usr/local/bin/docker-*' --exclude-path '/usr/local/bin/some-tool'
```
### Regex include
```bash
enroll harvest --out /tmp/enroll-harvest --include-path 're:^/home/[^/]+/\.config/myapp/.*$'
```
### `--dangerous`
```bash
enroll harvest --out /tmp/enroll-harvest --dangerous
```
### Remote + dangerous:
```bash
enroll harvest --remote-host myhost.example.com --remote-user myuser --dangerous
```
### `--sops` (encrypt at rest)
```bash
# Encrypted harvest bundle (writes /tmp/enroll-harvest/harvest.tar.gz.sops)
enroll harvest --out /tmp/enroll-harvest --dangerous --sops <FINGERPRINT(s)>
```
---
## Manifest
### Single-site (default: no --fqdn)
```bash
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
```
### Alternatively, do both steps in one shot:
### Multi-site (--fqdn)
```bash
enroll enroll --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
```
Then run:
### Container image caches
If Docker or Podman is available during harvest, Enroll records local image-cache metadata from `image ls` and `image inspect`. Images that expose registry `RepoDigest` values are reproducible by digest, for example `registry.example.net/app@sha256:...`; those are the references rendered into manifests. Local image IDs and tag-only images are preserved as evidence and notes, but are not treated as exact registry pull references.
For Ansible, digest-pinned Docker images are pulled with `community.docker.docker_image_pull` and digest-pinned Podman images are pulled with `containers.podman.podman_image`; harvested tag aliases are re-applied where possible. The generated `requirements.yml` includes `community.docker` and `containers.podman` alongside any other required collections. In `--fqdn` mode the image list is host-specific inventory data.
### Puppet target
```bash
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-puppet --target puppet
```
The Puppet target renders native packages, users/groups, managed directories/files/symlinks, basic service state, and the generated sysctl file/apply exec when present. Without `--fqdn`, `site.pp` uses `node default { ... }`; with `--fqdn`, it uses `node '<host>' { ... }`. Run from the generated output directory with the generated modules on Puppet's module path, for example:
```bash
cd /tmp/enroll-puppet
sudo puppet apply --modulepath ./modules manifests/site.pp --noop
```
Or with absolute paths:
```bash
sudo puppet apply --modulepath /tmp/enroll-puppet/modules /tmp/enroll-puppet/manifests/site.pp --noop
```
Docker images with registry digests are rendered as `docker::image` resources and require the Puppet environment to provide `puppetlabs-docker`; the generated module metadata records that dependency. Podman images with registry digests are rendered as guarded `podman pull` / `podman tag` exec resources. Images without `RepoDigest` are recorded in harvest state and notes, but are not converted into exact pull resources. Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Puppet README rather than converted into Puppet resources.
### Manifest with `--sops`
```bash
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
enroll manifest --harvest /tmp/enroll-harvest/harvest.tar.gz.sops --out /tmp/enroll-ansible --sops <FINGERPRINT(s)>
# Decrypt/extract the manifest bundle, then run Ansible from inside ./manifest/
cd /tmp/enroll-ansible
sops -d manifest.tar.gz.sops | tar -xzvf -
cd manifest
```
---
## Single-shot
```bash
enroll single-shot --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
```
Remote single-shot (run harvest over SSH, then manifest locally):
```bash
enroll single-shot --remote-host myhost.example.com --remote-user myuser --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "myhost.example.com"
```
---
## Diff
### Compare two harvest directories, output in json
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --format json
```
### Diff + webhook notify
```bash
enroll diff --old /path/to/golden/harvest --new /path/to/new/harvest --webhook https://nr.mig5.net/forms/webhooks/xxxx --webhook-format json --webhook-header 'X-Enroll-Secret: xxxx'
```
`diff` mode also supports email sending and text or markdown format, as well as `--exit-code` mode to trigger a return code of 2 (useful for crons or CI)
### Ignore a specific directory or file from the diff
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --exclude-path /var/anacron
```
### Ignore package version drift (routine upgrades) but still alert on add/remove
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --ignore-package-versions
```
### Enforce the old harvest state when drift is detected (requires Ansible)
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --enforce --ignore-package-versions --exclude-path /var/anacron
```
---
## Explain
### Explain a harvest
All of these do the same thing:
```bash
enroll explain /path/to/state.json
enroll explain /path/to/bundle_dir
enroll explain /path/to/harvest.tar.gz
```
### Explain a SOPS-encrypted harvest
```bash
enroll explain /path/to/harvest.tar.gz.sops --sops
```
### Explain with JSON output and more examples
```bash
enroll explain /path/to/state.json --format json --max-examples 25
```
### Example output
```
enroll explain /tmp/syrah.harvest
Enroll explain: /tmp/syrah.harvest
Host: syrah.mig5.net (os: debian, pkg: dpkg)
Enroll: 0.2.3
Inventory
- Packages: 254
- Why packages were included (observed_via):
- user_installed: 248 Package appears explicitly installed (as opposed to only pulled in as a dependency).
- package_role: 232 Package was referenced by an enroll packages snapshot/role. (e.g. acl, acpid, adduser)
- systemd_unit: 22 Package is associated with a systemd unit that was harvested. (e.g. postfix.service, tor.service, apparmor.service)
Roles collected
- users: 1 user(s), 1 file(s), 0 excluded
- services: 19 unit(s), 111 file(s), 6 excluded
- packages: 232 package snapshot(s), 41 file(s), 0 excluded
- apt_config: 26 file(s), 7 dir(s), 10 excluded
- dnf_config: 0 file(s), 0 dir(s), 0 excluded
- firewall_runtime: 2 snapshot(s), 1 ipset(s)
- etc_custom: 70 file(s), 20 dir(s), 0 excluded
- usr_local_custom: 35 file(s), 1 dir(s), 0 excluded
- extra_paths: 0 file(s), 0 dir(s), 0 excluded
Why files were included (managed_files.reason)
- custom_unowned (179): A file not owned by any package (often custom/operator-managed).. Examples: /etc/apparmor.d/local/lsb_release, /etc/apparmor.d/local/nvidia_modprobe, /etc/apparmor.d/local/sbin.dhclient
- usr_local_bin_script (35): Executable scripts under /usr/local/bin (often operator-installed).. Examples: /usr/local/bin/check_firewall, /usr/local/bin/awslogs
- apt_keyring (13): Repository signing key material used by APT.. Examples: /etc/apt/keyrings/openvpn-repo-public.asc, /etc/apt/trusted.gpg, /etc/apt/trusted.gpg.d/deb.torproject.org-keyring.gpg
- modified_conffile (10): A package-managed conffile differs from the packaged/default version.. Examples: /etc/dnsmasq.conf, /etc/ssh/moduli, /etc/tor/torrc
- logrotate_snippet (9): logrotate snippets/configs referenced in system configuration.. Examples: /etc/logrotate.d/rsyslog, /etc/logrotate.d/tor, /etc/logrotate.d/apt
- apt_config (7): APT configuration affecting package installation and repository behavior.. Examples: /etc/apt/apt.conf.d/01autoremove, /etc/apt/apt.conf.d/20listchanges, /etc/apt/apt.conf.d/70debconf
[...]
```
---
## Run Ansible
### Single-site
```bash
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml
```
### Multi-site (--fqdn)
```bash
ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml
```
## Notes / Safety
### Run only specific roles (tags)
Generated playbooks tag each role as `role_<name>` (e.g. `role_users`, `role_services`), so you can speed up targeted runs:
```bash
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_users
```
- enroll **skips** common sensitive locations like `/etc/ssl/private/*`, `/etc/ssh/ssh_host_*`, and files that look like private keys/tokens.
- It also skips symlinks, binary-ish files, and large files by default.
- Review each generated roles README before committing it anywhere.
- It only stores the raw config files. If you want to turn these into Jinja2 templates with dynamic inventory, see my other tool https://git.mig5.net/mig5/jinjaturtle .
## Configuration file
As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands.
## Troubleshooting
Sometimes, it can be easier to store them in a config file so you don't have to remember them!
- Run as root for the most complete harvest (`sudo ...`).
Enroll supports reading an ini-style file of all the arguments for each subcommand.
## Found a bug, have a suggestion?
### Location of the config file
You can e-mail me (see the pyproject.toml for details) or contact me on the Fediverse:
The path the config file can be specified with `-c` or `--config` on the command-line. Otherwise,
Enroll will look for `./enroll.ini`, `./.enroll.ini` (in the current working directory),
`~/.config/enroll/enroll.ini` (or `$XDG_CONFIG_HOME/enroll/enroll.ini`).
https://goto.mig5.net/@mig5
You may also pass `--no-config` if you deliberately want to ignore the config file even if it existed.
### Precedence
Highest wins:
* Explicit CLI flags
* INI config ([cmd], [enroll])
* argparse defaults
### Example config file
Here is an example.
Whenever an argument on the command-line has a 'hyphen' in it, just be sure to change it to an underscore in the ini file.
```ini
[enroll]
# (future global flags may live here)
[harvest]
dangerous = false
include_path =
/home/*/.bashrc
/home/*/.profile
exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
# remote_host = yourserver.example.com
# remote_user = you
# remote_port = 2222
[manifest]
# you can set defaults here too, e.g.
no_jinjaturtle = true
sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
[diff]
# ignore noisy drift
exclude_path = /var/anacron
ignore_package_versions = true
# enforce = true # requires ansible-playbook on PATH
[single-shot]
# if you use single-shot, put its defaults here.
# It does not inherit those of the subsections above, so you
# may wish to repeat them here.
include_path = re:^/home/[^/]+/\.config/myapp/.*$
```

161
debian/changelog vendored
View file

@ -1,3 +1,164 @@
enroll (0.6.0) unstable; urgency=medium
* Add support for capturing ipset and iptables configuration files
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role)
-- Miguel Jacq <mig@mig5.net> Thu, 14 May 2026 15:00 +1000
enroll (0.5.0) unstable; urgency=medium
* Add ssh config support where JinjaTurtle is used
-- Miguel Jacq <mig@mig5.net> Tue, 12 May 2026 12:00 +1000
enroll (0.4.4) unstable; urgency=medium
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
-- Miguel Jacq <mig@mig5.net> Tue, 17 Feb 2026 11:00 +1100
enroll (0.4.3) unstable; urgency=medium
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
-- Miguel Jacq <mig@mig5.net> Fri, 16 Jan 2026 11:00 +1100
enroll (0.4.2) unstable; urgency=medium
* Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
-- Miguel Jacq <mig@mig5.net> Tue, 13 Jan 2026 21:55:00 +1100
enroll (0.4.1) unstable; urgency=medium
* Add interactive output when 'enroll diff --enforce' is invoking Ansible.
-- Miguel Jacq <mig@mig5.net> Sun, 11 Jan 2026 10:00:00 +1100
enroll (0.4.0) unstable; urgency=medium
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
* Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
* Update pynacl dependency to resolve CVE-2025-69277
* Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
* Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
* Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
* Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible.
Only the specific roles that had diffed will be applied (via the new tags capability)
-- Miguel Jacq <mig@mig5.net> Sat, 10 Jan 2026 10:30:00 +1100
enroll (0.3.0) unstable; urgency=medium
* Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
* Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.
* Capture other files in the user's home directory such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
* Ignore files that end with a tilde or - (probably backup files generated by editors or shadow file changes)
* Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
-- Miguel Jacq <mig@mig5.net> Mon, 05 Jan 2026 17:00:00 +1100
enroll (0.2.3) unstable; urgency=medium
* Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible. It will also fall back to this prompt if a password is required but the arg wasn't passed in.
-- Miguel Jacq <mig@mig5.net> Sun, 04 Jan 2026 20:38:00 +1100
enroll (0.2.2) unstable; urgency=medium
* Fix stat() of parent directory so that we set directory perms correct on --include paths.
* Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
-- Miguel Jacq <mig@mig5.net> Sat, 03 Jan 2026 09:56:00 +1100
enroll (0.2.1) unstable; urgency=medium
* Don't accidentally add extra_paths role to usr_local_custom list, resulting in extra_paths appearing twice in manifested playbook
* Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files
-- Miguel Jacq <mig@mig5.net> Fri, 02 Jan 2026 21:30:00 +1100
enroll (0.2.0) unstable; urgency=medium
* Add version CLI arg
* Add ability to enroll RH-style systems (DNF5/DNF/RPM)
* Refactor harvest state to track package versions
-- Miguel Jacq <mig@mig5.net> Mon, 29 Dec 2025 17:30:00 +1100
enroll (0.1.7) unstable; urgency=medium
* Fix an attribution bug for certain files ending up in the wrong package/role.
-- Miguel Jacq <mig@mig5.net> Sun, 28 Dec 2025 18:30:00 +1100
enroll (0.1.6) unstable; urgency=medium
* DRY up some code logic
* More test coverage
-- Miguel Jacq <mig@mig5.net> Sun, 28 Dec 2025 15:30:00 +1100
enroll (0.1.5) unstable; urgency=medium
* Consolidate logrotate and cron files into their main service/package roles if they exist.
* Standardise on MAX_FILES_CAP in one place
* Manage apt stuff in its own role, not in etc_custom
-- Miguel Jacq <mig@mig5.net> Sun, 28 Dec 2025 10:00:00 +1100
enroll (0.1.4) unstable; urgency=medium
* Attempt to capture more stuff from /etc that might not be attributable to a specific package. This includes common singletons and systemd timers
* Avoid duplicate apt data in package-specific roles.
-- Miguel Jacq <mig@mig5.net> Sat, 27 Dec 2025 19:00:00 +1100
enroll (0.1.3) unstable; urgency=medium
* Allow the user to add extra paths to harvest, or paths to ignore, using `--exclude-path` and `--include-path`
arguments.
* Add support for an enroll.ini config file to store arguments per subcommand, to avoid having to remember
them all for repetitive executions.
-- Miguel Jacq <mig@mig5.net> Sat, 20 Dec 2025 18:24:00 +1100
enroll (0.1.2) unstable; urgency=medium
* Include files from `/usr/local/bin` and `/usr/local/etc` in harvest (assuming they aren't binaries or
symlinks) and store in `usr_local_custom` role, similar to `etc_custom`.
-- Miguel Jacq <mig@mig5.net> Thu, 18 Dec 2025 17:07:00 +1100
enroll (0.1.1) unstable; urgency=medium
* Add `diff` subcommand which can compare two harvests and send email or webhook notifications in different
formats.
-- Miguel Jacq <mig@mig5.net> Thu, 18 Dec 2025 15:00:00 +1100
enroll (0.1.0) unstable; urgency=medium
* Add remote mode for harvesting a remote machine via a local workstation (no need to install enroll remotely)
Optionally use `--no-sudo` if you don't want the remote user to have passwordless sudo when conducting the
harvest, albeit you'll end up with less useful data (same as if running `enroll harvest` on a machine without
sudo)
* Add `--dangerous` flag to capture even sensitive data (use at your own risk!)
* Add `--sops` flag which makes the harvest and the manifest 'out' data encrypted as a single SOPS data file.
This would make `--dangerous` a little bit safer, if your intention is just to store the Ansible manifest
in git or somewhere similar for disaster-recovery purposes (e.g encrypted at rest for safe-keeping).
* Do a better job at capturing other config files in `/etc/<package>/` even if that package doesn't normally
ship or manage those files.
* Don't collect files ending in `.log`
-- Miguel Jacq <mig@mig5.net> Wed, 17 Dec 2025 18:00:00 +1100
enroll (0.0.5) unstable; urgency=medium
* Use JinjaTurtle to generate dynamic template/inventory if it's on the PATH
* Support --fqdn flag for site-specific inventory and an inventory hosts file
* Generate an ansible.cfg if not present, to support host_vars plugin and other params
* Be more permissive with files that we previously thought contained secrets (ignore commented lines)
-- Miguel Jacq <mig@mig5.net> Tue, 16 Dec 2025 12:00:00 +1100
enroll (0.0.4) unstable; urgency=medium
* Fix dash package detection issue

7
debian/control vendored
View file

@ -8,12 +8,15 @@ Build-Depends:
dh-python,
pybuild-plugin-pyproject,
python3-all,
python3-poetry-core
python3-yaml,
python3-poetry-core,
python3-paramiko,
python3-jsonschema
Standards-Version: 4.6.2
Homepage: https://git.mig5.net/mig5/enroll
Package: enroll
Architecture: all
Depends: ${misc:Depends}, ${python3:Depends}
Depends: ${misc:Depends}, ${python3:Depends}, python3-yaml, python3-paramiko, python3-jsonschema
Description: Harvest a host into Ansible roles
A tool that inspects a system and emits Ansible roles/playbooks to reproduce it.

View file

@ -109,4 +109,3 @@
<tspan class="text-dark">en</tspan><tspan class="text-light">roll</tspan>
</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

View file

@ -1,8 +1,48 @@
from __future__ import annotations
import configparser
import os
from dataclasses import dataclass
from typing import Dict, List, Set, Tuple
import re
import shutil
import subprocess # nosec
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
@dataclass
class FlatpakInstall:
name: str
method: str
remote: Optional[str] = None
branch: Optional[str] = None
arch: Optional[str] = None
kind: Optional[str] = None
ref: Optional[str] = None
user: Optional[str] = None
home: Optional[str] = None
source: str = "filesystem"
@dataclass
class FlatpakRemote:
name: str
method: str
url: str
user: Optional[str] = None
home: Optional[str] = None
source: str = "filesystem"
@dataclass
class SnapInstall:
name: str
channel: Optional[str] = None
revision: Optional[int] = None
classic: bool = False
devmode: bool = False
dangerous: bool = False
notes: List[str] = field(default_factory=list)
source: str = "snap-list"
@dataclass
@ -16,6 +56,7 @@ class UserRecord:
primary_group: str
supplementary_groups: List[str]
ssh_files: List[str]
flatpaks: List[FlatpakInstall] = field(default_factory=list)
def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]:
@ -115,6 +156,612 @@ def find_user_ssh_files(home: str) -> List[str]:
return sorted(set(out))
def _read_first_existing_text(paths: List[str]) -> Optional[str]:
for path in paths:
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
value = f.read().strip()
if value:
return value
except OSError:
continue
return None
def _parse_flatpak_ref(
ref: str,
) -> Tuple[Optional[str], str, Optional[str], Optional[str]]:
"""Return (kind, name, arch, branch) for a Flatpak ref.
refs look like app/org.example.App/x86_64/stable or
runtime/org.example.Platform/x86_64/23.08. If the value is already just an
application/runtime ID, keep it as the name and leave the other fields empty.
"""
parts = [p for p in (ref or "").strip().split("/") if p]
if len(parts) >= 4 and parts[0] in {"app", "runtime"}:
return parts[0], parts[1], parts[2], parts[3]
return None, (ref or "").strip(), None, None
def _parse_plain_flatpak_list_output(
output: str,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakInstall]:
"""Parse default `flatpak list` table output.
Example:
Name Application ID Version Branch Installation
OnionShare org.onionshare.OnionShare 2.6.4 stable system
"""
out: List[FlatpakInstall] = []
seen: Set[
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
] = set()
id_re = re.compile(r"\b(?:[A-Za-z0-9_-]+\.)+[A-Za-z0-9_-]+\b")
for line in output.splitlines():
line = line.rstrip()
if not line.strip():
continue
if "Application ID" in line and "Installation" in line:
continue
match = id_re.search(line)
if not match:
continue
name = match.group(0)
tail = line[match.end() :].split()
installation = tail[-1] if tail else ""
if installation in {"system", "user"} and installation != method:
continue
branch = None
if len(tail) >= 2 and tail[-1] in {"system", "user"}:
branch = tail[-2]
elif tail:
branch = tail[-1]
key = (name, None, branch, None, None)
if key in seen:
continue
seen.add(key)
out.append(
FlatpakInstall(
name=name,
method=method,
remote=None,
branch=branch,
arch=None,
kind=None,
ref=None,
user=user,
home=home,
source="flatpak-list",
)
)
return sorted(out, key=lambda f: (f.name, f.branch or ""))
def _parse_flatpak_list_output(
output: str,
*,
method: str,
columns: Optional[Tuple[str, ...]] = None,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakInstall]:
"""Parse Flatpak list output.
If columns is None, parse the default table. Otherwise columns names must
match the order passed to `flatpak list --columns=...`.
"""
if columns is None:
return _parse_plain_flatpak_list_output(
output, method=method, user=user, home=home
)
out: List[FlatpakInstall] = []
seen: Set[
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
] = set()
for line in output.splitlines():
line = line.strip()
if not line:
continue
lower = line.lower()
if lower.startswith("ref") or lower.startswith("application id"):
continue
parts = line.split("\t")
if len(parts) < len(columns):
parts = line.split()
if not parts:
continue
fields = {
name: parts[idx].strip()
for idx, name in enumerate(columns)
if idx < len(parts)
}
ref = fields.get("ref") or fields.get("application") or ""
kind, name, ref_arch, ref_branch = _parse_flatpak_ref(ref)
if not name:
continue
remote = fields.get("origin") or None
branch = fields.get("branch") or ref_branch
arch = fields.get("arch") or ref_arch
if remote in {"", "-"}:
remote = None
if branch in {"", "-"}:
branch = None
if arch in {"", "-"}:
arch = None
key = (name, remote, branch, arch, kind)
if key in seen:
continue
seen.add(key)
out.append(
FlatpakInstall(
name=name,
method=method,
remote=remote,
branch=branch,
arch=arch,
kind=kind,
ref=ref if "/" in ref else None,
user=user,
home=home,
source="flatpak-list",
)
)
return sorted(
out,
key=lambda f: (
f.kind or "",
f.name,
f.remote or "",
f.branch or "",
f.arch or "",
),
)
_KNOWN_FLATPAK_LIST_COLUMNS = {
"name",
"description",
"application",
"version",
"branch",
"arch",
"origin",
"installation",
"ref",
"active",
"latest",
"size",
"options",
}
def _parse_flatpak_columns_help(output: str) -> Set[str]:
"""Parse `flatpak list --columns=help` output into supported fields."""
supported: Set[str] = set()
for line in output.splitlines():
# Help output varies a bit between Flatpak versions. Treat any known
# token as a supported field, whether it appears alone or in a
# description table.
for token in re.findall(r"[A-Za-z_][A-Za-z0-9_-]*", line.lower()):
if token in _KNOWN_FLATPAK_LIST_COLUMNS:
supported.add(token)
return supported
def _run_flatpak_columns_help() -> Optional[Set[str]]:
if shutil.which("flatpak") is None:
return None
try:
proc = subprocess.run( # nosec
["flatpak", "list", "--columns=help"],
shell=False,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except Exception:
return None
if proc.returncode != 0:
return None
supported = _parse_flatpak_columns_help(proc.stdout or "")
return supported or None
def _flatpak_list_attempts(
scope: str, supported: Optional[Set[str]]
) -> List[Tuple[List[str], Optional[Tuple[str, ...]]]]:
def supported_columns(*wanted: str) -> Optional[Tuple[str, ...]]:
if supported is not None and not set(wanted).issubset(supported):
return None
return tuple(wanted)
column_sets: List[Tuple[str, ...]] = []
for wanted in (
("application", "origin", "branch", "arch"),
("application", "branch", "arch"),
("application", "branch"),
("application",),
("ref", "origin", "branch", "arch"),
("ref", "branch", "arch"),
("ref", "branch"),
("ref",),
):
cols = supported_columns(*wanted)
if cols is not None and cols not in column_sets:
column_sets.append(cols)
attempts: List[Tuple[List[str], Optional[Tuple[str, ...]]]] = [
(
["flatpak", "list", scope, "--columns=" + ",".join(cols)],
cols,
)
for cols in column_sets
]
attempts.append((["flatpak", "list", scope], None))
return attempts
def _run_flatpak_list(method: str) -> Optional[Tuple[str, Optional[Tuple[str, ...]]]]:
if shutil.which("flatpak") is None:
return None
scope = "--system" if method == "system" else "--user"
supported = _run_flatpak_columns_help()
for args, columns in _flatpak_list_attempts(scope, supported):
try:
proc = subprocess.run( # nosec
args,
shell=False,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except Exception: # nosec B112
continue
if proc.returncode == 0:
return proc.stdout or "", columns
return None
def _flatpak_remote_from_ref(
flatpak_root: str, app_id: str, arch: str, branch: str, remote_names: List[str]
) -> Optional[str]:
for remote_name in remote_names:
ref = os.path.join(
flatpak_root,
"repo",
"refs",
"remotes",
remote_name,
"app",
app_id,
arch,
branch,
)
if os.path.exists(ref):
return remote_name
return None
def _parse_flatpak_deploy_origin(branch_dir: str) -> Optional[str]:
active_dir = os.path.join(branch_dir, "active")
candidates = [
os.path.join(active_dir, "origin"),
os.path.join(active_dir, "metadata"),
]
origin = _read_first_existing_text([candidates[0]])
if origin:
return origin
metadata = candidates[1]
if os.path.isfile(metadata):
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read(metadata, encoding="utf-8")
except Exception:
return None
for section in ("Application", "Runtime"):
if parser.has_option(section, "origin"):
value = parser.get(section, "origin", fallback="").strip()
if value:
return value
return None
def _find_flatpaks_in_root(
flatpak_root: str,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakInstall]:
apps_dir = os.path.join(flatpak_root, "app")
if not os.path.isdir(apps_dir):
return []
remote_names = [
r.name
for r in find_flatpak_remotes(flatpak_root, method=method, user=user, home=home)
]
out: List[FlatpakInstall] = []
try:
app_ids = sorted(os.listdir(apps_dir))
except OSError:
return []
seen: Set[Tuple[str, Optional[str], Optional[str], Optional[str]]] = set()
for app_id in app_ids:
app_path = os.path.join(apps_dir, app_id)
if not os.path.isdir(app_path):
continue
try:
arches = sorted(os.listdir(app_path))
except OSError:
continue
for arch in arches:
arch_path = os.path.join(app_path, arch)
if not os.path.isdir(arch_path):
continue
try:
branches = sorted(os.listdir(arch_path))
except OSError:
continue
for branch in branches:
branch_path = os.path.join(arch_path, branch)
if not os.path.isdir(branch_path):
continue
active_dir = os.path.join(branch_path, "active")
if not os.path.exists(active_dir):
continue
remote = _parse_flatpak_deploy_origin(branch_path)
if not remote:
remote = _flatpak_remote_from_ref(
flatpak_root, app_id, arch, branch, remote_names
)
key = (app_id, remote, branch, arch)
if key in seen:
continue
seen.add(key)
out.append(
FlatpakInstall(
name=app_id,
method=method,
remote=remote,
branch=branch or None,
arch=arch or None,
kind="app",
ref=f"app/{app_id}/{arch}/{branch}",
user=user,
home=home,
)
)
return sorted(
out, key=lambda f: (f.name, f.remote or "", f.branch or "", f.arch or "")
)
def find_flatpak_remotes(
flatpak_root: str,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakRemote]:
"""Return configured Flatpak remotes for a Flatpak installation root.
Flatpak stores remotes in the OSTree repo config. This gives us the remote
names and repository URLs. It does not reliably preserve the original
.flatpakref/.flatpakrepo URL that was used during installation.
"""
config_path = os.path.join(flatpak_root, "repo", "config")
if not os.path.isfile(config_path):
return []
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
parser.read(config_path, encoding="utf-8")
except Exception:
return []
out: List[FlatpakRemote] = []
for section in parser.sections():
match = re.fullmatch(r'remote\s+"(.+)"', section)
if not match:
continue
name = match.group(1).strip()
url = parser.get(section, "url", fallback="").strip()
if not name or not url:
continue
out.append(
FlatpakRemote(
name=name,
method=method,
url=url,
user=user,
home=home,
)
)
return sorted(out, key=lambda r: (r.method, r.user or "", r.name))
def find_user_flatpaks(home: str, user: Optional[str] = None) -> List[FlatpakInstall]:
"""Return per-user Flatpak applications installed under a home directory."""
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
return _find_flatpaks_in_root(flatpak_root, method="user", user=user, home=home)
def find_user_flatpak_remotes(
home: str, user: Optional[str] = None
) -> List[FlatpakRemote]:
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
return find_flatpak_remotes(flatpak_root, method="user", user=user, home=home)
def find_system_flatpaks() -> List[FlatpakInstall]:
"""Return Flatpak refs installed system-wide.
Prefer `flatpak list --system` because it is Flatpak's own view of
installed refs and includes layouts the filesystem scanner might miss.
Fall back to the on-disk app deployment tree when the command is
unavailable or produces unparsable output.
"""
listing = _run_flatpak_list("system")
if listing is not None:
output, columns = listing
parsed = _parse_flatpak_list_output(output, method="system", columns=columns)
if parsed or not output.strip():
return parsed
return _find_flatpaks_in_root("/var/lib/flatpak", method="system")
def find_system_flatpak_remotes() -> List[FlatpakRemote]:
return find_flatpak_remotes("/var/lib/flatpak", method="system")
def _parse_snap_notes(notes: str) -> List[str]:
if not notes or notes == "-":
return []
cleaned = notes.replace(",", " ").replace(";", " ")
return sorted(
{n.strip().lower() for n in cleaned.split() if n.strip() and n.strip() != "-"}
)
def _parse_snap_list_output(output: str) -> List[SnapInstall]:
out: List[SnapInstall] = []
for idx, line in enumerate(output.splitlines()):
line = line.strip()
if not line:
continue
if idx == 0 and line.lower().startswith("name"):
continue
parts = line.split(maxsplit=5)
if len(parts) < 5:
continue
name = parts[0]
revision: Optional[int]
try:
revision = int(parts[2])
except ValueError:
revision = None
tracking = parts[3]
channel = None if tracking in {"-", ""} else tracking
notes = _parse_snap_notes(parts[5] if len(parts) > 5 else "")
out.append(
SnapInstall(
name=name,
channel=channel,
revision=revision,
classic="classic" in notes,
devmode="devmode" in notes,
dangerous="dangerous" in notes,
notes=notes,
source="snap-list",
)
)
return sorted(out, key=lambda s: s.name)
def _run_snap_list() -> Optional[str]:
if shutil.which("snap") is None:
return None
try:
proc = subprocess.run( # nosec
["snap", "list"],
shell=False,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except Exception:
return None
if proc.returncode != 0:
return None
return proc.stdout or ""
def _find_system_snaps_from_filesystem() -> List[SnapInstall]:
snapd_snaps = "/var/lib/snapd/snaps"
if not os.path.isdir(snapd_snaps):
return []
current_revisions: Dict[str, int] = {}
snap_mounts = "/snap"
if os.path.isdir(snap_mounts):
try:
mount_names = os.listdir(snap_mounts)
except OSError:
mount_names = []
for name in mount_names:
current = os.path.join(snap_mounts, name, "current")
try:
target = os.readlink(current)
except OSError:
continue
try:
current_revisions[name] = int(os.path.basename(target.rstrip("/")))
except ValueError:
continue
candidates: Dict[str, List[int]] = {}
try:
entries = os.listdir(snapd_snaps)
except OSError:
return []
for entry in entries:
if not entry.endswith(".snap") or "_" not in entry:
continue
name, rev_text = entry[:-5].rsplit("_", 1)
try:
revision = int(rev_text)
except ValueError:
continue
candidates.setdefault(name, []).append(revision)
out: List[SnapInstall] = []
for name, revisions in candidates.items():
revision = current_revisions.get(name)
if revision is None:
revision = max(revisions)
out.append(SnapInstall(name=name, revision=revision, source="filesystem"))
return sorted(out, key=lambda s: s.name)
def find_system_snaps() -> List[SnapInstall]:
"""Return system-wide snap packages.
Prefer `snap list` because it exposes channel tracking and confinement notes.
Fall back to snapd's on-disk snap filenames when the command is unavailable.
"""
output = _run_snap_list()
if output is not None:
parsed = _parse_snap_list_output(output)
if parsed:
return parsed
return _find_system_snaps_from_filesystem()
def collect_non_system_users() -> List[UserRecord]:
defs = parse_login_defs()
uid_min = defs.get("UID_MIN", 1000)
@ -139,6 +786,10 @@ def collect_non_system_users() -> List[UserRecord]:
ssh_files = find_user_ssh_files(home) if home and home.startswith("/") else []
flatpaks: List[FlatpakInstall] = []
if home and home.startswith("/"):
flatpaks = find_user_flatpaks(home, user=name)
users.append(
UserRecord(
name=name,
@ -150,6 +801,7 @@ def collect_non_system_users() -> List[UserRecord]:
primary_group=primary_group,
supplementary_groups=supp,
ssh_files=ssh_files,
flatpaks=flatpaks,
)
)

110
enroll/ansible.py Normal file
View file

@ -0,0 +1,110 @@
from __future__ import annotations
from typing import Optional
from .ansible_renderer.context import _prepare_ansible_context
from .ansible_renderer.layout import _write_manifest_playbook, _write_site_scaffold
from .ansible_renderer.model import (
AnsibleManifestPlan,
AnsibleRole,
_collect_ansible_roles,
)
from .ansible_renderer.roles.container_images import _render_container_images_role
from .ansible_renderer.roles.desktop import _render_flatpak_role, _render_snap_role
from .ansible_renderer.roles.managed_files import _render_managed_file_roles
from .ansible_renderer.roles.packages import (
_render_common_ansible_roles,
_render_package_roles,
_render_service_roles,
)
from .ansible_renderer.roles.runtime import (
_render_firewall_runtime_role,
_render_sysctl_role,
)
from .ansible_renderer.roles.users import _render_users_role
from .state import inventory_packages_from_state, roles_from_state
class AnsibleManifestRenderer:
"""Render Ansible roles and playbook from a harvest bundle."""
def __init__(
self,
bundle_dir: str,
out_dir: str,
*,
fqdn: Optional[str] = None,
jinjaturtle: str = "auto",
no_common_roles: bool = False,
) -> None:
self.bundle_dir = bundle_dir
self.out_dir = out_dir
self.fqdn = fqdn
self.jinjaturtle = jinjaturtle
self.no_common_roles = no_common_roles
def render(self) -> None:
state = AnsibleRole.load_state(self.bundle_dir)
roles = roles_from_state(state)
inventory_packages = inventory_packages_from_state(state)
ctx = _prepare_ansible_context(
self.bundle_dir,
self.out_dir,
fqdn=self.fqdn,
jinjaturtle=self.jinjaturtle,
)
_write_site_scaffold(ctx)
use_common_roles = (not ctx.site_mode) and (not self.no_common_roles)
collection = _collect_ansible_roles(
roles,
inventory_packages,
use_common_roles=use_common_roles,
)
manifest_plan = AnsibleManifestPlan()
_render_users_role(ctx, manifest_plan, roles.get("users", {}))
_render_flatpak_role(ctx, manifest_plan, roles.get("flatpak", {}))
_render_snap_role(ctx, manifest_plan, roles.get("snap", {}))
_render_container_images_role(
ctx, manifest_plan, roles.get("container_images", {})
)
_render_managed_file_roles(ctx, manifest_plan, roles)
_render_sysctl_role(ctx, manifest_plan, roles.get("sysctl", {}))
_render_firewall_runtime_role(
ctx, manifest_plan, roles.get("firewall_runtime", {})
)
_render_service_roles(ctx, manifest_plan, collection.services)
common_tail_roles = _render_common_ansible_roles(
ctx, manifest_plan, collection.common_role_groups, collection.packages
)
_render_package_roles(ctx, manifest_plan, collection.packages)
# Place cron/logrotate at the end of the playbook so users exist before
# per-user crontabs are restored and core packages/services are in place.
for role in ("cron", "logrotate"):
manifest_plan.mark_tail_package(role)
for role in common_tail_roles:
manifest_plan.mark_tail_package(role)
_write_manifest_playbook(ctx, manifest_plan.ordered_roles())
def manifest_from_bundle_dir(
bundle_dir: str,
out_dir: str,
*,
fqdn: Optional[str] = None,
jinjaturtle: str = "auto",
no_common_roles: bool = False,
) -> None:
AnsibleManifestRenderer(
bundle_dir,
out_dir,
fqdn=fqdn,
jinjaturtle=jinjaturtle,
no_common_roles=no_common_roles,
).render()

View file

@ -0,0 +1 @@
"""Ansible manifest renderer implementation."""

View file

@ -0,0 +1,56 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional, Tuple
from ..jinjaturtle import find_jinjaturtle_cmd
@dataclass
class AnsibleManifestContext:
bundle_dir: str
out_dir: str
roles_root: str
fqdn: Optional[str]
site_mode: bool
jt_exe: Optional[str]
jt_enabled: bool
def _resolve_jinjaturtle_mode(jinjaturtle: str) -> Tuple[Optional[str], bool]:
jt_exe = find_jinjaturtle_cmd()
if jinjaturtle not in ("auto", "on", "off"):
raise ValueError("jinjaturtle must be one of: auto, on, off")
if jinjaturtle == "on":
if not jt_exe:
raise RuntimeError("jinjaturtle requested but not found on PATH")
return jt_exe, True
if jinjaturtle == "auto":
return jt_exe, jt_exe is not None
return jt_exe, False
def _prepare_ansible_context(
bundle_dir: str,
out_dir: str,
*,
fqdn: Optional[str],
jinjaturtle: str,
) -> AnsibleManifestContext:
site_mode = fqdn is not None and fqdn != ""
jt_exe, jt_enabled = _resolve_jinjaturtle_mode(jinjaturtle)
os.makedirs(out_dir, exist_ok=True)
roles_root = os.path.join(out_dir, "roles")
os.makedirs(roles_root, exist_ok=True)
return AnsibleManifestContext(
bundle_dir=bundle_dir,
out_dir=out_dir,
roles_root=roles_root,
fqdn=fqdn,
site_mode=site_mode,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
)

View file

@ -0,0 +1,69 @@
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional, Set, Tuple
from ..jinjaturtle import can_jinjify_path, infer_other_formats, run_jinjaturtle
from .yamlutil import _merge_mappings_overwrite, _yaml_dump_mapping, _yaml_load_mapping
def _jinjify_managed_files(
bundle_dir: str,
role: str,
role_dir: str,
managed_files: List[Dict[str, Any]],
*,
jt_exe: Optional[str],
jt_enabled: bool,
overwrite_templates: bool,
) -> Tuple[Set[str], str]:
"""
Return (templated_src_rels, combined_vars_text).
combined_vars_text is a YAML mapping fragment (no leading ---).
"""
templated: Set[str] = set()
vars_map: Dict[str, Any] = {}
if not (jt_enabled and jt_exe):
return templated, ""
for mf in managed_files:
dest_path = mf.get("path", "")
src_rel = mf.get("src_rel", "")
if not dest_path or not src_rel:
continue
if not can_jinjify_path(dest_path):
continue
artifact_path = os.path.join(bundle_dir, "artifacts", role, src_rel)
if not os.path.isfile(artifact_path):
continue
try:
force_fmt = infer_other_formats(dest_path)
res = run_jinjaturtle(
jt_exe, artifact_path, role_name=role, force_format=force_fmt
)
except Exception:
# If jinjaturtle cannot process a file for any reason, skip silently.
# (Enroll's core promise is to be optimistic and non-interactive.)
continue # nosec
tmpl_rel = src_rel + ".j2"
tmpl_dst = os.path.join(role_dir, "templates", tmpl_rel)
if overwrite_templates or not os.path.exists(tmpl_dst):
os.makedirs(os.path.dirname(tmpl_dst), exist_ok=True)
with open(tmpl_dst, "w", encoding="utf-8") as f:
f.write(res.template_text)
templated.add(src_rel)
if res.vars_text.strip():
# merge YAML mappings; last wins (avoids duplicate keys)
chunk = _yaml_load_mapping(res.vars_text)
if chunk:
vars_map = _merge_mappings_overwrite(vars_map, chunk)
if vars_map:
combined = _yaml_dump_mapping(vars_map, sort_keys=True)
return templated, combined
return templated, ""

View file

@ -0,0 +1,304 @@
from __future__ import annotations
import os
import re
import shutil
import stat
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from .context import AnsibleManifestContext
from .yamlutil import _merge_mappings_overwrite, _yaml_dump_mapping, _yaml_load_mapping
def _copy2_replace(src: str, dst: str) -> None:
dst_dir = os.path.dirname(dst)
os.makedirs(dst_dir, exist_ok=True)
# Copy to a temp file in the same directory, then atomically replace.
fd, tmp = tempfile.mkstemp(prefix=".enroll-tmp-", dir=dst_dir)
os.close(fd)
try:
shutil.copy2(src, tmp)
# Ensure the working tree stays mergeable: make the file user-writable.
st = os.stat(tmp, follow_symlinks=False)
mode = stat.S_IMODE(st.st_mode)
if not (mode & stat.S_IWUSR):
os.chmod(tmp, mode | stat.S_IWUSR)
os.replace(tmp, dst)
finally:
try:
os.unlink(tmp)
except FileNotFoundError:
pass
def _copy_artifacts(
bundle_dir: str,
role: str,
dst_files_dir: str,
*,
preserve_existing: bool = False,
exclude_rels: Optional[Set[str]] = None,
) -> None:
"""Copy harvested artifacts for a role into a destination *files* directory.
In non --fqdn mode, this is usually <role_dir>/files.
In --fqdn site mode, this is usually:
inventory/host_vars/<fqdn>/<role>/.files
"""
artifacts_dir = os.path.join(bundle_dir, "artifacts", role)
if not os.path.isdir(artifacts_dir):
return
for root, _, files in os.walk(artifacts_dir):
for fn in files:
src = os.path.join(root, fn)
rel = os.path.relpath(src, artifacts_dir)
dst = os.path.join(dst_files_dir, rel)
# If a file was successfully templatised by JinjaTurtle, do NOT
# also materialise the raw copy in the destination files dir.
if exclude_rels and rel in exclude_rels:
try:
if os.path.isfile(dst):
os.remove(dst)
except Exception:
pass # nosec
continue
if preserve_existing and os.path.exists(dst):
continue
os.makedirs(os.path.dirname(dst), exist_ok=True)
_copy2_replace(src, dst)
def _write_role_scaffold(role_dir: str) -> None:
os.makedirs(os.path.join(role_dir, "tasks"), exist_ok=True)
os.makedirs(os.path.join(role_dir, "handlers"), exist_ok=True)
os.makedirs(os.path.join(role_dir, "defaults"), exist_ok=True)
os.makedirs(os.path.join(role_dir, "meta"), exist_ok=True)
os.makedirs(os.path.join(role_dir, "files"), exist_ok=True)
os.makedirs(os.path.join(role_dir, "templates"), exist_ok=True)
def _role_tag(role: str) -> str:
"""Return a stable Ansible tag name for a role.
Used by `enroll diff --enforce` to run only the roles needed to repair drift.
"""
r = str(role or "").strip()
# Ansible tag charset is fairly permissive, but keep it portable and consistent.
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", r).strip("_")
if not safe:
safe = "other"
return f"role_{safe}"
def _write_playbook_all(path: str, roles: List[str]) -> None:
pb_lines = [
"---",
"- name: Apply all roles on all hosts",
" gather_facts: true",
" hosts: all",
" become: true",
" roles:",
]
for r in roles:
pb_lines.append(f" - role: {r}")
pb_lines.append(f" tags: [{_role_tag(r)}]")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(pb_lines) + "\n")
def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
pb_lines = [
"---",
f"- name: Apply all roles on {fqdn}",
f" hosts: {fqdn}",
" gather_facts: true",
" become: true",
" roles:",
]
for r in roles:
pb_lines.append(f" - role: {r}")
pb_lines.append(f" tags: [{_role_tag(r)}]")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(pb_lines) + "\n")
def _ensure_ansible_cfg(cfg_path: str) -> None:
if not os.path.exists(cfg_path):
with open(cfg_path, "w", encoding="utf-8") as f:
f.write("[defaults]\n")
f.write("roles_path = roles\n")
f.write("interpreter_python=/usr/bin/python3\n")
f.write("inventory = inventory\n")
f.write("stdout_callback = unixy\n")
f.write("force_color = 1\n")
f.write("vars_plugins_enabled = host_group_vars\n")
f.write("fact_caching = jsonfile\n")
f.write("fact_caching_connection = .enroll_cached_facts\n")
f.write("forks = 30\n")
f.write("remote_tmp = /tmp/ansible-${USER}\n")
f.write("timeout = 12\n")
f.write("[ssh_connection]\n")
f.write("pipelining = True\n")
f.write("scp_if_ssh = True\n")
return
def _ensure_requirements_yaml(
req_path: str,
collections: Optional[List[Dict[str, str]]] = None,
) -> None:
requested = collections or [{"name": "community.general", "version": ">=13.0.0"}]
existing: Dict[str, Any] = {}
if os.path.exists(req_path):
try:
existing = _yaml_load_mapping(Path(req_path).read_text(encoding="utf-8"))
except Exception:
existing = {}
current_items = existing.get("collections")
if not isinstance(current_items, list):
current_items = []
by_name: Dict[str, Dict[str, str]] = {}
ordered_names: List[str] = []
for item in current_items:
if isinstance(item, str):
name = item.strip()
if not name:
continue
entry: Dict[str, str] = {"name": name}
elif isinstance(item, dict):
name = str(item.get("name") or "").strip()
if not name:
continue
entry = {str(k): str(v) for k, v in item.items() if v is not None}
entry["name"] = name
else:
continue
if name not in by_name:
ordered_names.append(name)
by_name[name] = entry
for item in requested:
name = str(item.get("name") or "").strip()
if not name:
continue
entry = dict(item)
entry["name"] = name
if name not in by_name:
ordered_names.append(name)
by_name[name] = entry
else:
by_name[name].update(
{k: v for k, v in entry.items() if v not in (None, "")}
)
out = {"collections": [by_name[name] for name in ordered_names]}
Path(req_path).parent.mkdir(parents=True, exist_ok=True)
Path(req_path).write_text(
"---\n" + _yaml_dump_mapping(out, sort_keys=False), encoding="utf-8"
)
def _ensure_inventory_host(inv_path: str, fqdn: str) -> None:
os.makedirs(os.path.dirname(inv_path), exist_ok=True)
if not os.path.exists(inv_path):
with open(inv_path, "w", encoding="utf-8") as f:
f.write("[all]\n")
f.write(fqdn + "\n")
return
with open(inv_path, "r", encoding="utf-8") as f:
lines = [ln.rstrip("\n") for ln in f.readlines()]
# ensure there is an [all] group; if not, create it at top
if not any(ln.strip() == "[all]" for ln in lines):
lines = ["[all]"] + lines
# check if fqdn already present (exact match, ignoring whitespace)
if any(ln.strip() == fqdn for ln in lines):
return
# append at end
lines.append(fqdn)
with open(inv_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
def _hostvars_path(site_root: str, fqdn: str, role: str) -> str:
return os.path.join(site_root, "inventory", "host_vars", fqdn, f"{role}.yml")
def _host_role_files_dir(site_root: str, fqdn: str, role: str) -> str:
"""Host-specific files dir for a given role.
Layout:
inventory/host_vars/<fqdn>/<role>/.files/
"""
return os.path.join(site_root, "inventory", "host_vars", fqdn, role, ".files")
def _write_hostvars(site_root: str, fqdn: str, role: str, data: Dict[str, Any]) -> None:
"""Write host_vars YAML for a role for a specific host.
This is host-specific state and should track the current harvest output.
Existing keys not mentioned in `data` are preserved, but keys in `data`
are overwritten (including list values).
"""
path = _hostvars_path(site_root, fqdn, role)
os.makedirs(os.path.dirname(path), exist_ok=True)
existing_map: Dict[str, Any] = {}
if os.path.exists(path):
try:
existing_text = Path(path).read_text(encoding="utf-8")
existing_map = _yaml_load_mapping(existing_text)
except Exception:
existing_map = {}
merged = _merge_mappings_overwrite(existing_map, data)
out = "---\n" + _yaml_dump_mapping(merged, sort_keys=True)
with open(path, "w", encoding="utf-8") as f:
f.write(out)
def _write_role_defaults(role_dir: str, mapping: Dict[str, Any]) -> None:
"""Overwrite role defaults/main.yml with the provided mapping."""
defaults_path = os.path.join(role_dir, "defaults", "main.yml")
os.makedirs(os.path.dirname(defaults_path), exist_ok=True)
out = "---\n" + _yaml_dump_mapping(mapping, sort_keys=True)
with open(defaults_path, "w", encoding="utf-8") as f:
f.write(out)
def _write_site_scaffold(ctx: AnsibleManifestContext) -> None:
if not ctx.site_mode:
return
os.makedirs(os.path.join(ctx.out_dir, "inventory"), exist_ok=True)
os.makedirs(os.path.join(ctx.out_dir, "inventory", "host_vars"), exist_ok=True)
os.makedirs(os.path.join(ctx.out_dir, "playbooks"), exist_ok=True)
_ensure_inventory_host(
os.path.join(ctx.out_dir, "inventory", "hosts.ini"), ctx.fqdn or ""
)
_ensure_ansible_cfg(os.path.join(ctx.out_dir, "ansible.cfg"))
_ensure_requirements_yaml(os.path.join(ctx.out_dir, "requirements.yml"))
def _write_manifest_playbook(ctx: AnsibleManifestContext, roles: List[str]) -> None:
if ctx.site_mode:
_write_playbook_host(
os.path.join(ctx.out_dir, "playbooks", f"{ctx.fqdn}.yml"),
ctx.fqdn or "",
roles,
)
else:
_write_playbook_all(os.path.join(ctx.out_dir, "playbook.yml"), roles)

View file

@ -0,0 +1,227 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Set
from ..cm import CMModule, package_section_label, section_label_for_packages
from ..role_names import avoid_reserved_role_name
@dataclass
class AnsibleRoleCollection:
services: List[Dict[str, Any]]
packages: List[Dict[str, Any]]
common_role_groups: Dict[str, List[Dict[str, Any]]]
class AnsibleRole(CMModule):
"""Ansible-specific view of a renderer-neutral CMModule."""
def __init__(
self,
role_name: str,
*,
var_prefix: Optional[str] = None,
section_label: Optional[str] = None,
grouped: bool = False,
) -> None:
super().__init__(role_name=role_name, module_name=role_name)
self.var_prefix = var_prefix or role_name
self.section_label = section_label
self.grouped = grouped
self.entries: List[Dict[str, Any]] = []
self.excluded: List[Dict[str, Any]] = []
self.origin_lines: List[str] = []
def add_package_snapshot(self, snap: Dict[str, Any]) -> None:
pkg = str(snap.get("package") or "").strip()
source_role = str(snap.get("role_name") or pkg or self.role_name)
self.entries.append({"kind": "package", "snapshot": snap})
if pkg:
self.packages.add(pkg)
self.origin_lines.append(f"package `{pkg}` from role `{source_role}`")
self.add_managed_content(snap)
def add_service_snapshot(self, snap: Dict[str, Any]) -> None:
unit = str(snap.get("unit") or "").strip()
source_role = str(snap.get("role_name") or unit or self.role_name)
self.entries.append({"kind": "service", "snapshot": snap})
for pkg in snap.get("packages", []) or []:
pkg_s = str(pkg or "").strip()
if pkg_s:
self.packages.add(pkg_s)
if unit:
unit_file_state = str(snap.get("unit_file_state") or "")
self.services.setdefault(
unit,
{
"name": unit,
"manage": True,
"enabled": unit_file_state in ("enabled", "enabled-runtime"),
"state": (
"started" if snap.get("active_state") == "active" else "stopped"
),
},
)
self.origin_lines.append(f"service `{unit}` from role `{source_role}`")
self.add_managed_content(snap)
def add_managed_content(self, snap: Dict[str, Any]) -> None:
for d in self.managed_dirs_from_snapshot(snap):
path = str(d.get("path") or "").strip()
self.add_managed_dir(
path,
dest=path,
owner=d.get("owner") or "root",
group=d.get("group") or "root",
mode=d.get("mode") or "0755",
)
for mf in self.managed_files_from_snapshot(snap):
path = str(mf.get("path") or "").strip()
src_rel = str(mf.get("src_rel") or "").strip()
if not path or not src_rel:
continue
self.add_managed_file(
path,
dest=path,
src_rel=src_rel,
owner=mf.get("owner") or "root",
group=mf.get("group") or "root",
mode=mf.get("mode") or "0644",
reason=mf.get("reason") or "managed_file",
)
for ml in self.managed_links_from_snapshot(snap):
path = str(ml.get("path") or "").strip()
target = str(ml.get("target") or "").strip()
if not path or not target:
continue
self.add_managed_link(path, dest=path, src=target)
self.excluded.extend(snap.get("excluded", []) or [])
self.add_snapshot_notes(snap)
@property
def sorted_packages(self) -> List[str]:
return sorted(self.packages)
@property
def systemd_units_var(self) -> List[Dict[str, Any]]:
return [self.services[k] for k in sorted(self.services)]
class AnsibleManifestPlan:
"""Track generated Ansible roles without scattering category lists."""
_ORDER = (
"apt_config",
"dnf_config",
"package",
"service",
"etc_custom",
"usr_local_custom",
"extra_paths",
"flatpak",
"snap",
"container_images",
"users",
"tail_package",
"sysctl",
"firewall_runtime",
)
def __init__(self) -> None:
self._roles: Dict[str, List[str]] = {category: [] for category in self._ORDER}
self._tail_packages: List[str] = []
def add(self, category: str, role: str) -> None:
if category not in self._roles:
raise ValueError(f"unknown Ansible role category: {category}")
if role and role not in self._roles[category]:
self._roles[category].append(role)
def roles(self, category: str) -> List[str]:
return list(self._roles.get(category, []))
def has(self, category: str, role: str) -> bool:
return role in self._roles.get(category, [])
def mark_tail_package(self, role: str) -> None:
if self.has("package", role) and role not in self._tail_packages:
self._tail_packages.append(role)
def ordered_roles(self) -> List[str]:
tail = set(self._tail_packages)
package_roles = [r for r in self._roles["package"] if r not in tail]
out: List[str] = []
for category in self._ORDER:
if category == "package":
out.extend(package_roles)
elif category == "tail_package":
out.extend(self._tail_packages)
else:
out.extend(self._roles[category])
return out
def _role_id(raw: str) -> str:
"""Return an Ansible-safe role identifier from an arbitrary label."""
s = re.sub(r"[^A-Za-z0-9]+", "_", raw or "misc")
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
s = s.lower()
s = re.sub(r"_+", "_", s).strip("_")
if not s:
s = "misc"
if not re.match(r"^[a-z_]", s):
s = "r_" + s
return s
def _section_role_name(label: str, occupied_roles: Set[str]) -> str:
"""Create a stable section role name, avoiding generated-role collisions."""
base = avoid_reserved_role_name(_role_id(label), prefix="section")
role = base if base not in occupied_roles else f"section_{base}"
n = 2
while role in occupied_roles:
role = f"section_{base}_{n}"
n += 1
occupied_roles.add(role)
return role
def _collect_ansible_roles(
roles: Dict[str, Any],
inventory_packages: Dict[str, Any],
*,
use_common_roles: bool,
) -> AnsibleRoleCollection:
services = roles.get("services", []) or []
packages = roles.get("packages", []) or []
common_role_groups: Dict[str, List[Dict[str, Any]]] = {}
if use_common_roles:
for svc in services:
label = section_label_for_packages(
svc.get("packages", []) or [], inventory_packages
)
common_role_groups.setdefault(label, []).append(
{"kind": "service", "snapshot": svc}
)
for pr in packages:
label = package_section_label(pr, inventory_packages)
common_role_groups.setdefault(label, []).append(
{"kind": "package", "snapshot": pr}
)
return AnsibleRoleCollection(
services=[], packages=[], common_role_groups=common_role_groups
)
return AnsibleRoleCollection(
services=services,
packages=packages,
common_role_groups=common_role_groups,
)

View file

@ -0,0 +1,226 @@
from __future__ import annotations
import os
import re
from typing import Any, Callable, Dict, List, Set
def _markdown_list(items: List[str]) -> str:
values = [str(item) for item in items if str(item)]
return "\n".join(f"- {item}" for item in values) or "- (none)"
def _managed_file_lines(
managed_files: List[Dict[str, Any]], *, include_reason: bool
) -> List[str]:
out: List[str] = []
for mf in managed_files:
path = str(mf.get("path") or "")
if not path:
continue
if include_reason:
out.append(f"{path} ({mf.get('reason')})")
else:
out.append(path)
return out
def _excluded_lines(excluded: List[Dict[str, Any]]) -> List[str]:
return [f"{e.get('path')} ({e.get('reason')})" for e in excluded if e.get("path")]
def _read_artifact_lines(bundle_dir: str, role: str, src_rel: str) -> List[str]:
art_path = os.path.join(bundle_dir, "artifacts", role, src_rel)
try:
with open(art_path, "r", encoding="utf-8", errors="replace") as f:
return [line.rstrip("\n") for line in f]
except OSError:
return []
def _apt_config_readme(
*,
bundle_dir: str,
role: str,
snapshot: Dict[str, Any],
managed_files: List[Dict[str, Any]],
managed_dirs: List[Dict[str, Any]],
excluded: List[Dict[str, Any]],
notes: List[Any],
) -> str:
source_paths: List[str] = []
keyring_paths: List[str] = []
repo_hosts: Set[str] = set()
url_re = re.compile(r"(?:https?|ftp)://([^/\s]+)", re.IGNORECASE)
for mf in managed_files:
path = str(mf.get("path") or "")
src_rel = str(mf.get("src_rel") or "")
if not path or not src_rel:
continue
if path == "/etc/apt/sources.list" or path.startswith(
"/etc/apt/sources.list.d/"
):
source_paths.append(path)
for line in _read_artifact_lines(bundle_dir, role, src_rel):
s = line.strip()
if not s or s.startswith("#"):
continue
for match in url_re.finditer(s):
repo_hosts.add(match.group(1))
if (
path.startswith("/etc/apt/trusted.gpg")
or path.startswith("/etc/apt/keyrings/")
or path.startswith("/usr/share/keyrings/")
):
keyring_paths.append(path)
return f"""# apt_config
APT configuration harvested from the system (sources, pinning, and keyrings).
## Repository hosts
{_markdown_list(sorted(repo_hosts))}
## Source files
{_markdown_list(sorted(set(source_paths)))}
## Keyrings
{_markdown_list(sorted(set(keyring_paths)))}
## Managed files
{_markdown_list(_managed_file_lines(managed_files, include_reason=True))}
## Excluded
{_markdown_list(_excluded_lines(excluded))}
## Notes
{_markdown_list([str(n) for n in notes])}
"""
def _dnf_config_readme(
*,
bundle_dir: str,
role: str,
snapshot: Dict[str, Any],
managed_files: List[Dict[str, Any]],
managed_dirs: List[Dict[str, Any]],
excluded: List[Dict[str, Any]],
notes: List[Any],
) -> str:
repo_paths: List[str] = []
key_paths: List[str] = []
repo_hosts: Set[str] = set()
url_re = re.compile(r"(?:https?|ftp)://([^/\s]+)", re.IGNORECASE)
file_url_re = re.compile(r"file://(/[^\s]+)")
for mf in managed_files:
path = str(mf.get("path") or "")
src_rel = str(mf.get("src_rel") or "")
if not path or not src_rel:
continue
if path.startswith("/etc/yum.repos.d/") and path.endswith(".repo"):
repo_paths.append(path)
for line in _read_artifact_lines(bundle_dir, role, src_rel):
s = line.strip()
if not s or s.startswith("#") or s.startswith(";"):
continue
for match in url_re.finditer(s):
repo_hosts.add(match.group(1))
for match in file_url_re.finditer(s):
key_paths.append(match.group(1))
if path.startswith("/etc/pki/rpm-gpg/"):
key_paths.append(path)
return f"""# dnf_config
DNF/YUM configuration harvested from the system (repos, config files, and RPM GPG keys).
## Repository hosts
{_markdown_list(sorted(repo_hosts))}
## Repo files
{_markdown_list(sorted(set(repo_paths)))}
## GPG keys
{_markdown_list(sorted(set(key_paths)))}
## Managed files
{_markdown_list(_managed_file_lines(managed_files, include_reason=True))}
## Excluded
{_markdown_list(_excluded_lines(excluded))}
## Notes
{_markdown_list([str(n) for n in notes])}
"""
def _simple_managed_files_readme(
title: str,
description: str,
*,
include_reason: bool,
) -> Callable[..., str]:
def _builder(
*,
bundle_dir: str,
role: str,
snapshot: Dict[str, Any],
managed_files: List[Dict[str, Any]],
managed_dirs: List[Dict[str, Any]],
excluded: List[Dict[str, Any]],
notes: List[Any],
) -> str:
return f"""# {title}
{description}
## Managed files
{_markdown_list(_managed_file_lines(managed_files, include_reason=include_reason))}
## Excluded
{_markdown_list(_excluded_lines(excluded))}
## Notes
{_markdown_list([str(n) for n in notes])}
"""
return _builder
def _extra_paths_readme(
*,
bundle_dir: str,
role: str,
snapshot: Dict[str, Any],
managed_files: List[Dict[str, Any]],
managed_dirs: List[Dict[str, Any]],
excluded: List[Dict[str, Any]],
notes: List[Any],
) -> str:
include_pats = snapshot.get("include_patterns", []) or []
exclude_pats = snapshot.get("exclude_patterns", []) or []
return f"""# {role}
User-requested extra file harvesting.
## Include patterns
{_markdown_list([str(p) for p in include_pats])}
## Exclude patterns
{_markdown_list([str(p) for p in exclude_pats])}
## Managed directories
{_markdown_list([str(d.get('path') or '') for d in managed_dirs])}
## Managed files
{_markdown_list(_managed_file_lines(managed_files, include_reason=False))}
## Excluded
{_markdown_list(_excluded_lines(excluded))}
## Notes
{_markdown_list([str(n) for n in notes])}
"""

View file

@ -0,0 +1 @@
"""Role writers for the Ansible renderer."""

View file

@ -0,0 +1,192 @@
from __future__ import annotations
import os
from typing import Any, Dict, List
from ..context import AnsibleManifestContext
from ..layout import (
_ensure_requirements_yaml,
_write_hostvars,
_write_role_defaults,
_write_role_scaffold,
)
from ..model import AnsibleManifestPlan
from ..vars import _normalise_container_image_item
_CONTAINER_COLLECTIONS = [
{"name": "community.docker", "version": ">=4.0.0"},
{"name": "containers.podman", "version": ">=1.0.0"},
]
def _render_container_images_role(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
container_images_snapshot: Dict[str, Any],
) -> None:
raw_images = container_images_snapshot.get("images", []) or []
if not container_images_snapshot and not raw_images:
return
images = [_normalise_container_image_item(img) for img in raw_images]
if not images and not (container_images_snapshot.get("notes") or []):
return
role = container_images_snapshot.get("role_name", "container_images")
role_dir = os.path.join(ctx.roles_root, role)
_write_role_scaffold(role_dir)
_ensure_requirements_yaml(
os.path.join(ctx.out_dir, "requirements.yml"), _CONTAINER_COLLECTIONS
)
vars_map = {"container_images": images}
if ctx.site_mode:
_write_role_defaults(role_dir, {"container_images": []})
_write_hostvars(ctx.out_dir, ctx.fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
f.write(
"---\n"
"dependencies: []\n"
"collections:\n"
" - community.docker\n"
" - containers.podman\n"
)
tasks = """---
- name: Pull Docker images by immutable registry digest
community.docker.docker_image_pull:
name: "{{ item.pull_ref }}"
pull: not_present
platform: "{{ item.platform | default(omit, true) }}"
loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref', 'defined') | list }}"
when:
- item.pull_ref | default('') | length > 0
become: true
- name: Tag Docker images with harvested tag aliases
community.docker.docker_image_tag:
name: "{{ item.0.pull_ref }}"
repository:
- "{{ item.1.ref }}"
loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}"
when:
- item.0.pull_ref | default('') | length > 0
- item.1.repository | default('') | length > 0
- item.1.tag | default('') | length > 0
become: true
- name: Pull system Podman images by immutable registry digest
containers.podman.podman_image:
name: "{{ item.pull_ref }}"
state: present
force: false
platform: "{{ item.platform | default(omit, true) }}"
loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list }}"
when:
- item.pull_ref | default('') | length > 0
become: true
- name: Tag system Podman images with harvested tag aliases
containers.podman.podman_tag:
image: "{{ item.0.pull_ref }}"
target_names:
- "{{ item.1.ref }}"
loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}"
when:
- item.0.pull_ref | default('') | length > 0
- item.1.ref | default('') | length > 0
become: true
- name: Pull user Podman images by immutable registry digest
containers.podman.podman_image:
name: "{{ item.pull_ref }}"
state: present
force: false
platform: "{{ item.platform | default(omit, true) }}"
loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list }}"
when:
- item.pull_ref | default('') | length > 0
- item.user | default('') | length > 0
become: true
become_user: "{{ item.user }}"
- name: Tag user Podman images with harvested tag aliases
containers.podman.podman_tag:
image: "{{ item.0.pull_ref }}"
target_names:
- "{{ item.1.ref }}"
loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}"
when:
- item.0.pull_ref | default('') | length > 0
- item.0.user | default('') | length > 0
- item.1.ref | default('') | length > 0
become: true
become_user: "{{ item.0.user }}"
"""
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
f.write(tasks)
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\n")
def _fmt_image(img: Dict[str, Any]) -> str:
pull_ref = (
img.get("pull_ref") or "(no registry digest; not rendered as an exact pull)"
)
tags = img.get("repo_tags") or []
tag_part = f" tags={', '.join(tags)}" if tags else ""
platform = img.get("platform")
platform_part = f" platform={platform}" if platform else ""
return f"- {img.get('engine', 'unknown')}: {pull_ref}{tag_part}{platform_part}"
notes = list(container_images_snapshot.get("notes", []) or [])
unpinned_notes: List[str] = []
for img in images:
if img.get("pull_ref"):
continue
label = (
", ".join(img.get("repo_tags") or [])
or img.get("image_id")
or "unknown image"
)
unpinned_notes.append(
f"{label}: no RepoDigest was available, so no exact pull task is emitted."
)
readme = (
"""# container_images
Generated Docker and Podman image-cache restoration role.
Images are pulled by immutable registry digest, such as
`registry.example.net/app@sha256:...`, when the harvest found a usable
`RepoDigest`. Local image IDs are recorded in `state.json` for evidence but are
not registry pull references.
**Note:** This role requires the `community.docker` and `containers.podman`
Ansible collections. Install them with:
`ansible-galaxy collection install -r requirements.yml`.
Registry credentials are not harvested. Private-registry authentication must be
managed separately before this role runs.
## Container images
"""
+ "\n".join(_fmt_image(img) for img in images)
+ """
## Notes
"""
+ ("\n".join([f"- {n}" for n in notes + unpinned_notes]) or "- (none)")
+ "\n"
)
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("container_images", role)

View file

@ -0,0 +1,308 @@
from __future__ import annotations
import os
from typing import Any, Dict, List
from ..context import AnsibleManifestContext
from ..layout import (
_ensure_requirements_yaml,
_write_hostvars,
_write_role_defaults,
_write_role_scaffold,
)
from ..model import AnsibleManifestPlan
from ..vars import (
_normalise_flatpak_item,
_normalise_flatpak_remote,
_normalise_snap_item,
)
def _render_flatpak_role(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
flatpak_snapshot: Dict[str, Any],
) -> None:
out_dir = ctx.out_dir
roles_root = ctx.roles_root
fqdn = ctx.fqdn
site_mode = ctx.site_mode
# -------------------------
# Flatpak role (system-wide Flatpak remotes and applications)
# -------------------------
raw_flatpak_apps = flatpak_snapshot.get("system_flatpaks", []) or []
raw_flatpak_remotes = flatpak_snapshot.get("remotes", []) or []
if flatpak_snapshot:
role = flatpak_snapshot.get("role_name", "flatpak")
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
_ensure_requirements_yaml(os.path.join(out_dir, "requirements.yml"))
flatpak_system_flatpaks = [
_normalise_flatpak_item(fp, method="system") for fp in raw_flatpak_apps
]
flatpak_remotes = [_normalise_flatpak_remote(r) for r in raw_flatpak_remotes]
vars_map = {
"flatpak_system_flatpaks": flatpak_system_flatpaks,
"flatpak_remotes": flatpak_remotes,
}
if site_mode:
_write_role_defaults(
role_dir,
{"flatpak_system_flatpaks": [], "flatpak_remotes": []},
)
_write_hostvars(out_dir, fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
with open(
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(
"---\n" "dependencies: []\n" "collections:\n" " - community.general\n"
)
tasks = """---
- name: Ensure system Flatpak remotes exist
ansible.builtin.command:
argv:
- flatpak
- remote-add
- --system
- --if-not-exists
- "{{ item.name }}"
- "{{ item.url }}"
loop: "{{ flatpak_remotes | default([]) }}"
when:
- item.name is defined
- item.url is defined
- item.url | length > 0
become: true
changed_when: false
- name: Install system-wide Flatpaks
community.general.flatpak:
name:
- "{{ item.name }}"
state: present
method: system
remote: "{{ item.remote | default(omit) }}"
from_url: "{{ item.from_url | default(omit) }}"
loop: "{{ flatpak_system_flatpaks | default([]) }}"
when:
- item.name is defined
- item.name | length > 0
become: true
"""
with open(
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(tasks)
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\n")
def _fmt_flatpak_apps(items: List[Dict[str, Any]]) -> str:
lines = []
for item in items:
name = item.get("name")
if not name:
continue
detail_parts = []
for key in ("remote", "branch", "arch"):
value = item.get(key)
if value not in (None, "", []):
detail_parts.append(f"{key}={value}")
details = f" ({', '.join(detail_parts)})" if detail_parts else ""
lines.append(f"- {name}{details}")
return "\n".join(lines) or "- (none)"
def _fmt_flatpak_remotes(items: List[Dict[str, Any]]) -> str:
lines = []
for item in items:
name = item.get("name")
url = item.get("url")
if not name or not url:
continue
lines.append(f"- {name}: {url}")
return "\n".join(lines) or "- (none)"
notes = flatpak_snapshot.get("notes", []) or []
readme = (
"""# flatpak
Generated system-wide Flatpak remotes and applications.
**Note:** This role requires the `community.general` Ansible collection.
Install it with: `ansible-galaxy collection install -r requirements.yml`.
Flatpak `remote` is harvested from the installed deployment where detectable.
The original `.flatpakref` URL is generally not preserved by Flatpak after
installation, so `from_url` is only emitted if a future/hand-edited state file
contains it.
## System Flatpak remotes
"""
+ _fmt_flatpak_remotes(flatpak_remotes)
+ """\n
## System-wide Flatpaks
"""
+ _fmt_flatpak_apps(flatpak_system_flatpaks)
+ """\n
## Notes
"""
+ ("\n".join([f"- {n}" for n in notes]) or "- (none)")
+ """\n"""
)
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("flatpak", role)
def _render_snap_role(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
snap_snapshot: Dict[str, Any],
) -> None:
out_dir = ctx.out_dir
roles_root = ctx.roles_root
fqdn = ctx.fqdn
site_mode = ctx.site_mode
# -------------------------
# Snap role (system-wide snap packages)
# -------------------------
raw_system_snaps = snap_snapshot.get("system_snaps", []) or []
if raw_system_snaps:
role = snap_snapshot.get("role_name", "snap") if snap_snapshot else "snap"
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
_ensure_requirements_yaml(os.path.join(out_dir, "requirements.yml"))
snap_system_snaps = [_normalise_snap_item(s) for s in raw_system_snaps]
vars_map = {"snap_system_snaps": snap_system_snaps}
if site_mode:
_write_role_defaults(role_dir, {"snap_system_snaps": []})
_write_hostvars(out_dir, fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
with open(
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(
"---\n" "dependencies: []\n" "collections:\n" " - community.general\n"
)
tasks = """---
- name: Install system-wide snaps with full detected attributes
community.general.snap:
name:
- "{{ item.name }}"
state: present
channel: "{{ item.channel | default(omit) if not (item.install_revision | default(false)) else omit }}"
revision: "{{ item.revision | default(omit) if (item.install_revision | default(false)) else omit }}"
classic: "{{ item.classic | default(false) }}"
devmode: "{{ item.devmode | default(false) }}"
dangerous: "{{ item.dangerous | default(false) }}"
loop: "{{ snap_system_snaps | default([]) }}"
when:
- item.name is defined
- item.name | length > 0
become: true
register: _enroll_snap_full_results
ignore_errors: true
- name: Install system-wide snaps with compatibility options
community.general.snap:
name:
- "{{ item.item.name }}"
state: present
channel: "{{ item.item.channel | default(omit) if not (item.item.install_revision | default(false)) else omit }}"
classic: "{{ item.item.classic | default(false) }}"
loop: "{{ (_enroll_snap_full_results | default({})).results | default([]) }}"
when:
- item.failed | default(false)
- item.item.name is defined
- item.item.name | length > 0
become: true
register: _enroll_snap_compat_results
ignore_errors: true
- name: Install system-wide snaps with minimal options
community.general.snap:
name:
- "{{ item.item.item.name }}"
state: present
loop: "{{ (_enroll_snap_compat_results | default({})).results | default([]) }}"
when:
- item.failed | default(false)
- item.item.item.name is defined
- item.item.item.name | length > 0
become: true
ignore_errors: true
"""
with open(
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(tasks)
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\n")
def _fmt_snap_apps(items: List[Dict[str, Any]]) -> str:
lines = []
for item in items:
name = item.get("name")
if not name:
continue
detail_parts = []
for key in ("channel", "revision"):
value = item.get(key)
if value not in (None, "", []):
detail_parts.append(f"{key}={value}")
for key in ("classic", "devmode", "dangerous"):
if item.get(key):
detail_parts.append(key)
details = f" ({', '.join(detail_parts)})" if detail_parts else ""
lines.append(f"- {name}{details}")
return "\n".join(lines) or "- (none)"
notes = snap_snapshot.get("notes", []) or []
readme = (
"""# snap
Generated system-wide snap packages.
**Note:** This role requires the `community.general` Ansible collection.
Install it with: `ansible-galaxy collection install -r requirements.yml`.
The first install task uses all harvested attributes. If the installed
`community.general.snap` module is too old for some parameters, the generated
role falls back to reduced then minimal install tasks on a best-effort basis.
## System-wide snaps
"""
+ _fmt_snap_apps(snap_system_snaps)
+ """\n
## Notes
"""
+ ("\n".join([f"- {n}" for n in notes]) or "- (none)")
+ """\n"""
)
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("snap", role)

View file

@ -0,0 +1,257 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional, Tuple
from ..context import AnsibleManifestContext
from ..jinjaturtle import _jinjify_managed_files
from ..layout import (
_copy_artifacts,
_host_role_files_dir,
_write_hostvars,
_write_role_defaults,
_write_role_scaffold,
)
from ..model import AnsibleManifestPlan
from ..readme import (
_apt_config_readme,
_dnf_config_readme,
_extra_paths_readme,
_simple_managed_files_readme,
)
from ..tasks import _render_generic_files_tasks
from ..vars import _build_managed_dirs_var, _build_managed_files_var
from ..yamlutil import _merge_mappings_overwrite, _yaml_load_mapping
@dataclass(frozen=True)
class AnsibleManagedFileRoleSpec:
"""Declarative managed-file singleton role rendering spec.
Puppet collects these singleton snapshots in a simple loop and feeds
each one through the same managed-content renderer. Ansible has more
layout concerns (defaults vs host_vars, optional JinjaTurtle templates,
handlers), but the resource intent is the same, so keep the per-role
differences in data rather than spelling out one branch per role.
"""
key: str
default_role: str
category: str
readme_builder: Callable[..., str]
notify_systemd: Optional[str] = None
handlers: str = "---\n"
include_dirs_when_empty: bool = False
_SYSTEMD_DAEMON_RELOAD_HANDLER = """---
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
"""
MANAGED_FILE_ROLE_SPECS: Tuple[AnsibleManagedFileRoleSpec, ...] = (
AnsibleManagedFileRoleSpec(
key="apt_config",
default_role="apt_config",
category="apt_config",
readme_builder=_apt_config_readme,
),
AnsibleManagedFileRoleSpec(
key="dnf_config",
default_role="dnf_config",
category="dnf_config",
readme_builder=_dnf_config_readme,
),
AnsibleManagedFileRoleSpec(
key="etc_custom",
default_role="etc_custom",
category="etc_custom",
notify_systemd="Run systemd daemon-reload",
handlers=_SYSTEMD_DAEMON_RELOAD_HANDLER,
readme_builder=_simple_managed_files_readme(
"etc_custom",
"Unowned /etc config files not attributed to packages or services.",
include_reason=False,
),
),
AnsibleManagedFileRoleSpec(
key="usr_local_custom",
default_role="usr_local_custom",
category="usr_local_custom",
readme_builder=_simple_managed_files_readme(
"usr_local_custom",
"Unowned /usr/local files (scripts in /usr/local/bin and config under /usr/local/etc).",
include_reason=False,
),
),
AnsibleManagedFileRoleSpec(
key="extra_paths",
default_role="extra_paths",
category="extra_paths",
readme_builder=_extra_paths_readme,
include_dirs_when_empty=True,
),
)
def _managed_file_role_has_resources(
snapshot: Dict[str, Any], spec: AnsibleManagedFileRoleSpec
) -> bool:
if not snapshot:
return False
if snapshot.get("managed_files"):
return True
return bool(spec.include_dirs_when_empty and snapshot.get("managed_dirs"))
def _write_managed_files_role_from_spec(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
snapshot: Dict[str, Any],
spec: AnsibleManagedFileRoleSpec,
) -> None:
role = _write_managed_files_role(
snapshot=snapshot,
default_role=spec.default_role,
bundle_dir=ctx.bundle_dir,
roles_root=ctx.roles_root,
out_dir=ctx.out_dir,
fqdn=ctx.fqdn,
site_mode=ctx.site_mode,
jt_exe=ctx.jt_exe,
jt_enabled=ctx.jt_enabled,
notify_systemd=spec.notify_systemd,
handlers=spec.handlers,
readme_builder=spec.readme_builder,
)
manifest_plan.add(spec.category, role)
def _write_managed_files_role(
*,
snapshot: Dict[str, Any],
default_role: str,
bundle_dir: str,
roles_root: str,
out_dir: str,
fqdn: Optional[str],
site_mode: bool,
jt_exe: Optional[str],
jt_enabled: bool,
notify_systemd: Optional[str],
handlers: str,
readme_builder: Callable[..., str],
) -> str:
"""Render an Ansible role whose main purpose is managed files/dirs.
This covers apt_config, dnf_config, etc_custom, usr_local_custom, and
extra_paths. Their harvested state shape is the same; only their README
and optional handler differ.
"""
role = snapshot.get("role_name", default_role)
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
var_prefix = role
managed_files = snapshot.get("managed_files", []) or []
managed_dirs = snapshot.get("managed_dirs", []) or []
excluded = snapshot.get("excluded", []) or []
notes = snapshot.get("notes", []) or []
templated, jt_vars = _jinjify_managed_files(
bundle_dir,
role,
role_dir,
managed_files,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=not site_mode,
)
if site_mode:
_copy_artifacts(
bundle_dir,
role,
_host_role_files_dir(out_dir, fqdn or "", role),
exclude_rels=templated,
)
else:
_copy_artifacts(
bundle_dir,
role,
os.path.join(role_dir, "files"),
exclude_rels=templated,
)
files_var = _build_managed_files_var(
managed_files,
templated,
notify_other=None,
notify_systemd=notify_systemd,
)
dirs_var = _build_managed_dirs_var(managed_dirs)
jt_map = _yaml_load_mapping(jt_vars) if jt_vars.strip() else {}
vars_map: Dict[str, Any] = {
f"{var_prefix}_managed_files": files_var,
f"{var_prefix}_managed_dirs": dirs_var,
}
vars_map = _merge_mappings_overwrite(vars_map, jt_map)
if site_mode:
_write_role_defaults(
role_dir,
{f"{var_prefix}_managed_files": [], f"{var_prefix}_managed_dirs": []},
)
_write_hostvars(out_dir, fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
tasks = "---\n" + _render_generic_files_tasks(
var_prefix, include_restart_notify=False
)
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
f.write(tasks.rstrip() + "\n")
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(handlers.rstrip() + "\n")
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
f.write("---\ndependencies: []\n")
readme = readme_builder(
bundle_dir=bundle_dir,
role=role,
snapshot=snapshot,
managed_files=managed_files,
managed_dirs=managed_dirs,
excluded=excluded,
notes=notes,
)
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
return role
def _render_managed_file_roles(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
roles: Dict[str, Any],
) -> None:
"""Render file-centric singleton roles in the same loop style as Puppet."""
for spec in MANAGED_FILE_ROLE_SPECS:
snapshot = roles.get(spec.key, {})
if not isinstance(snapshot, dict):
continue
if not _managed_file_role_has_resources(snapshot, spec):
continue
_write_managed_files_role_from_spec(ctx, manifest_plan, snapshot, spec)

View file

@ -0,0 +1,601 @@
from __future__ import annotations
import os
from typing import Any, Dict, List, Set
from ..context import AnsibleManifestContext
from ..jinjaturtle import _jinjify_managed_files
from ..layout import (
_copy_artifacts,
_host_role_files_dir,
_write_hostvars,
_write_role_defaults,
_write_role_scaffold,
)
from ..model import AnsibleManifestPlan, AnsibleRole, _section_role_name
from ..tasks import (
_render_generic_files_tasks,
_render_grouped_systemd_tasks,
_render_install_packages_tasks,
)
from ..vars import (
_build_managed_dirs_var,
_build_managed_files_var,
_build_managed_links_var,
)
from ..yamlutil import _merge_mappings_overwrite, _yaml_load_mapping
from ...role_names import avoid_reserved_role_name
def _render_service_roles(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
services_to_manifest: List[Dict[str, Any]],
) -> None:
bundle_dir = ctx.bundle_dir
out_dir = ctx.out_dir
roles_root = ctx.roles_root
fqdn = ctx.fqdn
site_mode = ctx.site_mode
jt_exe = ctx.jt_exe
jt_enabled = ctx.jt_enabled
# -------------------------
# Service roles
# -------------------------
for svc in services_to_manifest:
source_role = svc["role_name"]
role = avoid_reserved_role_name(source_role, prefix="service")
unit = svc["unit"]
pkgs = svc.get("packages", []) or []
managed_files = svc.get("managed_files", []) or []
managed_dirs = svc.get("managed_dirs", []) or []
managed_links = svc.get("managed_links", []) or []
ansible_role = AnsibleRole(role)
ansible_role.add_service_snapshot(svc)
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
var_prefix = role
unit_state = ansible_role.services.get(unit, {})
enabled_at_harvest = bool(unit_state.get("enabled"))
desired_state = str(unit_state.get("state") or "stopped")
templated, jt_vars = _jinjify_managed_files(
bundle_dir,
source_role,
role_dir,
managed_files,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=not site_mode,
)
# Copy only the non-templated artifacts.
if site_mode:
_copy_artifacts(
bundle_dir,
source_role,
_host_role_files_dir(out_dir, fqdn or "", role),
exclude_rels=templated,
)
else:
_copy_artifacts(
bundle_dir,
source_role,
os.path.join(role_dir, "files"),
exclude_rels=templated,
)
files_var = _build_managed_files_var(
managed_files,
templated,
notify_other="Restart service",
notify_systemd="Run systemd daemon-reload",
)
links_var = _build_managed_links_var(managed_links)
dirs_var = _build_managed_dirs_var(managed_dirs)
jt_map = _yaml_load_mapping(jt_vars) if jt_vars.strip() else {}
base_vars: Dict[str, Any] = {
f"{var_prefix}_unit_name": unit,
f"{var_prefix}_packages": pkgs,
f"{var_prefix}_managed_files": files_var,
f"{var_prefix}_managed_dirs": dirs_var,
f"{var_prefix}_managed_links": links_var,
f"{var_prefix}_manage_unit": True,
f"{var_prefix}_systemd_enabled": bool(enabled_at_harvest),
f"{var_prefix}_systemd_state": desired_state,
}
base_vars = _merge_mappings_overwrite(base_vars, jt_map)
if site_mode:
# Role defaults are host-agnostic/safe; all harvested state is in host_vars.
_write_role_defaults(
role_dir,
{
f"{var_prefix}_unit_name": unit,
f"{var_prefix}_packages": [],
f"{var_prefix}_managed_files": [],
f"{var_prefix}_managed_dirs": [],
f"{var_prefix}_managed_links": [],
f"{var_prefix}_manage_unit": False,
f"{var_prefix}_systemd_enabled": False,
f"{var_prefix}_systemd_state": "stopped",
},
)
_write_hostvars(out_dir, fqdn or "", role, base_vars)
else:
_write_role_defaults(role_dir, base_vars)
handlers = f"""---
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
- name: Restart service
ansible.builtin.service:
name: "{{{{ {var_prefix}_unit_name }}}}"
state: restarted
when:
- {var_prefix}_manage_unit | default(false)
- ({var_prefix}_systemd_state | default('stopped')) == 'started'
"""
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(handlers)
task_parts: List[str] = []
task_parts.append("---\n" + _render_install_packages_tasks(role, var_prefix))
task_parts.append(
_render_generic_files_tasks(var_prefix, include_restart_notify=True)
)
task_parts.append(
f"""- name: Probe whether systemd unit exists and is manageable
ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
check_mode: true
register: _unit_probe
failed_when: false
changed_when: false
when: {var_prefix}_manage_unit | default(false)
- name: Ensure unit enablement matches harvest
ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}"
enabled: "{{{{ {var_prefix}_systemd_enabled | bool }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
when:
- {var_prefix}_manage_unit | default(false)
- _unit_probe is succeeded
- name: Ensure unit running state matches harvest
ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}"
state: "{{{{ {var_prefix}_systemd_state }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
when:
- {var_prefix}_manage_unit | default(false)
- _unit_probe is succeeded
"""
)
tasks = "\n".join(task_parts).rstrip() + "\n"
with open(
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(tasks)
with open(
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\ndependencies: []\n")
excluded = svc.get("excluded", [])
notes = svc.get("notes", [])
readme = f"""# {role}
Generated from `{unit}`.
## Packages
{os.linesep.join("- " + p for p in pkgs) or "- (none detected)"}
## Managed files
{os.linesep.join("- " + mf["path"] + " (" + mf["reason"] + ")" for mf in managed_files) or "- (none)"}
## Managed symlinks
{os.linesep.join("- " + ml["path"] + " -> " + ml["target"] + " (" + ml.get("reason", "") + ")" for ml in managed_links) or "- (none)"}
## Excluded (possible secrets / unsafe)
{os.linesep.join("- " + e["path"] + " (" + e["reason"] + ")" for e in excluded) or "- (none)"}
## Notes
{os.linesep.join("- " + n for n in notes) or "- (none)"}
"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("service", role)
def _render_common_ansible_roles(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
common_role_groups: Dict[str, List[Dict[str, Any]]],
package_roles: List[Dict[str, Any]],
) -> List[str]:
bundle_dir = ctx.bundle_dir
roles_root = ctx.roles_root
jt_exe = ctx.jt_exe
jt_enabled = ctx.jt_enabled
common_tail_roles: List[str] = []
# -------------------------
# Common package section/group roles
#
# Outside --fqdn/site mode, package and systemd-unit roles are grouped by
# Debian Section or RPM Group by default. Managed config and unit state can
# live in those section roles too; --no-common-roles preserves the historic
# one-role-per-package/unit output, and --fqdn implies that mode because
# grouped role contents would be unsafe across multiple harvested hosts.
# -------------------------
# -------------------------
# Manually installed package roles
# -------------------------
occupied_roles: Set[str] = set(
manifest_plan.roles("apt_config")
+ manifest_plan.roles("dnf_config")
+ manifest_plan.roles("users")
+ manifest_plan.roles("flatpak")
+ manifest_plan.roles("snap")
+ manifest_plan.roles("service")
+ manifest_plan.roles("firewall_runtime")
+ manifest_plan.roles("sysctl")
+ manifest_plan.roles("etc_custom")
+ manifest_plan.roles("usr_local_custom")
+ manifest_plan.roles("extra_paths")
)
for pr in package_roles:
occupied_roles.add(
avoid_reserved_role_name(str(pr.get("role_name") or ""), prefix="package")
)
for section_label, entries in sorted(common_role_groups.items()):
role = _section_role_name(section_label, occupied_roles)
ansible_role = AnsibleRole(
role,
var_prefix=role,
section_label=section_label,
grouped=True,
)
for entry in entries:
kind = entry.get("kind") or "package"
snap = entry.get("snapshot") or {}
if kind == "service":
ansible_role.add_service_snapshot(snap)
else:
ansible_role.add_package_snapshot(snap)
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
var_prefix = ansible_role.var_prefix
files_var: List[Dict[str, Any]] = []
dirs_var: List[Dict[str, Any]] = []
links_var: List[Dict[str, Any]] = []
jt_combined: Dict[str, Any] = {}
seen_files: Set[tuple] = set()
seen_dirs: Set[tuple] = set()
seen_links: Set[tuple] = set()
for entry in ansible_role.entries:
kind = entry.get("kind") or "package"
snap = entry.get("snapshot") or {}
source_role = str(snap.get("role_name") or "")
managed_files = snap.get("managed_files", []) or []
managed_dirs = snap.get("managed_dirs", []) or []
managed_links = snap.get("managed_links", []) or []
templated: Set[str] = set()
jt_vars = ""
if managed_files and source_role:
templated, jt_vars = _jinjify_managed_files(
bundle_dir,
source_role,
role_dir,
managed_files,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=True,
)
_copy_artifacts(
bundle_dir,
source_role,
os.path.join(role_dir, "files"),
exclude_rels=templated,
)
notify_other = "Restart managed services" if kind == "service" else None
for item in _build_managed_files_var(
managed_files,
templated,
notify_other=notify_other,
notify_systemd="Run systemd daemon-reload",
):
key = (item.get("dest"), item.get("src_rel"), item.get("kind"))
if key not in seen_files:
seen_files.add(key)
files_var.append(item)
for item in _build_managed_dirs_var(managed_dirs):
key = (
item.get("dest"),
item.get("owner"),
item.get("group"),
item.get("mode"),
)
if key not in seen_dirs:
seen_dirs.add(key)
dirs_var.append(item)
for item in _build_managed_links_var(managed_links):
key = (item.get("dest"), item.get("src"))
if key not in seen_links:
seen_links.add(key)
links_var.append(item)
jt_map = _yaml_load_mapping(jt_vars) if jt_vars.strip() else {}
jt_combined = _merge_mappings_overwrite(jt_combined, jt_map)
packages = ansible_role.sorted_packages
files_var = sorted(files_var, key=lambda x: str(x.get("dest") or ""))
dirs_var = sorted(dirs_var, key=lambda x: str(x.get("dest") or ""))
links_var = sorted(links_var, key=lambda x: str(x.get("dest") or ""))
systemd_units = ansible_role.systemd_units_var
base_vars: Dict[str, Any] = {
f"{var_prefix}_packages": packages,
f"{var_prefix}_managed_files": files_var,
f"{var_prefix}_managed_dirs": dirs_var,
f"{var_prefix}_managed_links": links_var,
f"{var_prefix}_systemd_units": systemd_units,
}
base_vars = _merge_mappings_overwrite(base_vars, jt_combined)
_write_role_defaults(role_dir, base_vars)
if {"cron", "logrotate"}.intersection(ansible_role.packages):
common_tail_roles.append(role)
handlers = (
"""---
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
- name: Restart managed services
ansible.builtin.service:
name: "{{ item.name }}"
state: restarted
loop: "{{ """
+ f"{var_prefix}_systemd_units"
+ """ | default([]) }}"
when:
- item.manage | default(false)
- (item.state | default('stopped')) == 'started'
"""
)
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(handlers)
task_parts: List[str] = []
task_parts.append("---\n" + _render_install_packages_tasks(role, var_prefix))
task_parts.append(
_render_generic_files_tasks(var_prefix, include_restart_notify=True)
)
task_parts.append(_render_grouped_systemd_tasks(var_prefix))
tasks = "\n".join(task_parts).rstrip() + "\n"
with open(
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(tasks)
with open(
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\ndependencies: []\n")
readme = f"""# {role}
Common role for package section/group `{section_label}`.
## Origin roles
{os.linesep.join("- " + line for line in sorted(ansible_role.origin_lines)) or "- (none)"}
## Packages
{os.linesep.join("- " + p for p in packages) or "- (none)"}
## Managed files
{os.linesep.join("- " + mf["dest"] for mf in files_var) or "- (none)"}
## Managed symlinks
{os.linesep.join("- " + ml["dest"] + " -> " + ml["src"] for ml in links_var) or "- (none)"}
## Systemd units
{os.linesep.join("- " + u["name"] + " (enabled=" + str(u["enabled"]).lower() + ", state=" + u["state"] + ")" for u in systemd_units) or "- (none)"}
## Excluded (possible secrets / unsafe)
{os.linesep.join("- " + e.get("path", "") + " (" + e.get("reason", "") + ")" for e in ansible_role.excluded) or "- (none)"}
## Notes
{os.linesep.join("- " + n for n in ansible_role.notes) or "- (none)"}
"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("package", role)
return common_tail_roles
def _render_package_roles(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
package_roles: List[Dict[str, Any]],
) -> None:
bundle_dir = ctx.bundle_dir
out_dir = ctx.out_dir
roles_root = ctx.roles_root
fqdn = ctx.fqdn
site_mode = ctx.site_mode
jt_exe = ctx.jt_exe
jt_enabled = ctx.jt_enabled
# Process package roles (those with configuration files)
for pr in package_roles:
source_role = pr["role_name"]
role = avoid_reserved_role_name(source_role, prefix="package")
pkg = pr.get("package") or ""
managed_files = pr.get("managed_files", []) or []
managed_dirs = pr.get("managed_dirs", []) or []
managed_links = pr.get("managed_links", []) or []
ansible_role = AnsibleRole(role)
ansible_role.add_package_snapshot(pr)
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
var_prefix = role
templated, jt_vars = _jinjify_managed_files(
bundle_dir,
source_role,
role_dir,
managed_files,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=not site_mode,
)
# Copy only the non-templated artifacts.
if site_mode:
_copy_artifacts(
bundle_dir,
source_role,
_host_role_files_dir(out_dir, fqdn or "", role),
exclude_rels=templated,
)
else:
_copy_artifacts(
bundle_dir,
source_role,
os.path.join(role_dir, "files"),
exclude_rels=templated,
)
pkgs = ansible_role.sorted_packages
files_var = _build_managed_files_var(
managed_files,
templated,
notify_other=None,
notify_systemd="Run systemd daemon-reload",
)
links_var = _build_managed_links_var(managed_links)
dirs_var = _build_managed_dirs_var(managed_dirs)
jt_map = _yaml_load_mapping(jt_vars) if jt_vars.strip() else {}
base_vars: Dict[str, Any] = {
f"{var_prefix}_packages": pkgs,
f"{var_prefix}_managed_files": files_var,
f"{var_prefix}_managed_dirs": dirs_var,
f"{var_prefix}_managed_links": links_var,
}
base_vars = _merge_mappings_overwrite(base_vars, jt_map)
if site_mode:
_write_role_defaults(
role_dir,
{
f"{var_prefix}_packages": [],
f"{var_prefix}_managed_files": [],
f"{var_prefix}_managed_dirs": [],
f"{var_prefix}_managed_links": [],
},
)
_write_hostvars(out_dir, fqdn or "", role, base_vars)
else:
_write_role_defaults(role_dir, base_vars)
handlers = """---
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
"""
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(handlers)
task_parts: List[str] = []
task_parts.append("---\n" + _render_install_packages_tasks(role, var_prefix))
task_parts.append(
_render_generic_files_tasks(var_prefix, include_restart_notify=False)
)
tasks = "\n".join(task_parts).rstrip() + "\n"
with open(
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(tasks)
with open(
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\ndependencies: []\n")
excluded = pr.get("excluded", [])
notes = pr.get("notes", [])
readme = f"""# {role}
Generated for package `{pkg}`.
## Managed files
{os.linesep.join("- " + mf["path"] + " (" + mf["reason"] + ")" for mf in managed_files) or "- (none)"}
## Managed symlinks
{os.linesep.join("- " + ml["path"] + " -> " + ml["target"] + " (" + ml.get("reason", "") + ")" for ml in managed_links) or "- (none)"}
## Excluded (possible secrets / unsafe)
{os.linesep.join("- " + e["path"] + " (" + e["reason"] + ")" for e in excluded) or "- (none)"}
## Notes
{os.linesep.join("- " + n for n in notes) or "- (none)"}
> Note: package roles (those not discovered via a systemd service) do not attempt to restart or enable services automatically.
"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("package", role)

View file

@ -0,0 +1,219 @@
from __future__ import annotations
import os
from typing import Any, Dict
from ..context import AnsibleManifestContext
from ..layout import (
_copy_artifacts,
_host_role_files_dir,
_write_hostvars,
_write_role_defaults,
_write_role_scaffold,
)
from ..model import AnsibleManifestPlan
from ..tasks import (
_render_firewall_runtime_tasks,
_render_install_packages_tasks,
_render_sysctl_handlers,
_render_sysctl_tasks,
)
def _render_sysctl_role(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
sysctl_snapshot: Dict[str, Any],
) -> None:
if not (sysctl_snapshot and (sysctl_snapshot.get("managed_files") or [])):
return
role = sysctl_snapshot.get("role_name", "sysctl")
role_dir = os.path.join(ctx.roles_root, role)
_write_role_scaffold(role_dir)
var_prefix = role
managed_files = sysctl_snapshot.get("managed_files", []) or []
conf_src_rel = ""
for mf in managed_files:
if mf.get("path") == "/etc/sysctl.d/99-enroll.conf":
conf_src_rel = mf.get("src_rel") or ""
break
if not conf_src_rel and managed_files:
conf_src_rel = managed_files[0].get("src_rel") or ""
parameters = sysctl_snapshot.get("parameters", {}) or {}
notes = sysctl_snapshot.get("notes", []) or []
if ctx.site_mode:
_copy_artifacts(
ctx.bundle_dir,
role,
_host_role_files_dir(ctx.out_dir, ctx.fqdn or "", role),
)
else:
_copy_artifacts(ctx.bundle_dir, role, os.path.join(role_dir, "files"))
vars_map: Dict[str, Any] = {
f"{var_prefix}_conf_src_rel": conf_src_rel,
f"{var_prefix}_apply": True,
f"{var_prefix}_ignore_apply_errors": True,
}
if ctx.site_mode:
_write_role_defaults(
role_dir,
{
f"{var_prefix}_conf_src_rel": "",
f"{var_prefix}_apply": True,
f"{var_prefix}_ignore_apply_errors": True,
},
)
_write_hostvars(ctx.out_dir, ctx.fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
tasks = "---\n" + _render_sysctl_tasks(var_prefix)
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
f.write(tasks.rstrip() + "\n")
handlers_dir = os.path.join(role_dir, "handlers")
os.makedirs(handlers_dir, exist_ok=True)
with open(os.path.join(handlers_dir, "main.yml"), "w", encoding="utf-8") as f:
f.write(_render_sysctl_handlers(var_prefix))
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
f.write("---\ndependencies: []\n")
param_count = len(parameters) if isinstance(parameters, dict) else 0
sample_params = []
if isinstance(parameters, dict):
sample_params = sorted(parameters.keys())[:25]
readme = f"""# {role}
Generated from live writable sysctl state captured during harvest.
This role deploys the captured values to `/etc/sysctl.d/99-enroll.conf`. The generated file intentionally contains writable, single-line sysctl values only; read-only, multiline, action-like, and host-identity keys are skipped to avoid creating a noisy or brittle boot-time configuration.
## Captured parameters
Captured parameter count: {param_count}
{os.linesep.join("- " + x for x in sample_params) or "- (none)"}
{"- ..." if param_count > len(sample_params) else ""}
## Notes
{os.linesep.join("- " + n for n in notes) or "- (none)"}
## Safety notes
- `sysctl_apply` defaults to `true` and applies `/etc/sysctl.d/99-enroll.conf` when the file changes.
- `sysctl_ignore_apply_errors` defaults to `true`, because sysctl availability and writability can vary across kernels, containers, and hardware.
- Review this role before applying it broadly across unlike hosts.
"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("sysctl", role)
def _render_firewall_runtime_role(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
firewall_runtime_snapshot: Dict[str, Any],
) -> None:
if not (
firewall_runtime_snapshot
and (
firewall_runtime_snapshot.get("ipset_save")
or firewall_runtime_snapshot.get("iptables_v4_save")
or firewall_runtime_snapshot.get("iptables_v6_save")
)
):
return
role = firewall_runtime_snapshot.get("role_name", "firewall_runtime")
role_dir = os.path.join(ctx.roles_root, role)
_write_role_scaffold(role_dir)
var_prefix = role
packages = firewall_runtime_snapshot.get("packages", []) or []
ipset_save = firewall_runtime_snapshot.get("ipset_save") or ""
ipset_sets = firewall_runtime_snapshot.get("ipset_sets", []) or []
iptables_v4_save = firewall_runtime_snapshot.get("iptables_v4_save") or ""
iptables_v6_save = firewall_runtime_snapshot.get("iptables_v6_save") or ""
notes = firewall_runtime_snapshot.get("notes", []) or []
if ctx.site_mode:
_copy_artifacts(
ctx.bundle_dir,
role,
_host_role_files_dir(ctx.out_dir, ctx.fqdn or "", role),
)
else:
_copy_artifacts(ctx.bundle_dir, role, os.path.join(role_dir, "files"))
vars_map: Dict[str, Any] = {
f"{var_prefix}_packages": packages,
f"{var_prefix}_ipset_save": ipset_save,
f"{var_prefix}_ipset_sets": ipset_sets,
f"{var_prefix}_iptables_v4_save": iptables_v4_save,
f"{var_prefix}_iptables_v6_save": iptables_v6_save,
f"{var_prefix}_sync_ipsets_exact": True,
f"{var_prefix}_restore_iptables": True,
}
if ctx.site_mode:
_write_role_defaults(
role_dir,
{
f"{var_prefix}_packages": [],
f"{var_prefix}_ipset_save": "",
f"{var_prefix}_ipset_sets": [],
f"{var_prefix}_iptables_v4_save": "",
f"{var_prefix}_iptables_v6_save": "",
f"{var_prefix}_sync_ipsets_exact": True,
f"{var_prefix}_restore_iptables": True,
},
)
_write_hostvars(ctx.out_dir, ctx.fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
tasks = (
"---\n"
+ _render_install_packages_tasks(role, var_prefix)
+ _render_firewall_runtime_tasks(var_prefix)
)
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
f.write(tasks.rstrip() + "\n")
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
f.write("---\ndependencies: []\n")
readme = f"""# {role}
Generated from live firewall runtime state captured during harvest.
This role restores live ipset and iptables state only for firewall families where Enroll did not find corresponding persistent configuration on the source host. Static firewall configuration files, such as `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, UFW, nftables, firewalld, or `/etc/ipset*`, are harvested separately as managed files and treated as authoritative for their respective family.
## Captured snapshots
- ipset: {ipset_save or "(none)"}
- iptables IPv4: {iptables_v4_save or "(none)"}
- iptables IPv6: {iptables_v6_save or "(none)"}
## Captured ipsets
{os.linesep.join("- " + x for x in ipset_sets) or "- (none)"}
## Notes
{os.linesep.join("- " + n for n in notes) or "- (none)"}
## Safety notes
- `firewall_runtime_sync_ipsets_exact` defaults to `true`; it flushes captured set members before replaying the saved members so stale entries are removed. This applies only when no persistent ipset config was found.
- `firewall_runtime_restore_iptables` defaults to `true`; `iptables-restore`/`ip6tables-restore` replace only captured families. A family is captured only when no corresponding persistent iptables config was found.
"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("firewall_runtime", role)

View file

@ -0,0 +1,434 @@
from __future__ import annotations
import os
from typing import Any, Dict, List
from ..context import AnsibleManifestContext
from ..layout import (
_copy_artifacts,
_ensure_requirements_yaml,
_host_role_files_dir,
_write_hostvars,
_write_role_defaults,
_write_role_scaffold,
)
from ..model import AnsibleManifestPlan
from ..vars import _normalise_flatpak_item, _normalise_flatpak_remote
def _render_users_role(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
users_snapshot: Dict[str, Any],
) -> None:
bundle_dir = ctx.bundle_dir
out_dir = ctx.out_dir
roles_root = ctx.roles_root
fqdn = ctx.fqdn
site_mode = ctx.site_mode
# -------------------------
# Users role (non-system users)
# -------------------------
if users_snapshot:
role = users_snapshot.get("role_name", "users")
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
# Users role includes harvested SSH-related files; in site mode keep them
# host-specific to avoid cross-host clobber.
if site_mode:
_copy_artifacts(
bundle_dir, role, _host_role_files_dir(out_dir, fqdn or "", role)
)
else:
_copy_artifacts(bundle_dir, role, os.path.join(role_dir, "files"))
users = users_snapshot.get("users", [])
managed_files = users_snapshot.get("managed_files", [])
excluded = users_snapshot.get("excluded", [])
notes = users_snapshot.get("notes", [])
# Build groups list and a simplified user dict list suitable for loops
group_names: List[str] = []
group_set = set()
users_data: List[Dict[str, Any]] = []
for u in users:
name = u.get("name")
if not name:
continue
pg = u.get("primary_group") or name
home = u.get("home") or f"/home/{name}"
sshdir = home.rstrip("/") + "/.ssh"
supp = u.get("supplementary_groups") or []
if pg:
group_set.add(pg)
for g in supp:
if g:
group_set.add(g)
users_data.append(
{
"name": name,
"uid": u.get("uid"),
"primary_group": pg,
"home": home,
"ssh_dir": sshdir,
"shell": u.get("shell"),
"gecos": u.get("gecos"),
"supplementary_groups": sorted(set(supp)),
}
)
group_names = sorted(group_set)
# User-managed files (authorized_keys plus dangerous-mode shell dotfiles).
# Keep the variable name for compatibility with existing generated data.
ssh_files: List[Dict[str, Any]] = []
for mf in managed_files:
dest = mf.get("path") or ""
src_rel = mf.get("src_rel") or ""
if not dest or not src_rel:
continue
owner = "root"
group = "root"
for u in users_data:
home_prefix = (u.get("home") or "").rstrip("/") + "/"
if home_prefix and dest.startswith(home_prefix):
owner = str(u.get("name") or "root")
group = str(u.get("primary_group") or owner)
break
# Prefer the harvested file mode so we preserve any deliberate
# permissions (e.g. 0600 for certain dotfiles). For authorized_keys,
# enforce 0600 regardless.
mode = mf.get("mode") or "0644"
if mf.get("reason") == "authorized_keys":
mode = "0600"
ssh_files.append(
{
"dest": dest,
"src_rel": src_rel,
"owner": owner,
"group": group,
"mode": mode,
}
)
# Only create .ssh directories for users that actually have harvested
# files under .ssh. This mirrors Puppet's behaviour and avoids creating
# empty SSH directories merely because a user account exists.
ssh_dirs_by_dest: Dict[str, Dict[str, Any]] = {}
for item in ssh_files:
dest = str(item.get("dest") or "")
if not dest:
continue
for user in users_data:
ssh_dir = str(user.get("ssh_dir") or "").rstrip("/")
if not ssh_dir or not dest.startswith(ssh_dir + "/"):
continue
ssh_dirs_by_dest.setdefault(
ssh_dir,
{
"dest": ssh_dir,
"owner": str(user.get("name") or item.get("owner") or "root"),
"group": str(
user.get("primary_group") or item.get("group") or "root"
),
"mode": "0700",
},
)
break
ssh_dirs = sorted(
ssh_dirs_by_dest.values(), key=lambda item: str(item.get("dest") or "")
)
# Build Flatpak and Snap lists. Flatpak can be installed system-wide or
# per-user. Snap packages are system-wide; per-user ~/snap/* directories
# are runtime/user data and are not treated as install sources.
users_flatpaks: List[Dict[str, Any]] = []
user_flatpak_map = users_snapshot.get("user_flatpaks", {}) or {}
home_by_user = {
str(u.get("name")): str(u.get("home") or "") for u in users_data
}
for uname, flatpaks in user_flatpak_map.items():
for fp in flatpaks or []:
users_flatpaks.append(
_normalise_flatpak_item(
fp,
method="user",
user=str(uname),
home=home_by_user.get(str(uname)) or None,
)
)
flatpak_remotes = [
_normalise_flatpak_remote(r)
for r in (users_snapshot.get("user_flatpak_remotes", []) or [])
]
users_needs_community = bool(flatpak_remotes or users_flatpaks)
if users_needs_community:
_ensure_requirements_yaml(os.path.join(out_dir, "requirements.yml"))
# Variables are host-specific in site mode; in non-site mode they live in role defaults.
if site_mode:
_write_role_defaults(
role_dir,
{
"users_groups": [],
"users_users": [],
"users_ssh_dirs": [],
"users_ssh_files": [],
"users_flatpaks": [],
"users_flatpak_remotes": [],
},
)
_write_hostvars(
out_dir,
fqdn or "",
role,
{
"users_groups": group_names,
"users_users": users_data,
"users_ssh_dirs": ssh_dirs,
"users_ssh_files": ssh_files,
"users_flatpaks": users_flatpaks,
"users_flatpak_remotes": flatpak_remotes,
},
)
else:
_write_role_defaults(
role_dir,
{
"users_groups": group_names,
"users_users": users_data,
"users_ssh_dirs": ssh_dirs,
"users_ssh_files": ssh_files,
"users_flatpaks": users_flatpaks,
"users_flatpak_remotes": flatpak_remotes,
},
)
with open(
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
) as f:
if users_needs_community:
f.write(
"---\n"
"dependencies: []\n"
"collections:\n"
" - community.general\n"
)
else:
f.write("---\ndependencies: []\n")
# tasks (data-driven)
users_tasks = """---
- name: Ensure groups exist
ansible.builtin.group:
name: "{{ item }}"
state: present
loop: "{{ users_groups | default([]) }}"
- name: Ensure users exist
ansible.builtin.user:
name: "{{ item.name }}"
uid: "{{ item.uid | default(omit) }}"
group: "{{ item.primary_group }}"
home: "{{ item.home }}"
create_home: true
shell: "{{ item.shell | default(omit) }}"
comment: "{{ item.gecos | default(omit) }}"
state: present
loop: "{{ users_users | default([]) }}"
- name: Ensure users supplementary groups
ansible.builtin.user:
name: "{{ item.name }}"
groups: "{{ item.supplementary_groups | default([]) | join(',') }}"
append: true
loop: "{{ users_users | default([]) }}"
when: (item.supplementary_groups | default([])) | length > 0
- name: Ensure .ssh directories exist for managed SSH files
ansible.builtin.file:
path: "{{ item.dest }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop: "{{ users_ssh_dirs | default([]) }}"
- name: Deploy user-managed files
vars:
_enroll_ff:
files:
- "{{ inventory_dir }}/host_vars/{{ inventory_hostname }}/{{ role_name }}/.files/{{ item.src_rel }}"
- "{{ role_path }}/files/{{ item.src_rel }}"
ansible.builtin.copy:
src: "{{ lookup('ansible.builtin.first_found', _enroll_ff) }}"
dest: "{{ item.dest }}"
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop: "{{ users_ssh_files | default([]) }}"
"""
if flatpak_remotes or users_flatpaks:
users_tasks += """
- name: Ensure user Flatpak remotes exist
ansible.builtin.command:
argv:
- flatpak
- remote-add
- --user
- --if-not-exists
- "{{ item.name }}"
- "{{ item.url }}"
loop: "{{ users_flatpak_remotes | default([]) | selectattr('method', 'equalto', 'user') | list }}"
when:
- item.name is defined
- item.url is defined
- item.url | length > 0
- item.user is defined
become: true
become_user: "{{ item.user }}"
environment:
HOME: "{{ item.home | default('/home/' ~ item.user, true) }}"
XDG_DATA_HOME: "{{ (item.home | default('/home/' ~ item.user, true)) ~ '/.local/share' }}"
changed_when: false
- name: Install user Flatpaks
community.general.flatpak:
name:
- "{{ item.name }}"
state: present
method: user
remote: "{{ item.remote | default(omit) }}"
from_url: "{{ item.from_url | default(omit) }}"
loop: "{{ users_flatpaks | default([]) }}"
when:
- item.name is defined
- item.name | length > 0
- item.user is defined
become: true
become_user: "{{ item.user }}"
environment:
HOME: "{{ item.home | default('/home/' ~ item.user, true) }}"
XDG_DATA_HOME: "{{ (item.home | default('/home/' ~ item.user, true)) ~ '/.local/share' }}"
"""
with open(
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
) as f:
f.write(users_tasks)
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\n")
def _fmt_app_list(items: List[Dict[str, Any]]) -> str:
lines = []
for item in items:
name = item.get("name")
if not name:
continue
detail_parts = []
for key in ("remote", "channel", "revision", "branch", "arch"):
value = item.get(key)
if value not in (None, "", []):
detail_parts.append(f"{key}={value}")
for key in ("classic", "devmode", "dangerous"):
if item.get(key):
detail_parts.append(key)
details = f" ({', '.join(detail_parts)})" if detail_parts else ""
lines.append(f"- {name}{details}")
return "\n".join(lines) or "- (none)"
def _fmt_user_flatpaks(items: List[Dict[str, Any]]) -> str:
lines = []
for item in items:
name = item.get("name")
user = item.get("user")
if not name or not user:
continue
detail_parts = []
for key in ("remote", "branch", "arch"):
value = item.get(key)
if value not in (None, "", []):
detail_parts.append(f"{key}={value}")
details = f" ({', '.join(detail_parts)})" if detail_parts else ""
lines.append(f"- {user}: {name}{details}")
return "\n".join(lines) or "- (none)"
def _fmt_remotes(items: List[Dict[str, Any]]) -> str:
lines = []
for item in items:
name = item.get("name")
url = item.get("url")
method = item.get("method") or "system"
user = item.get("user")
if not name or not url:
continue
owner = f"user={user}" if user else "system"
lines.append(f"- {name} ({method}, {owner}): {url}")
return "\n".join(lines) or "- (none)"
readme = (
"""# users
Generated non-system user accounts, SSH public material, and per-user Flatpak
applications/remotes.
**Note:** User Flatpak tasks require the `community.general` Ansible collection.
Install it with: `ansible-galaxy collection install -r requirements.yml`.
Flatpak `remote` is harvested from the installed deployment where detectable.
The original `.flatpakref` URL is generally not preserved by Flatpak after
installation, so `from_url` is only emitted if a future/hand-edited state file
contains it.
## Users
"""
+ (
"\n".join([f"- {u.get('name')} (uid {u.get('uid')})" for u in users])
or "- (none)"
)
+ """\n
## Included SSH files
"""
+ (
"\n".join(
[f"- {mf.get('path')} ({mf.get('reason')})" for mf in managed_files]
)
or "- (none)"
)
+ """\n
## Flatpak remotes
"""
+ _fmt_remotes(flatpak_remotes)
+ """\n
## User Flatpaks
"""
+ _fmt_user_flatpaks(users_flatpaks)
+ """\n
## Excluded
"""
+ (
"\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded])
or "- (none)"
)
+ """\n
## Notes
"""
+ ("\n".join([f"- {n}" for n in notes]) or "- (none)")
+ """\n"""
)
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("users", role)

View file

@ -0,0 +1,290 @@
from __future__ import annotations
def _render_generic_files_tasks(
var_prefix: str, *, include_restart_notify: bool
) -> str:
"""Render generic tasks to deploy <var_prefix>_managed_files safely."""
# Using first_found makes roles work in both modes:
# - site-mode: inventory/host_vars/<host>/<role>/.files/...
# - non-site: roles/<role>/files/...
return f"""- name: Ensure managed directories exist (preserve owner/group/mode)
ansible.builtin.file:
path: "{{{{ item.dest }}}}"
state: directory
owner: "{{{{ item.owner }}}}"
group: "{{{{ item.group }}}}"
mode: "{{{{ item.mode }}}}"
loop: "{{{{ {var_prefix}_managed_dirs | default([]) }}}}"
- name: Deploy any systemd unit files (templates)
ansible.builtin.template:
src: "{{{{ item.src_rel }}}}.j2"
dest: "{{{{ item.dest }}}}"
owner: "{{{{ item.owner }}}}"
group: "{{{{ item.group }}}}"
mode: "{{{{ item.mode }}}}"
loop: >-
{{{{ {var_prefix}_managed_files | default([])
| selectattr('is_systemd_unit', 'equalto', true)
| selectattr('kind', 'equalto', 'template')
| list }}}}
notify: "{{{{ item.notify | default([]) }}}}"
- name: Deploy any systemd unit files (raw files)
vars:
_enroll_ff:
files:
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ item.src_rel }}}}"
- "{{{{ role_path }}}}/files/{{{{ item.src_rel }}}}"
ansible.builtin.copy:
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
dest: "{{{{ item.dest }}}}"
owner: "{{{{ item.owner }}}}"
group: "{{{{ item.group }}}}"
mode: "{{{{ item.mode }}}}"
loop: >-
{{{{ {var_prefix}_managed_files | default([])
| selectattr('is_systemd_unit', 'equalto', true)
| selectattr('kind', 'equalto', 'copy')
| list }}}}
notify: "{{{{ item.notify | default([]) }}}}"
- name: Reload systemd to pick up unit changes
ansible.builtin.meta: flush_handlers
when: >-
({var_prefix}_managed_files | default([])
| selectattr('is_systemd_unit', 'equalto', true)
| list
| length) > 0
- name: Deploy any other managed files (templates)
ansible.builtin.template:
src: "{{{{ item.src_rel }}}}.j2"
dest: "{{{{ item.dest }}}}"
owner: "{{{{ item.owner }}}}"
group: "{{{{ item.group }}}}"
mode: "{{{{ item.mode }}}}"
loop: >-
{{{{ {var_prefix}_managed_files | default([])
| selectattr('is_systemd_unit', 'equalto', false)
| selectattr('kind', 'equalto', 'template')
| list }}}}
notify: "{{{{ item.notify | default([]) }}}}"
- name: Deploy any other managed files (raw files)
vars:
_enroll_ff:
files:
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ item.src_rel }}}}"
- "{{{{ role_path }}}}/files/{{{{ item.src_rel }}}}"
ansible.builtin.copy:
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
dest: "{{{{ item.dest }}}}"
owner: "{{{{ item.owner }}}}"
group: "{{{{ item.group }}}}"
mode: "{{{{ item.mode }}}}"
loop: >-
{{{{ {var_prefix}_managed_files | default([])
| selectattr('is_systemd_unit', 'equalto', false)
| selectattr('kind', 'equalto', 'copy')
| list }}}}
notify: "{{{{ item.notify | default([]) }}}}"
- name: Ensure managed symlinks exist
ansible.builtin.file:
src: "{{{{ item.src }}}}"
dest: "{{{{ item.dest }}}}"
state: link
force: true
loop: "{{{{ {var_prefix}_managed_links | default([]) }}}}"
"""
def _render_install_packages_tasks(role: str, var_prefix: str) -> str:
"""Render package installation through Ansible's generic package provider.
Puppet uses provider-backed package resources instead of selecting
apt/dnf/yum in the generated manifest. Ansible's package module is the
equivalent abstraction: it proxies to the target host's detected package
manager and keeps generated roles provider-neutral.
"""
return f"""- name: Install packages for {role}
ansible.builtin.package:
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
state: present
when: ({var_prefix}_packages | default([])) | length > 0
"""
def _render_grouped_systemd_tasks(var_prefix: str) -> str:
"""Render tasks to manage multiple systemd units in a common role."""
return f"""- name: Probe whether grouped systemd units exist and are manageable
ansible.builtin.systemd:
name: "{{{{ item.name }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
check_mode: true
loop: "{{{{ {var_prefix}_systemd_units | default([]) }}}}"
register: _enroll_unit_probes
failed_when: false
changed_when: false
when: item.manage | default(false)
- name: Ensure grouped unit enablement matches harvest
ansible.builtin.systemd:
name: "{{{{ item.item.name }}}}"
enabled: "{{{{ item.item.enabled | bool }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
when:
- item.item.manage | default(false)
- not (item.failed | default(false))
- name: Ensure grouped unit running state matches harvest
ansible.builtin.systemd:
name: "{{{{ item.item.name }}}}"
state: "{{{{ item.item.state }}}}"
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
when:
- item.item.manage | default(false)
- not (item.failed | default(false))
"""
def _render_sysctl_tasks(var_prefix: str) -> str:
return f"""- name: Ensure sysctl.d exists
ansible.builtin.file:
path: /etc/sysctl.d
state: directory
owner: root
group: root
mode: "0755"
- name: Deploy captured sysctl configuration
vars:
_enroll_ff:
files:
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_conf_src_rel }}}}"
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_conf_src_rel }}}}"
ansible.builtin.copy:
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
dest: /etc/sysctl.d/99-enroll.conf
owner: root
group: root
mode: "0644"
when: ({var_prefix}_conf_src_rel | default('') | length) > 0
notify: Apply captured sysctl configuration
"""
def _render_sysctl_handlers(var_prefix: str) -> str:
return f"""---
- name: Apply captured sysctl configuration
ansible.builtin.command:
argv:
- sysctl
- -e
- -p
- /etc/sysctl.d/99-enroll.conf
register: _enroll_sysctl_apply
changed_when: false
failed_when:
- not ({var_prefix}_ignore_apply_errors | default(true) | bool)
- _enroll_sysctl_apply.rc != 0
when: {var_prefix}_apply | default(true) | bool
"""
def _render_firewall_runtime_tasks(var_prefix: str) -> str:
"""Render tasks for live ipset/iptables snapshots."""
return f"""- name: Ensure firewall runtime snapshot directory exists
ansible.builtin.file:
path: /etc/enroll/firewall
state: directory
owner: root
group: root
mode: "0750"
- name: Deploy captured ipset snapshot
vars:
_enroll_ff:
files:
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_ipset_save }}}}"
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_ipset_save }}}}"
ansible.builtin.copy:
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
dest: /etc/enroll/firewall/ipset.save
owner: root
group: root
mode: "0600"
when: ({var_prefix}_ipset_save | default('') | length) > 0
- name: Flush captured ipsets before restoring members
ansible.builtin.command:
cmd: "ipset flush {{{{ item }}}}"
loop: "{{{{ {var_prefix}_ipset_sets | default([]) }}}}"
register: _enroll_ipset_flush
failed_when: false
changed_when: false
when:
- ({var_prefix}_ipset_save | default('') | length) > 0
- {var_prefix}_sync_ipsets_exact | default(true) | bool
- name: Restore captured ipsets
ansible.builtin.shell: "ipset restore -exist < /etc/enroll/firewall/ipset.save"
args:
executable: /bin/sh
register: _enroll_ipset_restore
changed_when: _enroll_ipset_restore.rc == 0
when: ({var_prefix}_ipset_save | default('') | length) > 0
- name: Deploy captured IPv4 iptables snapshot
vars:
_enroll_ff:
files:
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_iptables_v4_save }}}}"
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_iptables_v4_save }}}}"
ansible.builtin.copy:
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
dest: /etc/enroll/firewall/iptables.v4
owner: root
group: root
mode: "0600"
when: ({var_prefix}_iptables_v4_save | default('') | length) > 0
- name: Restore captured IPv4 iptables rules
ansible.builtin.command:
cmd: iptables-restore /etc/enroll/firewall/iptables.v4
register: _enroll_iptables_v4_restore
changed_when: _enroll_iptables_v4_restore.rc == 0
when:
- ({var_prefix}_iptables_v4_save | default('') | length) > 0
- {var_prefix}_restore_iptables | default(true) | bool
- name: Deploy captured IPv6 iptables snapshot
vars:
_enroll_ff:
files:
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_iptables_v6_save }}}}"
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_iptables_v6_save }}}}"
ansible.builtin.copy:
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
dest: /etc/enroll/firewall/iptables.v6
owner: root
group: root
mode: "0600"
when: ({var_prefix}_iptables_v6_save | default('') | length) > 0
- name: Restore captured IPv6 iptables rules
ansible.builtin.command:
cmd: ip6tables-restore /etc/enroll/firewall/iptables.v6
register: _enroll_iptables_v6_restore
changed_when: _enroll_iptables_v6_restore.rc == 0
when:
- ({var_prefix}_iptables_v6_save | default('') | length) > 0
- {var_prefix}_restore_iptables | default(true) | bool
"""

View file

@ -0,0 +1,151 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set
def _normalise_flatpak_item(
item: Any,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> Dict[str, Any]:
if isinstance(item, str):
out: Dict[str, Any] = {"name": item, "method": method}
elif isinstance(item, dict):
out = dict(item)
out.setdefault("method", method)
else:
out = {"name": str(item), "method": method}
if user:
out.setdefault("user", user)
if home:
out.setdefault("home", home)
return out
def _normalise_flatpak_remote(item: Any) -> Dict[str, Any]:
if isinstance(item, dict):
out = dict(item)
else:
out = {"name": str(item)}
out.setdefault("method", "system")
return out
def _normalise_snap_item(item: Any) -> Dict[str, Any]:
if isinstance(item, str):
out: Dict[str, Any] = {"name": item}
elif isinstance(item, dict):
out = dict(item)
else:
out = {"name": str(item)}
notes = out.get("notes") or []
if isinstance(notes, str):
notes = [notes]
notes_l = {str(n).lower() for n in notes}
out["classic"] = bool(out.get("classic") or "classic" in notes_l)
out["devmode"] = bool(out.get("devmode") or "devmode" in notes_l)
out["dangerous"] = bool(out.get("dangerous") or "dangerous" in notes_l)
# The Ansible snap module's revision parameter pins/holds the snap. For
# ordinary store snaps that track a channel, preserve the channel instead
# of freezing every harvested host at today's revision.
if out.get("revision") is not None and not out.get("channel"):
out["install_revision"] = True
else:
out["install_revision"] = False
return out
def _build_managed_dirs_var(
managed_dirs: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Convert enroll managed_dirs into an Ansible-friendly list of dicts.
Each dict drives a role task loop and is safe across hosts.
"""
out: List[Dict[str, Any]] = []
for d in managed_dirs:
dest = d.get("path") or ""
if not dest:
continue
out.append(
{
"dest": dest,
"owner": d.get("owner") or "root",
"group": d.get("group") or "root",
"mode": d.get("mode") or "0755",
}
)
return out
def _build_managed_files_var(
managed_files: List[Dict[str, Any]],
templated_src_rels: Set[str],
*,
notify_other: Optional[str] = None,
notify_systemd: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Convert enroll managed_files into an Ansible-friendly list of dicts.
Each dict drives a role task loop and is safe across hosts.
"""
out: List[Dict[str, Any]] = []
for mf in managed_files:
dest = mf.get("path") or ""
src_rel = mf.get("src_rel") or ""
if not dest or not src_rel:
continue
is_unit = str(dest).startswith("/etc/systemd/system/")
kind = "template" if src_rel in templated_src_rels else "copy"
notify: List[str] = []
if is_unit and notify_systemd:
notify.append(notify_systemd)
if (not is_unit) and notify_other:
notify.append(notify_other)
out.append(
{
"dest": dest,
"src_rel": src_rel,
"owner": mf.get("owner") or "root",
"group": mf.get("group") or "root",
"mode": mf.get("mode") or "0644",
"kind": kind,
"is_systemd_unit": bool(is_unit),
"notify": notify,
}
)
return out
def _build_managed_links_var(
managed_links: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Convert enroll managed_links into an Ansible-friendly list of dicts."""
out: List[Dict[str, Any]] = []
for ml in managed_links or []:
dest = ml.get("path") or ""
src = ml.get("target") or ""
if not dest or not src:
continue
out.append({"dest": dest, "src": src})
return out
def _normalise_container_image_item(item: Any) -> Dict[str, Any]:
if isinstance(item, dict):
out = dict(item)
else:
out = {"pull_ref": str(item)}
out.setdefault("engine", "docker")
out.setdefault("scope", "system")
out.setdefault("user", None)
out.setdefault("home", None)
out.setdefault("repo_tags", [])
out.setdefault("repo_digests", [])
out.setdefault("tag_aliases", [])
out.setdefault("notes", [])
return out

View file

@ -0,0 +1,69 @@
from __future__ import annotations
from typing import Any, Dict, List
def _try_yaml():
try:
import yaml # type: ignore
except Exception:
return None
return yaml
def _yaml_load_mapping(text: str) -> Dict[str, Any]:
yaml = _try_yaml()
if yaml is None:
return {}
try:
obj = yaml.safe_load(text)
except Exception:
return {}
if obj is None:
return {}
if isinstance(obj, dict):
return obj
return {}
def _yaml_dump_mapping(obj: Dict[str, Any], *, sort_keys: bool = True) -> str:
yaml = _try_yaml()
if yaml is None:
# fall back to a naive key: value dump (best-effort)
lines: List[str] = []
for k, v in sorted(obj.items()) if sort_keys else obj.items():
lines.append(f"{k}: {v!r}")
return "\n".join(lines).rstrip() + "\n"
# ansible-lint/yamllint's indentation rules are stricter than YAML itself.
# In particular, they expect sequences nested under a mapping key to be
# indented (e.g. `foo:\n - a`), whereas PyYAML's default is often
# `foo:\n- a`.
class _IndentDumper(yaml.SafeDumper): # type: ignore
def increase_indent(self, flow: bool = False, indentless: bool = False):
return super().increase_indent(flow, False)
return (
yaml.dump(
obj,
Dumper=_IndentDumper,
default_flow_style=False,
sort_keys=sort_keys,
indent=2,
allow_unicode=True,
).rstrip()
+ "\n"
)
def _merge_mappings_overwrite(
existing: Dict[str, Any], incoming: Dict[str, Any]
) -> Dict[str, Any]:
"""Merge incoming into existing with overwrite.
NOTE: Unlike role defaults merging, host_vars should reflect the current
harvest for a host. Therefore lists are replaced rather than unioned.
"""
merged = dict(existing)
merged.update(incoming)
return merged

79
enroll/cache.py Normal file
View file

@ -0,0 +1,79 @@
from __future__ import annotations
import os
import re
import tempfile
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
def _safe_component(s: str) -> str:
s = s.strip()
if not s:
return "unknown"
s = re.sub(r"[^A-Za-z0-9_.-]+", "_", s)
s = re.sub(r"_+", "_", s)
return s[:64]
def enroll_cache_dir() -> Path:
"""Return the base cache directory for enroll.
We default to ~/.local/cache to match common Linux conventions in personal
homedirs, but honour XDG_CACHE_HOME if set.
"""
base = os.environ.get("XDG_CACHE_HOME")
if base:
root = Path(base).expanduser()
else:
root = Path.home() / ".local" / "cache"
return root / "enroll"
@dataclass(frozen=True)
class HarvestCache:
"""A locally-persistent directory that holds a harvested bundle."""
dir: Path
@property
def state_json(self) -> Path:
return self.dir / "state.json"
def _ensure_dir_secure(path: Path) -> None:
"""Create a directory with restrictive permissions; refuse symlinks."""
# Refuse a symlink at the leaf.
if path.exists() and path.is_symlink():
raise RuntimeError(f"Refusing to use symlink path: {path}")
path.mkdir(parents=True, exist_ok=True, mode=0o700)
try:
os.chmod(path, 0o700)
except OSError:
# Best-effort; on some FS types chmod may fail.
pass
def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache:
"""Create a new, unpredictable harvest directory under the user's cache.
This mitigates pre-guessing attacks (e.g. an attacker creating a directory
in advance in a shared temp location) by creating the bundle directory under
the user's home and using mkdtemp() randomness.
"""
base = enroll_cache_dir() / "harvest"
_ensure_dir_secure(base)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
safe = _safe_component(hint or "harvest")
prefix = f"{ts}-{safe}-"
# mkdtemp creates a new directory with a random suffix.
d = Path(tempfile.mkdtemp(prefix=prefix, dir=str(base)))
try:
os.chmod(d, 0o700)
except OSError:
pass
return HarvestCache(dir=d)

275
enroll/capture.py Normal file
View file

@ -0,0 +1,275 @@
from __future__ import annotations
import os
import shutil
import stat
from typing import List, Optional, Set
from .fsutil import stat_triplet
from .harvest_types import ExcludedFile, ManagedFile, ManagedLink
from .ignore import IgnorePolicy
from .pathfilter import PathFilter
def files_differ(a: str, b: str, *, max_bytes: int = 2_000_000) -> bool:
"""Return True if file ``a`` differs from file ``b``.
Best-effort and conservative: unreadable/missing baselines, non-regular
files, and unexpectedly large files are treated as different so callers err
on the side of preserving user state.
"""
try:
st_a = os.stat(a, follow_symlinks=True)
except OSError:
return True
if not stat.S_ISREG(st_a.st_mode):
return True
try:
st_b = os.stat(b, follow_symlinks=True)
except OSError:
return True
if not stat.S_ISREG(st_b.st_mode):
return True
if st_a.st_size != st_b.st_size:
return True
if st_a.st_size > max_bytes:
return True
try:
with open(a, "rb") as fa, open(b, "rb") as fb:
while True:
ca = fa.read(1024 * 64)
cb = fb.read(1024 * 64)
if ca != cb:
return True
if not ca:
return False
except OSError:
return True
def copy_into_bundle(
bundle_dir: str, role_name: str, abs_path: str, src_rel: str
) -> None:
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(abs_path, dst)
def capture_file(
*,
bundle_dir: str,
role_name: str,
abs_path: str,
reason: str,
policy: IgnorePolicy,
path_filter: PathFilter,
managed_out: List[ManagedFile],
excluded_out: List[ExcludedFile],
seen_role: Optional[Set[str]] = None,
seen_global: Optional[Set[str]] = None,
metadata: Optional[tuple[str, str, str]] = None,
) -> bool:
"""Try to capture a single file into the bundle.
Returns True if the file was copied and appended to ``managed_out``.
``seen_role`` de-duplicates within a role; ``seen_global`` de-duplicates
across harvest stages so multiple generated roles do not manage one path.
"""
if seen_global is not None and abs_path in seen_global:
return False
if seen_role is not None and abs_path in seen_role:
return False
def _mark_seen() -> None:
if seen_role is not None:
seen_role.add(abs_path)
if seen_global is not None:
seen_global.add(abs_path)
if path_filter.is_excluded(abs_path):
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
_mark_seen()
return False
deny = policy.deny_reason(abs_path)
if deny:
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
_mark_seen()
return False
try:
owner, group, mode = (
metadata if metadata is not None else stat_triplet(abs_path)
)
except OSError:
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
_mark_seen()
return False
src_rel = abs_path.lstrip("/")
try:
copy_into_bundle(bundle_dir, role_name, abs_path, src_rel)
except OSError:
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
_mark_seen()
return False
managed_out.append(
ManagedFile(
path=abs_path,
src_rel=src_rel,
owner=owner,
group=group,
mode=mode,
reason=reason,
)
)
_mark_seen()
return True
USER_SHELL_DOTFILES_WITH_SKEL_BASELINE = [
(".bashrc", "user_shell_rc"),
(".profile", "user_profile"),
(".bash_logout", "user_shell_logout"),
]
USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE = [
(".bash_aliases", "user_shell_aliases"),
]
def capture_user_shell_dotfiles(
*,
bundle_dir: str,
role_name: str,
home: str,
skel_dir: str,
enabled: bool,
policy: IgnorePolicy,
path_filter: PathFilter,
managed_out: List[ManagedFile],
excluded_out: List[ExcludedFile],
seen_role: Optional[Set[str]],
seen_global: Optional[Set[str]],
) -> int:
"""Capture selected per-user shell dotfiles when explicitly enabled."""
if not enabled:
return 0
home = (home or "").rstrip("/")
if not home or not home.startswith("/"):
return 0
captured = 0
max_compare_bytes = int(getattr(policy, "max_file_bytes", 256_000))
for rel, reason in USER_SHELL_DOTFILES_WITH_SKEL_BASELINE:
upath = os.path.join(home, rel)
if not os.path.isfile(upath) or os.path.islink(upath):
continue
skel_path = os.path.join(skel_dir, rel)
if not files_differ(upath, skel_path, max_bytes=max_compare_bytes):
continue
if capture_file(
bundle_dir=bundle_dir,
role_name=role_name,
abs_path=upath,
reason=reason,
policy=policy,
path_filter=path_filter,
managed_out=managed_out,
excluded_out=excluded_out,
seen_role=seen_role,
seen_global=seen_global,
):
captured += 1
for rel, reason in USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE:
upath = os.path.join(home, rel)
if not os.path.isfile(upath) or os.path.islink(upath):
continue
if capture_file(
bundle_dir=bundle_dir,
role_name=role_name,
abs_path=upath,
reason=reason,
policy=policy,
path_filter=path_filter,
managed_out=managed_out,
excluded_out=excluded_out,
seen_role=seen_role,
seen_global=seen_global,
):
captured += 1
return captured
def capture_link(
*,
role_name: str,
abs_path: str,
reason: str,
policy: IgnorePolicy,
path_filter: PathFilter,
managed_out: List[ManagedLink],
excluded_out: List[ExcludedFile],
seen_role: Optional[Set[str]] = None,
seen_global: Optional[Set[str]] = None,
) -> bool:
"""Record a symlink for later materialisation by the manifest renderer."""
if seen_global is not None and abs_path in seen_global:
return False
if seen_role is not None and abs_path in seen_role:
return False
def _mark_seen() -> None:
if seen_role is not None:
seen_role.add(abs_path)
if seen_global is not None:
seen_global.add(abs_path)
if path_filter.is_excluded(abs_path):
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
_mark_seen()
return False
deny_link = getattr(policy, "deny_reason_link", None)
if callable(deny_link):
deny = deny_link(abs_path)
else:
deny = policy.deny_reason(abs_path)
if deny in ("not_regular_file", "not_file", "not_regular"):
deny = None
if deny:
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
_mark_seen()
return False
if not os.path.islink(abs_path):
excluded_out.append(ExcludedFile(path=abs_path, reason="not_symlink"))
_mark_seen()
return False
try:
target = os.readlink(abs_path)
except OSError:
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
_mark_seen()
return False
managed_out.append(ManagedLink(path=abs_path, target=target, reason=reason))
_mark_seen()
return True

File diff suppressed because it is too large Load diff

300
enroll/cm.py Normal file
View file

@ -0,0 +1,300 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, Iterator, List, Mapping, Set
from .state import load_state, state_path, write_state
@dataclass
class CMModule:
"""Renderer-neutral configuration-management resource group.
A CMModule is intentionally small: it captures the resources that a target
renderer can turn into Ansible tasks, Puppet resources, etc.
The renderer may still decide how to name/include/order the group.
"""
role_name: str
module_name: str
packages: Set[str] = field(default_factory=set)
groups: Set[str] = field(default_factory=set)
users: Dict[str, Dict[str, Any]] = field(default_factory=dict)
dirs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
files: Dict[str, Dict[str, Any]] = field(default_factory=dict)
links: Dict[str, Dict[str, Any]] = field(default_factory=dict)
services: Dict[str, Dict[str, Any]] = field(default_factory=dict)
notes: List[str] = field(default_factory=list)
def has_resources(self) -> bool:
return bool(
self.packages
or self.groups
or self.users
or self.dirs
or self.files
or self.links
or self.services
or self.notes
)
@staticmethod
def state_path(bundle_dir: str | Path) -> Path:
"""Return the canonical state.json path for a harvest bundle."""
return state_path(bundle_dir)
@classmethod
def load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
"""Load state.json for a renderer using the shared bundle state loader."""
return load_state(bundle_dir)
@classmethod
def _load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
"""Backward-compatible alias for renderer subclasses."""
return cls.load_state(bundle_dir)
@classmethod
def write_state(
cls,
bundle_dir: str | Path,
state: Mapping[str, Any],
*,
indent: int = 2,
sort_keys: bool = True,
) -> Path:
"""Write state.json using the shared bundle state writer."""
return write_state(bundle_dir, state, indent=indent, sort_keys=sort_keys)
@staticmethod
def _snapshot_items(snap: Dict[str, Any], key: str) -> Iterator[Dict[str, Any]]:
values = snap.get(key) or []
if not isinstance(values, list):
return
for item in values:
if isinstance(item, dict):
yield item
@classmethod
def managed_dirs_from_snapshot(
cls, snap: Dict[str, Any]
) -> Iterator[Dict[str, Any]]:
return cls._snapshot_items(snap, "managed_dirs")
@classmethod
def managed_files_from_snapshot(
cls, snap: Dict[str, Any]
) -> Iterator[Dict[str, Any]]:
return cls._snapshot_items(snap, "managed_files")
@classmethod
def managed_links_from_snapshot(
cls, snap: Dict[str, Any]
) -> Iterator[Dict[str, Any]]:
return cls._snapshot_items(snap, "managed_links")
def add_managed_dir(
self,
path: str,
*,
owner: Any = "root",
group: Any = "root",
mode: Any = "0755",
**attrs: Any,
) -> None:
if not path:
return
data: Dict[str, Any] = {
"owner": owner or "root",
"group": group or "root",
"mode": mode or "0755",
}
data.update(attrs)
self.dirs.setdefault(path, data)
def add_managed_file(
self,
path: str,
*,
owner: Any = "root",
group: Any = "root",
mode: Any = "0644",
**attrs: Any,
) -> None:
if not path:
return
data: Dict[str, Any] = {
"owner": owner or "root",
"group": group or "root",
"mode": mode or "0644",
}
data.update(attrs)
self.files.setdefault(path, data)
def add_managed_link(self, path: str, **attrs: Any) -> None:
if path:
self.links.setdefault(path, attrs)
def add_snapshot_notes(self, snap: Dict[str, Any]) -> None:
self.notes.extend(str(n) for n in (snap.get("notes", []) or []))
def remove_directory_resource_conflicts(self) -> None:
for path in set(self.files) | set(self.links):
self.dirs.pop(path, None)
def package_section_label(
package_role: Dict[str, Any], inventory_packages: Dict[str, Any]
) -> str:
"""Return the Debian Section/RPM Group label for a package role."""
pkg = str(package_role.get("package") or "").strip()
inv = inventory_packages.get(pkg) or {}
candidates: List[str] = []
for value in (package_role.get("section"), inv.get("section"), inv.get("group")):
if isinstance(value, str) and value.strip():
candidates.append(value.strip())
for inst in inv.get("installations", []) or []:
if not isinstance(inst, dict):
continue
for key in ("section", "group"):
value = inst.get(key)
if isinstance(value, str) and value.strip():
candidates.append(value.strip())
for value in candidates:
if value.lower() not in {"(none)", "none", "unspecified"}:
return value
return "misc"
def section_label_for_packages(
packages: List[str], inventory_packages: Dict[str, Any]
) -> str:
"""Return a stable section/group label for a set of packages."""
for pkg in packages or []:
label = package_section_label({"package": pkg}, inventory_packages)
if label and label.lower() != "misc":
return label
return "misc"
def role_order_key(role: str) -> tuple[int, str]:
# Keep broadly similar ordering to generated Ansible playbooks: package/config
# scaffolding first, then services/users, then host-specific runtime state.
priority = {
"apt_config": 10,
"dnf_config": 11,
"etc_custom": 80,
"usr_local_custom": 81,
"extra_paths": 82,
"container_images": 88,
"users": 90,
"sysctl": 95,
"firewall_runtime": 99,
}
return (priority.get(role, 50), role)
def _drop_duplicate_set_items(
module: CMModule,
values: Set[str],
seen: Set[str],
resource_type: str,
) -> Set[str]:
kept: Set[str] = set()
for value in sorted(values):
if value in seen:
module.notes.append(
f"Skipped duplicate {resource_type}[{value}] already emitted earlier in this catalog."
)
continue
kept.add(value)
seen.add(value)
return kept
def _drop_duplicate_mapping_items(
module: CMModule,
values: Dict[str, Dict[str, Any]],
seen: Set[str],
resource_type: str,
*,
excluded_titles: Set[str] | None = None,
excluded_reason: str = "conflicts with another resource",
) -> Dict[str, Dict[str, Any]]:
kept: Dict[str, Dict[str, Any]] = {}
excluded_titles = excluded_titles or set()
for title, attrs in values.items():
if title in excluded_titles:
module.notes.append(f"Skipped {resource_type}[{title}]: {excluded_reason}.")
continue
if title in seen:
module.notes.append(
f"Skipped duplicate {resource_type}[{title}] already emitted earlier in this catalog."
)
continue
kept[title] = attrs
seen.add(title)
return kept
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
"""Resolve global catalog conflicts before renderer output.
Puppet compiles a single resource catalog. Ansible can tolerate the same
package, service, or parent directory appearing in more than one role;
catalog targets cannot. Resolve those conflicts in the shared model rather
than deleting renderer output after the fact.
"""
ordered = list(modules)
concrete_file_paths: Set[str] = set()
for module in ordered:
concrete_file_paths.update(module.files)
concrete_file_paths.update(module.links)
seen_packages: Set[str] = set()
seen_groups: Set[str] = set()
seen_users: Set[str] = set()
seen_dirs: Set[str] = set()
seen_files: Set[str] = set()
seen_links: Set[str] = set()
seen_services: Set[str] = set()
for module in ordered:
module.packages = _drop_duplicate_set_items(
module, module.packages, seen_packages, "Package"
)
module.groups = _drop_duplicate_set_items(
module, module.groups, seen_groups, "Group"
)
module.users = _drop_duplicate_mapping_items(
module, module.users, seen_users, "User"
)
module.dirs = _drop_duplicate_mapping_items(
module,
module.dirs,
seen_dirs,
"File",
excluded_titles=concrete_file_paths,
excluded_reason="a file or link with the same path is emitted in this catalog",
)
module.files = _drop_duplicate_mapping_items(
module, module.files, seen_files | seen_links, "File"
)
seen_files.update(module.files)
module.links = _drop_duplicate_mapping_items(
module, module.links, seen_links | seen_files, "File"
)
seen_links.update(module.links)
module.services = _drop_duplicate_mapping_items(
module, module.services, seen_services, "Service"
)

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import glob
import hashlib
import os
import subprocess # nosec
from typing import Dict, List, Optional, Set, Tuple
@ -64,6 +63,53 @@ def list_manual_packages() -> List[str]:
return sorted(set(pkgs))
def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
"""Return mapping of installed package name -> installed instances.
Uses dpkg-query and is expected to work on Debian/Ubuntu-like systems.
Output format:
{"pkg": [{"version": "...", "arch": "...", "section": "..."}, ...], ...}
"""
try:
p = subprocess.run(
[
"dpkg-query",
"-W",
"-f=${Package}\t${Version}\t${Architecture}\t${Section}\n",
],
text=True,
capture_output=True,
check=False,
) # nosec
except Exception:
return {}
out: Dict[str, List[Dict[str, str]]] = {}
for raw in (p.stdout or "").splitlines():
line = raw.strip("\n")
if not line:
continue
parts = line.split("\t")
if len(parts) < 3:
continue
name, ver, arch = parts[0].strip(), parts[1].strip(), parts[2].strip()
if not name:
continue
instance = {"version": ver, "arch": arch}
if len(parts) >= 4 and parts[3].strip():
instance["section"] = parts[3].strip()
out.setdefault(name, []).append(instance)
# Stable ordering for deterministic JSON dumps.
for k in list(out.keys()):
out[k] = sorted(
out[k], key=lambda x: (x.get("arch") or "", x.get("version") or "")
)
return out
def build_dpkg_etc_index(
info_dir: str = "/var/lib/dpkg/info",
) -> Tuple[Set[str], Dict[str, str], Dict[str, Set[str]], Dict[str, List[str]]]:
@ -154,7 +200,9 @@ def parse_status_conffiles(
if ":" in line:
k, v = line.split(":", 1)
key = k
cur[key] = v.lstrip()
# Preserve leading spaces in continuation lines, but strip
# the trailing newline from the initial key line value.
cur[key] = v.lstrip().rstrip("\n")
if cur:
flush()
@ -178,28 +226,3 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]:
md5, rel = line.split(None, 1)
m[rel.strip()] = md5.strip()
return m
def file_md5(path: str) -> str:
h = hashlib.md5() # nosec
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def stat_triplet(path: str) -> Tuple[str, str, str]:
st = os.stat(path, follow_symlinks=True)
mode = oct(st.st_mode & 0o777)[2:].zfill(4)
import pwd, grp
try:
owner = pwd.getpwuid(st.st_uid).pw_name
except KeyError:
owner = str(st.st_uid)
try:
group = grp.getgrgid(st.st_gid).gr_name
except KeyError:
group = str(st.st_gid)
return owner, group, mode

1351
enroll/diff.py Normal file

File diff suppressed because it is too large Load diff

601
enroll/explain.py Normal file
View file

@ -0,0 +1,601 @@
from __future__ import annotations
import json
from collections import Counter, defaultdict
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Tuple
from .diff import _bundle_from_input # reuse existing bundle handling
from .state import load_state
@dataclass(frozen=True)
class ReasonInfo:
title: str
why: str
_MANAGED_FILE_REASONS: Dict[str, ReasonInfo] = {
# Package manager / repo config
"apt_config": ReasonInfo(
"APT configuration",
"APT configuration affecting package installation and repository behavior.",
),
"apt_source": ReasonInfo(
"APT repository source",
"APT source list entries (e.g. sources.list or sources.list.d).",
),
"apt_keyring": ReasonInfo(
"APT keyring",
"Repository signing key material used by APT.",
),
"apt_signed_by_keyring": ReasonInfo(
"APT Signed-By keyring",
"Keyring referenced via a Signed-By directive in an APT source.",
),
"yum_conf": ReasonInfo(
"YUM/DNF main config",
"Primary YUM configuration (often /etc/yum.conf).",
),
"yum_config": ReasonInfo(
"YUM/DNF config",
"YUM/DNF configuration files (including conf.d).",
),
"yum_repo": ReasonInfo(
"YUM/DNF repository",
"YUM/DNF repository definitions (e.g. yum.repos.d).",
),
"dnf_config": ReasonInfo(
"DNF configuration",
"DNF configuration affecting package installation and repositories.",
),
"rpm_gpg_key": ReasonInfo(
"RPM GPG key",
"Repository signing keys used by RPM/YUM/DNF.",
),
# SSH
"authorized_keys": ReasonInfo(
"SSH authorized keys",
"User authorized_keys files (controls who can log in with SSH keys).",
),
"ssh_public_key": ReasonInfo(
"SSH public key",
"SSH host/user public keys relevant to authentication.",
),
# System config / security
"system_security": ReasonInfo(
"Security configuration",
"Security-sensitive configuration (SSH, sudoers, PAM, auth, etc.).",
),
"system_network": ReasonInfo(
"Network configuration",
"Network configuration (interfaces, resolv.conf, network managers, etc.).",
),
"system_firewall": ReasonInfo(
"Firewall configuration",
"Firewall rules/configuration (ufw, nftables, iptables, ipset, etc.).",
),
"system_sysctl": ReasonInfo(
"sysctl configuration",
"Kernel sysctl tuning (sysctl.conf / sysctl.d).",
),
"system_modprobe": ReasonInfo(
"modprobe configuration",
"Kernel module configuration (modprobe.d).",
),
"system_mounts": ReasonInfo(
"Mount configuration",
"Mount configuration (e.g. /etc/fstab and related).",
),
"system_rc": ReasonInfo(