Metadata-Version: 2.4
Name: enroll
Version: 0.9.0
Summary: Enroll a server's running state retrospectively into Ansible
License-Expression: GPL-3.0-or-later
License-File: LICENSE
Author: Miguel Jacq
Author-email: mig@mig5.net
Requires-Python: >=3.10
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Dist: PyYAML (>=6,<7)
Requires-Dist: jsonschema (>=4.23,<5)
Requires-Dist: paramiko (>=3.5)
Project-URL: Repository, https://git.mig5.net/mig5/enroll
Description-Content-Type: text/markdown

# 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 (Debian-like or RedHat-like) and generates Ansible configuration-management code from it.

- 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 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.
- With `--harvest-sysctl` and root/sudo, captures live writable sysctl state into a `sysctl` role that manages `/etc/sysctl.d/99-enroll.conf`.
- With `--harvest-firewall`, captures live ipset and iptables runtime state, 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'
- Tries to capture Flatpak, Snap, Docker image presence
- Captures snowflake-y things found in /usr/local/bin (for non-binary files) and /usr/local/etc
- Avoids trying to start systemd services that were detected as inactive during harvest.

---

## 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 Ansible configuration-management code.

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.

---

## Manifest layout

Without `--host`, each harvest produces a standalone Ansible project in a new
output directory. Supply your inventory when running its playbook.

To create an extendable multi-host project, give the first host an explicit
inventory identity. Run these commands from outside the output directory:

```bash
enroll manifest --harvest ./harvest-web1 --host web1 --out ./ansible
# The web1 harvest can now be archived or removed.
enroll manifest --harvest ./harvest-web2 --host web2 --out ./ansible --extend
cd ansible
ansible-galaxy collection install -r requirements.yml
ansible-playbook -i inventory/hosts.yml playbook.yml --limit web1
```

The project contains reusable `roles/`, complete host settings in
`inventory/host_vars/<host>/main.yml`, inventory membership in `inventory/hosts.yml`,
and one ordered play in `playbooks/<host>.yml` for each host. The root playbook
imports those plays. `host_notes/<host>.md` retains capture notes and exclusions.
`.enroll/project.json` records the format/generator version, renderer options,
variable ownership, hosts and SHA256 fingerprints. Keep that metadata with the project;
it contains no duplicate harvest or captured file contents.

The first host's raw files and templates remain under each generated role.
When an extending host has the same artifact, both use that shared file. When
an artifact differs, Enroll moves the shared file into
`inventory/host_files/<earlier-host>/<role>/` and stores the incoming version
under the new host. Every later host gets its own copy of that artifact, even
if its contents match an earlier host. Ansible selects the host copy first and
falls back to the role copy for files that remain shared.

Roles share task and handler implementations when their paths, contents and
modes match. Different task or handler logic gets its own role implementation,
named after the host (for example `httpd__host_ashpool_mig5_net_34f62c26a93d`)
and selected by that host's playbook in normal, prerequisite and activation phases.
Grouped service roles use one handler that loops over each host's restart units.
A configuration change that notifies the handler restarts every active unit in
that role's host-specific list; an empty list performs no restarts.
Generated settings live in host variables; different values and empty lists
remain specific to each host. Role defaults in this mode are empty. No semantic
normalization or "close enough" matching is attempted.

You may edit host variables (including `ansible_host`, `ansible_user` and connection
settings) and host-specific artifacts; extension preserves their bytes. You may edit roles too, but an edited
role cannot subsequently be shared with an incoming generated role. New, uniquely
named roles can be added. Duplicate host identifiers, generated role name collisions,
variable namespace conflicts, differing renderer options and collection constraint
conflicts are errors. Use the same `--no-common-roles` and JinjaTurtle settings for
subsequent extensions. Host replacement/removal and extension of projects made
with the earlier multi-host format are not supported; regenerate from harvests.

Generated playbooks, inventory membership, `ansible.cfg`, `requirements.yml`, the
root README and Enroll metadata are owned by the generator. Editing the tracked
control files prevents extension; put connection customizations in host variables.
Existing unshared roles and other ordinary files are preserved. Projects containing
symlinks, hardlinks or special files are refused rather than followed.

