Welcome to the first of Enroll's new, erm, news section! To celebrate, Enroll 0.7.0 has been released, and makes manifest rendering target-selectable based on your preferred config management tool! Ansible remains the default, but Puppet and Salt are now possible too.
+Earlier 0.7.0 development builds experimented with Puppet and Salt renderers. That experiment has been removed from the current codebase. The supported flow today is simpler and more honest: harvest a Linux host, validate the bundle, and generate Ansible roles and playbooks.
-Highlights in 0.7.0
--
-
- Puppet support!
--target puppetrenders Puppet module/control-repo style output., and in--fqdnmode, renders per-host Hiera data.
- - Salt Stack support!
--target saltrenders Salt state trees and, in--fqdnmode, Salt pillar data.
- - Single-site output tries to combine package/service data by their package manager's
Sectioncategory, to reduce role sprawl and speed up execution.
- - Flatpak and Snap detection! -
- Docker image detection! -
--target ansible, --target puppet, or --target salt, it is stale. Current enroll manifest has no --target option.Dry-run examples: choose your own config management adventure!
-Feels too good to be true, but it isn't:
-$ enroll harvest --out ./harvest
+ What remains
+
+ - Harvest bundles with
state.json and captured artifacts.
+ - Ansible manifesting in single-site mode or
--fqdn multi-site mode.
+ - Role grouping by package section/group where possible, with
--no-common-roles available when you prefer one generated role per package or unit.
+ - Remote harvesting over SSH, including OpenSSH config support, sudo prompts, and encrypted key passphrase handling.
+ - Diff, explain, and validate workflows for drift reporting and bundle inspection.
+ - SOPS at-rest encryption for harvest and manifest bundles.
+ - JinjaTurtle integration for supported Ansible templates when the executable is available.
+
-$ enroll manifest --harvest ./harvest --target ansible --out ./ansible
-$ ansible-playbook -i "localhost," -c local ./ansible/playbook.yml --check --diff
+ A current workflow
+ $ enroll harvest --out ./harvest
+$ enroll validate ./harvest
+$ enroll manifest --harvest ./harvest --out ./ansible
+$ cd ./ansible
+$ ansible-galaxy collection install -r requirements.yml
+$ ansible-playbook -i "localhost," -c local playbook.yml --check
-$ enroll manifest --harvest ./harvest --target puppet --out ./puppet
-$ puppet apply --modulepath ./puppet/modules ./puppet/manifests/site.pp --noop
+ Security and provenance changes
+ The current documentation is also more explicit about trust boundaries. Enroll validates structure and tries to avoid accidental secret capture, symlink traversal, unsafe artifact paths, unsafe root output paths, and root PATH foot-guns. It still cannot prove that a harvested desired state is semantically safe to apply.
+ This harvest is structurally valid, but Enroll cannot prove it is semantically safe. Only apply manifests generated from harvests whose provenance you trust.
-$ enroll manifest --harvest ./harvest --target salt --out ./salt
-$ salt-call --local --file-root ./salt/states state.apply test=True
- Other notable current behavior
+-
+
- Safe-mode content scanning tolerates value-less credential words in comments, but populated credential assignments are still treated as sensitive even if commented out. +
- Automatic per-user shell dotfile harvesting is only enabled in
--dangerousmode.
+ - Plain directory harvests are frozen into a private temp tree before manifesting, reducing TOCTOU exposure between validation and rendering. +
- Remote harvests reject unknown SSH host keys by default. +
- Root runs refuse unsafe non-interactive
PATHsetups unless--assume-safe-pathis supplied.
+
I recommend using Salt 3008.1 - on Debian 13 I encountered annoying noisy Python errors with version 3007, which are unrelated to Enroll.
- -Since Salt uses Jinja2 templates, it will take advantage of my other tool JinjaTurtle, if it's on your PATH, just like Ansible does.
And because I didn't want Puppet users to feel left out, version 0.5.5 of JinjaTurtle (despite its name) also supports erb templates as well. This means Puppet templates will be generated if you have JinjaTurtle and there are viable config files for it, too!
Have you used enroll diff --enforce to enforce reverting a system to an older harvest state via Ansible? Well, that works too for Puppet or Salt - just add --target [salt|puppet] to that command.
New grouping behaviour in roles/modules
-Did you find the number of manifested roles overwhelming?
-Previously, Enroll created an Ansible role (or, now, a Puppet module or Salt role) for pretty much every 'package' it found. In some cases this could result in hundreds of roles.
-Technically fine, but overwhelming to look at! It also made the playbooks a bit slow to run. If you have fewer roles for the same harvest, Ansible gets faster.
- -As of 0.7.0, Enroll groups related package and service snapshots by the package manager's Section category (or comparable backend metadata).
For example, network-related packages and config files might end up in a role called net. Meanwhile, vim, nano might both appear in editors, and mutt and Thunderbird may be in mail. It's easier on the eye, and it's quicker to run!
If you don't like this new layout, you can pass --no-common-roles to enforce the previous behaviour. Also, if you use --fqdn for host-specific data-driven output, 'common' roles are disabled automatically, to avoid 'bleed in' of unnecessary packages on other hosts that might share that role later.
Flatpak and Snap detection
-Enroll now attempts to detect Flatpak and Snaps present on the system. For Flatpaks, this includes user-specific Flatpaks as well as system-wide ones. Manifesting in Ansible will attempt to use the community.general collection to create Flatpak and Snap tasks to enforce the presence of those packages.
Flatpak/Snap manifesting is also available for Puppet and Snap, but it's slightly cruder through the use of guarded cmd/exec statements - I found this keeps things simpler than having to add third party modules/extensions (and the state of extensions in Salt Stack right now, is a bit of a mess, IMO).
- -$ sudo ansible-playbook playbook.yml -i localhost, -c local --tags role_snap --diff
-
-PLAY [Apply all roles on all hosts] *******************************************************************************************************************************************************************************
-
-TASK [Gathering Facts] ********************************************************************************************************************************************************************************************
-ok: [localhost]
-
-TASK [snap : Install system-wide snaps with full detected attributes] *********************************************************************************************************************************************
-ok: [localhost] => (item={'channel': 'latest/stable', 'classic': False, 'dangerous': False, 'devmode': False, 'install_revision': False, 'name': 'bare', 'notes': ['base'], 'revision': 5, 'source': 'snap-list'})
-ok: [localhost] => (item={'channel': 'latest/stable', 'classic': False, 'dangerous': False, 'devmode': False, 'install_revision': False, 'name': 'core24', 'notes': ['base'], 'revision': 1643, 'source': 'snap-list'})
-ok: [localhost] => (item={'channel': 'latest/stable', 'classic': False, 'dangerous': False, 'devmode': False, 'install_revision': False, 'name': 'gnome-46-2404', 'notes': [], 'revision': 153, 'source': 'snap-list'})
-ok: [localhost] => (item={'channel': 'latest/stable', 'classic': False, 'dangerous': False, 'devmode': False, 'install_revision': False, 'name': 'gtk-common-themes', 'notes': [], 'revision': 1535, 'source': 'snap-list'})
-ok: [localhost] => (item={'channel': 'latest/stable', 'classic': False, 'dangerous': False, 'devmode': False, 'install_revision': False, 'name': 'mesa-2404', 'notes': [], 'revision': 1165, 'source': 'snap-list'})
-ok: [localhost] => (item={'channel': 'latest/stable', 'classic': False, 'dangerous': False, 'devmode': False, 'install_revision': False, 'name': 'onionshare', 'notes': [], 'revision': 212, 'source': 'snap-list'})
-ok: [localhost] => (item={'channel': 'latest/stable', 'classic': False, 'dangerous': False, 'devmode': False, 'install_revision': False, 'name': 'snapd', 'notes': ['snapd'], 'revision': 26865, 'source': 'snap-list'})
-
- Docker/Podman image detection
-The harvest now detects the presence of container images, if the user has permission to call Docker or Podman. In particular, it detects the SHA256 of the image instead of relying on floating tags.
All three renderers (Ansible, Salt and Puppet) will attempt to enforce the presence of those Docker images per their precise SHA256 hash, if they were present in the harvest but not on the machine upon applying a manifest.
-For Ansible, you may need the community.docker collection, but on Debian 13 I found that it was already present by default in the official ansible Debian packages.
For Ansible, if using Podman, you'll need 1.20.0 or later of the community.podman collection. Enroll creates a requirements.yml to make it easy for you: its README.md will guide you to run ansible-galaxy collection install -r requirements.yml before running the playbook.
$ ansible-playbook -i localhost, -c local playbook.yml --check --diff --tags role_container_images
-PLAY [Apply all roles on all hosts] *******************************************************************************************************************************************************************************
-
-TASK [Gathering Facts] ********************************************************************************************************************************************************************************************
-ok: [localhost]
-
-TASK [container_images : Pull Docker images by immutable registry digest] *****************************************************************************************************************************************
-ok: [localhost] => (item={'architecture': 'amd64', 'created': '2026-06-17T22:44:41.625799723Z', 'engine': 'docker', 'home': None, 'image_id': 'sha256:eaf6f386053efdf0ad30236a394b4460e3669006afc9eb1220ae2047f330cc9c', 'notes': [], 'os': 'linux', 'platform': 'linux/amd64', 'pull_ref': 'nginx@sha256:42f2d24ae18df9b5251d1cc45548085656d2335e9338fd150a24e415462d151f', 'repo_digests': ['nginx@sha256:42f2d24ae18df9b5251d1cc45548085656d2335e9338fd150a24e415462d151f'], 'repo_tags': ['nginx:latest'], 'scope': 'system', 'size': 161308134, 'source': 'docker image inspect', 'tag_aliases': [{'ref': 'nginx:latest', 'repository': 'nginx', 'tag': 'latest'}], 'user': None, 'variant': None})
-
-TASK [container_images : Tag Docker images with harvested tag aliases] ********************************************************************************************************************************************
-ok: [localhost] => (item=[{'architecture': 'amd64', 'created': '2026-06-17T22:44:41.625799723Z', 'engine': 'docker', 'home': None, 'image_id': 'sha256:eaf6f386053efdf0ad30236a394b4460e3669006afc9eb1220ae2047f330cc9c', 'notes': [], 'os': 'linux', 'platform': 'linux/amd64', 'pull_ref': 'nginx@sha256:42f2d24ae18df9b5251d1cc45548085656d2335e9338fd150a24e415462d151f', 'repo_digests': ['nginx@sha256:42f2d24ae18df9b5251d1cc45548085656d2335e9338fd150a24e415462d151f'], 'repo_tags': ['nginx:latest'], 'scope': 'system', 'size': 161308134, 'source': 'docker image inspect', 'user': None, 'variant': None}, {'ref': 'nginx:latest', 'repository': 'nginx', 'tag': 'latest'}])
-
-TASK [container_images : Pull system Podman images by immutable registry digest] **********************************************************************************************************************************
-ok: [localhost] => (item={'architecture': 'amd64', 'created': '2026-06-16T00:01:29.967161902Z', 'engine': 'podman', 'home': None, 'image_id': 'sha256:d529dd0c6e5597ac7e4a3e2dea65c3fcc6173f4cae713c409265c1dd9914a11b', 'notes': [], 'os': 'linux', 'platform': 'linux/amd64', 'pull_ref': 'docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b', 'repo_digests': ['docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b', 'docker.io/library/alpine@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f'], 'repo_tags': ['docker.io/library/alpine:latest'], 'scope': 'system', 'size': 8709729, 'source': 'podman image inspect', 'tag_aliases': [{'ref': 'docker.io/library/alpine:latest', 'repository': 'docker.io/library/alpine', 'tag': 'latest'}], 'user': None, 'variant': None})
-
-TASK [container_images : Tag system Podman images with harvested tag aliases] *************************************************************************************************************************************
-ok: [localhost] => (item=[{'architecture': 'amd64', 'created': '2026-06-16T00:01:29.967161902Z', 'engine': 'podman', 'home': None, 'image_id': 'sha256:d529dd0c6e5597ac7e4a3e2dea65c3fcc6173f4cae713c409265c1dd9914a11b', 'notes': [], 'os': 'linux', 'platform': 'linux/amd64', 'pull_ref': 'docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b', 'repo_digests': ['docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b', 'docker.io/library/alpine@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f'], 'repo_tags': ['docker.io/library/alpine:latest'], 'scope': 'system', 'size': 8709729, 'source': 'podman image inspect', 'user': None, 'variant': None}, {'ref': 'docker.io/library/alpine:latest', 'repository': 'docker.io/library/alpine', 'tag': 'latest'}])
-
-TASK [container_images : Pull user Podman images by immutable registry digest] ************************************************************************************************************************************
-skipping: [localhost]
-
-TASK [container_images : Tag user Podman images with harvested tag aliases] ***************************************************************************************************************************************
-skipping: [localhost]
-
-PLAY RECAP ********************************************************************************************************************************************************************************************************
-localhost : ok=5 changed=0 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0
-
- I did not use community extensions/modules for Docker in the Salt and Puppet renderers, because, well, they are god-awful (the Salt one simply doesn't work in 3008.1, and the Puppet one is non-idempotent and I would argue cruder in its approach to image management than a guarded Exec call can be (and is).
Other smaller changes
--
-
sysctlruntime parameters are now detected and would be written to/etc/sysctl.d/99-enroll.conf. Not all runtime parameters are supported.
- .bashrcand similar files are now only harvested from user directories when--dangerousis used, since this is a common place for sensitive environment variables to be set. As always, remember that--dangerousgives better harvest coverage, but you should use--sopsor some other means of your own to encrypt the harvested data at rest safely!
- - Some output during an Ansible play is hidden with
no_logto avoid potentially sensitive output, particularly of systemd unit state.
- - In case you missed it in version 0.6.0: Enroll now harvests runtime
iptablesandipsetrules!
-
More coverage
-With these changes comes a lot of new 'variance' and argument input to the app. Pytest coverage is now at about 86%, and there is a big suite unit tests for Ansible, Puppet and Salt too, in CI. I'm continuing to try and automate testing all the ways you can use this tool.
- --
Thanks to everyone who has reached out with suggestions, constructive criticism, and bug reports!
-If you think you know someone who would like Enroll, please tell them about it!
-