Install
Install from your preferred channel. The Python package requires Python 3.10 or newer.
$ pip install enroll
$ pipx install enroll$ sudo mkdir -p /usr/share/keyrings
$ curl -fsSL https://mig5.net/static/mig5.asc | sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
$ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mig5.list
$ sudo apt update
$ sudo apt install enroll$ 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$ poetry install
$ poetry run enroll --helpMental model
Enroll works in two main phases. Harvest collects host facts and relevant files into a bundle. Manifest turns that bundle into Ansible configuration-management code.
$ enroll harvest --out ./harvest
$ enroll validate ./harvest
$ enroll manifest --harvest ./harvest --out ./ansiblediff, explain, and validate all operate on harvest bundles too.
Output modes: standalone and multi-host
Standalone project
The default for one host or a reusable starting point. Output must be a new directory. Raw files live in role files/ directories and generated settings in role defaults.
$ enroll manifest --harvest ./harvest --out ./ansibleMulti-host project
Start with --host, then add harvests using --host and --extend. Host settings go in inventory. Matching files stay in the role; differing files move to per-host inventory storage.
$ enroll manifest --harvest ./web1.harvest --host web1 --out ./ansible
$ enroll manifest --harvest ./web2.harvest --host web2 --out ./ansible --extendenroll harvest
Harvest writes a bundle containing state.json and captured artifacts. It detects packages, services, changed config, unowned service files, non-system users and SSH public keys, miscellaneous /etc, selected symlinks, Flatpak/Snap state, Docker/Podman image metadata, and /usr/local scripts/config. Live writable sysctls and firewall state are opt-in; persistent configuration remains part of normal collection.
$ enroll harvest --out /tmp/enroll-harvest
$ enroll harvest --remote-host host.example.net --remote-user admin --out /tmp/enroll-harvest
$ enroll harvest --out /tmp/enroll-harvest --include-path '/home/*/.bashrc' --exclude-path '/usr/local/bin/docker-*'Common flags
--remote-host,--remote-user,--remote-port,--remote-ssh-configfor SSH harvesting.--ask-become-pass/-Kfor remote sudo prompts.--ask-key-passphraseor--ssh-key-passphrase-env ENV_VARfor encrypted SSH private keys.--no-sudofor a less complete remote harvest without sudo.--include-pathand--exclude-pathfor plain paths, globs,glob:,re:, orregex:patterns. Excludes win over includes.--dangerousdisables likely-secret filtering.--harvest-sysctlcaptures live writable sysctl values;--harvest-firewallcaptures live ipset/iptables state when corresponding persistent files are absent. Both work with remote harvest and single-shot.--sops GPG_FINGERPRINT...writes an encryptedharvest.tar.gz.sops.--assume-safe-pathskips the root unsafe-PATHprompt in trusted automation.
enroll manifest
Manifest validates a harvest and renders Ansible output.
$ enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
$ enroll manifest --harvest /tmp/web1.harvest --out /tmp/enroll-multihost --host web1.example.net
$ enroll manifest --harvest /tmp/web2.harvest --out /tmp/enroll-multihost --host web2.example.net --extend
$ enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible-separate-roles --no-common-rolesAnsible output
- Standalone output contains
playbook.yml,roles/, role files, defaults, templates,ansible.cfg, andrequirements.yml. - Multi-host output adds
inventory/hosts.yml,inventory/host_vars/<host>/main.yml,playbooks/<host>.yml, capture notes underhost_notes/, and project metadata under.enroll/. The root playbook imports each host play. - Generated task and handler implementations are shared when they match. Identical files and templates stay in the role. When one differs, Enroll moves that artifact for all hosts using the role into
inventory/host_files/<host>/<role>/; later hosts get their own copy. Different task logic gets a separate role named after its host, such ashttpd__host_ashpool_mig5_net_34f62c26a93d. - Grouped service roles share one restart handler across hosts. When notified, it restarts each active unit in that host's
<role>_restart_unitsinventory list. A host with no active units has an empty list. - Playbooks tag roles as
role_<role_name>, such asrole_users. Check your generated playbook for the roles it contains.
Run --extend from outside the project. Use consistent renderer options for each host. Enroll rejects duplicate hosts, edited roles or shared artifacts needed for reuse, namespace collisions, conflicting collection requirements and unsafe project entries. Extension requires Linux atomic directory exchange; a named encrypted project must be unpacked before extension. Projects from the earlier multi-host format must be regenerated from harvests.
For captured firewall snapshots, firewall_runtime_persist defaults to false. Enable it in the generated variables only after checking for another firewall persistence mechanism; it installs enroll-firewall.service to restore rules at boot and reconcile them on each playbook run.
enroll single-shot
Single-shot runs harvest then manifest in one command. It supports remote SSH, include/exclude paths, --dangerous, --sops, --host, --extend, --no-common-roles, the opt-in runtime capture flags and JinjaTurtle flags. Each harvest output path must be new.
$ enroll single-shot --harvest /tmp/web1.harvest --out /tmp/enroll-ansible --host web1.example.net
$ enroll single-shot --remote-host web2.example.net --remote-user admin --harvest /tmp/web2.harvest --out /tmp/enroll-ansible --host web2.example.net --extendenroll diff
Diff compares two harvest bundles and reports package, service, user, managed-file, symlink, directory and application-inventory drift. Inputs can be bundle directories, state.json paths, tarballs, or SOPS bundles when --sops is enabled.
$ enroll diff --old ./baseline --new ./current --format markdown
$ enroll diff --old ./baseline --new ./current --ignore-package-versions --exclude-path /var/anacron
$ enroll diff --old ./baseline --new ./current --webhook https://example.net/hook --webhook-format json --webhook-header 'Authorization: Bearer ...'Use --exit-code to return status 2 when differences exist. Use --notify-always to send webhook/email even when there are no differences.
enroll explain
Explain produces a human or JSON summary of a harvest: package counts, roles, included reasons, excluded reasons, and example paths.
$ enroll explain ./harvest
$ enroll explain ./harvest --format json --max-examples 25
$ enroll explain ./harvest.tar.gz.sops --sopsenroll validate
Validate checks that state.json exists, is valid JSON, matches the vendored JSON Schema unless skipped, and references artifacts that actually exist. It also reports unreferenced files in artifacts/.
$ enroll validate ./harvest
$ enroll validate ./harvest --format json --out validate.json
$ enroll validate ./harvest --fail-on-warnings
$ enroll validate ./harvest --schema ./state.schema.json
$ enroll validate ./harvest --schema https://enroll.sh/schema/state.schema.json --allow-remote-schemaRemote schemas are not fetched unless --allow-remote-schema is explicitly supplied. Use --no-schema to skip schema checks while keeping consistency checks.
Sensitive data
By default, Enroll tries not to harvest likely secrets. It denies known sensitive paths, private key material, common certificate/private-key locations, credential-looking assignments, credential-bearing URIs, authorization headers, service account key names, and other obvious sensitive values.
--dangerous disables those checks. It may copy private keys, API tokens, TLS key material, database passwords, and other secrets into the harvest output in plaintext. Use it only intentionally, and strongly consider --sops.Value-less comment mentions such as # token are tolerated so stock config files do not become impossible to harvest. A commented-out populated credential assignment is still treated as sensitive.
JinjaTurtle integration
If the jinjaturtle executable is on PATH, Enroll can turn supported config files into Ansible templates. The default mode uses JinjaTurtle when available. --jinjaturtle makes it required; --no-jinjaturtle disables it.
$ enroll manifest --harvest ./harvest --out ./ansible --jinjaturtle
$ enroll manifest --harvest ./harvest --out ./ansible --no-jinjaturtleSupported suffixes include INI, CFG, JSON, TOML, YAML, XML, repo files, and common systemd unit file suffixes. Templates live in role templates/; variables live in role defaults or host vars depending on output mode.
Configuration file
Enroll can load INI-style defaults from --config, ENROLL_CONFIG, $XDG_CONFIG_HOME/enroll/enroll.ini, or ~/.config/enroll/enroll.ini. Current-directory config files are not auto-loaded; pass them explicitly.
[enroll]
assume_safe_path = false
[harvest]
dangerous = false
include_path =
/home/*/.bashrc
/home/*/.profile
exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
# remote_host = host.example.net
# remote_user = admin
[manifest]
no_jinjaturtle = true
# sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
[diff]
exclude_path = /var/anacron
ignore_package_versions = true
[single-shot]
include_path = re:^/home/[^/]+/\.config/myapp/.*$Precedence is explicit CLI flags, then INI config, then argparse defaults. For hyphenated flags, use underscores in the INI file.
Run generated Ansible
Install generated collection requirements first, then run in check mode before applying for real.
$ cd /tmp/enroll-ansible
$ ansible-galaxy collection install -r requirements.yml
$ ansible-playbook -i "localhost," -c local playbook.yml --check
$ ansible-playbook -i "localhost," -c local playbook.yml
# multi-host project
$ ansible-playbook -i inventory/hosts.yml playbook.yml --limit web1.example.net --check
# targeted role tags
$ ansible-playbook -i "localhost," -c local playbook.yml --tags role_users