Extension locks the project, builds and checks a private staging copy, checks for
concurrent changes, and atomically swaps directories using Linux `renameat2`.
Unsupported filesystems fail without changing the project. Run from outside the
project and do not edit or apply it during extension. Existing directories still
require explicit `--extend`; failures before publication preserve the project.

`--host` and `--extend` also work with `single-shot`. A named project can be generated
with `--sops`, but extension requires an unpacked plaintext project; `--extend --sops`
is rejected. There is no merger for arbitrary Ansible repositories. The old `--fqdn`
flag remains removed; `--host` plus explicit `--extend` replaces its unsafe implicit
merging behavior.

---

## 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*`
- Optional (`--harvest-sysctl`) 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)
- Optional (`--harvest-firewall`) 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.
- Root PATH safety:
  - when run as root, Enroll warns and asks for confirmation if `PATH` contains `.`, an empty/relative entry, or a group/world-writable directory.
  - use `--assume-safe-path` for trusted non-interactive automation where that `PATH` is intentional.

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

---

### `enroll manifest`
Generate Ansible output from an existing harvest bundle.

**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 and playbook).
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.

**Common flags**
- `--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.

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

**IMPORTANT**: Always make sure that you take adequate precautions to prevent a malicious actor from tampering with your harvest. Enroll tries to set the permissions of it to something your running user has access to, but environments and situations can vary. A malicious actor could change your harvest contents in a way that doesn't violate the schema but results in sensitive exposure or dangerous execution once you apply the 'manifested' configuration management version of it.

Whenever in doubt, add `--sops` (with SOPS installed on your PATH) and encrypt the harvest so that only you can decrypt it.

---

### `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 `--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)

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


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

Safe-mode content scanning is intentionally conservative. It treats common assignment-style credential keys as sensitive, including names such as `password` (and abbreviations like `passwd`, `pwd`, and `pw`, e.g. `db_pw`), `client_secret`, `secret_key`, `auth_token`, `api_key`, `aws_access_key_id`, `aws_secret_access_key`, `azure_client_secret`, `GOOGLE_APPLICATION_CREDENTIALS`, and service-account key names.

**IMPORTANT**: Enroll tolerates value-less credential keyword mentions in comments, such as `# token`, so ordinary stock configuration files do not become unusable. However, commented-out credential values are still treated as sensitive. A populated credential assignment, credential-bearing URI, `Authorization` header, or private-key material is refused in default safe mode even when it appears inside a comment. Use `--dangerous` only when you intentionally want to collect such material, and prefer `--sops` or another appropriate form of at-rest encryption whenever in doubt.

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 wish to opt in to collecting everything, use `--dangerous` mode, but be aware of what it means:

### `--dangerous`

**IMPORTANT:** 'dangerous' mode is exactly that: it disables “likely secret” safety checks when harvesting system data.

This means it can copy private keys, TLS key material, API tokens, database passwords, and other credentials into the harvest output **in plaintext**, including paths that would normally be considered very secret.

If you intend to keep harvests/manifests long-term on disk away from the host or its usual protected paths, 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

If [JinjaTurtle](https://git.mig5.net/mig5/jinjaturtle) is installed, `enroll` can generate templates for ini/json/xml/toml-style config in renderers.

For Ansible:
- Templates live in `roles/<role>/templates/...`
- Variables live in `roles/<role>/defaults/main.yml`.

You can force template generation on with `--jinjaturtle` or disable it with `--no-jinjaturtle`.

---

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

## Fedora

```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
```bash
pip install enroll
```

## Poetry (dev)
```bash
poetry install
poetry run enroll --help
```

---

## Found a bug / have a suggestion?

My Forgejo doesn't currently support federation, so I haven't opened registration/login for issues.

Instead, email me (see `pyproject.toml`).

---

# Examples

## Harvest

### Local harvest
```bash
enroll harvest --out /tmp/enroll-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)>
```

---

## Runtime snapshots are opt-in

Normal harvesting keeps persistent configuration files, including `/etc/sysctl.conf`,
`/etc/sysctl.d`, and supported firewall configuration. It does not snapshot live
sysctl or ipset/iptables state unless you request it:

```bash
enroll harvest --out ./harvest --harvest-firewall --harvest-sysctl
```

Both flags also work with `single-shot`, remote harvesting and INI configuration
(`harvest_firewall = true`, `harvest_sysctl = true`). They are independent of
`--dangerous` and still honor path exclusions.

Firewall capture skips families with known persistent files, but cannot discover
every `rc.local` hook, custom script or competing firewall manager. Generated
`firewall_runtime_persist` defaults to **false**: the role restores a captured
snapshot when its files change, without installing a boot service. Set it to
`true` only after choosing Enroll as the persistence owner and disabling any
competing restore mechanism. That opt-in installs `enroll-firewall.service`,
restores ipsets before iptables, and reconciles the snapshot on every playbook
run (so those runs intentionally report a change). Setting the variable back to
false does not remove an already installed service; disable/remove it explicitly
when migrating to another persistence owner.

`--harvest-sysctl` records writable live values into `99-enroll.conf`. Live values
may be temporary or duplicate settings in other sysctl files. Review that file
alongside `/etc/sysctl.conf` and `sysctl.d` ordering before applying it; the flag
is not a claim that these values are the source host's intended persistent policy.

## Manifest

### Standalone output
```bash
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
```

---

## Single-shot

```bash
enroll single-shot --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
```

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

---

## Service and package family association

Enroll first identifies the installed package owning each enabled service's unit
file. It can then include related installed packages in that service's snapshot
and capture their modified configuration before generating the Ansible role:

- Debian/Ubuntu: use `dpkg-query` source-package identity and direct `Depends` or
  `Pre-Depends` relationships, including uniquely resolved installed alternatives
  and `Provides` capabilities.
- RPM systems (including DNF/Yum): use the local RPM database's `SOURCERPM`,
  `REQUIRENAME` and `PROVIDENAME`. Both packages must have the same source RPM
  filename, including its version/release. This needs no repoquery plugin or
  network access.

A dependency in either direction is evidence only when both packages share that
source identity. Enroll follows one edge from the unit owner, without recursively
absorbing dependencies. A package owning another captured service keeps its
existing attribution. If several services qualify for an additional package,
its exact `PACKAGE.service` name breaks the tie; otherwise no additional
attribution is made and service notes explain the ambiguity. Names alone never
establish a relationship. Successful associations also appear in service notes.

For example, `console-setup.service` belongs to `console-setup-linux`, while the
related `console-setup` package depends on it and shares its source package.
Enroll can include both packages and their configuration in `console_setup`
instead of producing an additional `package_console_setup` role. Default
Section/Group role grouping still applies unless `--no-common-roles` is used.

This is conservative attribution, not dependency solving: version constraints
are not evaluated, multiple installed instances of a package are excluded from
new associations, and ambiguous providers, RPM rich dependencies and file-path
requirements without an explicit matching `Provides` are not inferred. Missing
metadata or failed queries leave the existing ownership/configuration inference
in place. Unresolved service/package name clashes retain separate artifact
namespaces. No changes to installed packages or their manual/automatic status are
made during harvest.

---

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

---

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

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

## Configuration file

As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands.

Sometimes, it can be easier to store them in a config file so you don't have to remember them!

Enroll supports reading an ini-style file of all the arguments for each subcommand.

### Location of the config file

The path the config file can be specified with `-c` or `--config` on the command-line. Otherwise,
Enroll will look for the `ENROLL_CONFIG` environment variable, `$XDG_CONFIG_HOME/enroll/enroll.ini`,
or `~/.config/enroll/enroll.ini`.

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

[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/.*$
```

## Reconstruction limits

Enroll records observed state; it cannot infer every intended absence or application
dependency. Review notes and exclusions before adopting a generated manifest.
Non-service systemd unit lifecycle state (timers, sockets, paths, mounts), deliberately
removed package defaults, application data, databases, volumes and virtualenvs still
need explicit review. Safe-mode secret heuristics are conservative and may exclude
ordinary configuration; use targeted review rather than assuming the bundle is complete.

Generated playbooks install package prerequisites and create users/groups before
configuration, and activate services after deployment. Numeric group IDs are retained;
conflicting target names/IDs fail for explicit resolution. Supplementary memberships
remain additive. Grouped service handlers restart the host's active units for the
notified role.
Every output is a new standalone tree; generation is staged and published on success.

