Compare commits

...
Sign in to create a new pull request.

29 commits

Author SHA1 Message Date
5f49bef2ad
remove fedi link
Some checks failed
CI / test (push) Failing after 2m13s
2026-07-24 10:56:31 +10:00
3253f6e16c
Add 0.8.0 news
Some checks failed
CI / test (push) Failing after 2m7s
2026-07-14 17:36:59 +10:00
41edfece4a
schema update
Some checks failed
CI / test (push) Failing after 2m23s
2026-07-05 11:03:25 +10:00
97011f287f
Updates
Some checks are pending
CI / test (push) Waiting to run
2026-07-05 11:00:58 +10:00
5319ac9161
Update news
All checks were successful
CI / test (push) Successful in 1m52s
2026-06-30 16:36:26 +10:00
b4de16b73a
Wording change
Some checks failed
CI / test (push) Has been cancelled
2026-06-30 16:21:27 +10:00
c760c2ab0f
Updates to reflect reality
Some checks are pending
CI / test (push) Waiting to run
2026-06-30 16:18:14 +10:00
38439b0e89
Revert "Fixes for a never-to-be-released, um, release"
This reverts commit ff1ac0e01c.
2026-06-30 16:01:12 +10:00
ff1ac0e01c
Fixes for a never-to-be-released, um, release
All checks were successful
CI / test (push) Successful in 2m27s
2026-06-22 20:26:45 +10:00
277249c4c5
news updates
All checks were successful
CI / test (push) Successful in 2m36s
2026-06-21 15:36:33 +10:00
7cda9a0519
paragraph change
All checks were successful
CI / test (push) Successful in 2m4s
2026-06-21 15:32:49 +10:00
1dc5fd759c
Wording change
Some checks failed
CI / test (push) Has been cancelled
2026-06-21 15:32:17 +10:00
468a293f61
Footer change
All checks were successful
CI / test (push) Successful in 2m4s
2026-06-21 15:23:32 +10:00
044455a58a
Wording change
All checks were successful
CI / test (push) Successful in 2m5s
2026-06-21 15:20:22 +10:00
a0a2ed4e4e
Troubleshooting
All checks were successful
CI / test (push) Successful in 2m5s
2026-06-21 13:26:54 +10:00
89831d600b
enroll diff --enforce can take --target for specific config managers
All checks were successful
CI / test (push) Successful in 2m39s
2026-06-21 12:49:20 +10:00
3e635c8e47
Update schema
Some checks are pending
CI / test (push) Waiting to run
2026-06-21 12:45:53 +10:00
b48666dc43
more docs
Some checks are pending
CI / test (push) Waiting to run
2026-06-21 12:45:20 +10:00
1d1316d89f
More updates
Some checks are pending
CI / test (push) Waiting to run
2026-06-21 12:41:31 +10:00
493c6f5b8c
Fix icon for diff
All checks were successful
CI / test (push) Successful in 2m7s
2026-06-21 10:29:19 +10:00
ef97d75b9f
More docs tweaks
All checks were successful
CI / test (push) Successful in 2m6s
2026-06-21 10:26:36 +10:00
8c5d99b4b6
Updates for 0.7.0
All checks were successful
CI / test (push) Successful in 2m2s
2026-06-21 09:50:20 +10:00
9229606588
Salt is back, I'm a glutton for punishment
All checks were successful
CI / test (push) Successful in 2m35s
2026-06-18 20:47:13 +10:00
fcbb3eb26b
ignore .lock file 2026-06-18 20:40:59 +10:00
d0d4bb5186
remove lock file 2026-06-18 20:40:47 +10:00
0c152d9166
Update 0.7.0 news
All checks were successful
CI / test (push) Successful in 2m17s
2026-06-17 17:51:02 +10:00
0d8927ef6d
Clarify ansible neeed for flatpak/snap
All checks were successful
CI / test (push) Successful in 2m14s
2026-06-17 15:54:10 +10:00
83c30de44a
0.7.0 2026-06-17 15:35:26 +10:00
dd9a9113ba
0.7.0 2026-06-17 15:35:16 +10:00
19 changed files with 1214 additions and 1088 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
*.swp
src/public
src/.hugo_build.lock

View file

View file

@ -1,25 +1,26 @@
---
title: "Enroll"
html_title: "Enroll - Reverse-engineering servers into Ansible"
description: "Enroll inspects Debian-like and RedHat-like Linux hosts and generates Ansible roles/playbooks from what it finds. Harvest → Manifest → Manage."
og_title: "Enroll - Reverse-engineering servers into Ansible"
og_description: "Harvest a host's real configuration and turn it into Ansible roles/playbooks. Safe-by-default, with optional SOPS encryption."
html_title: "Enroll - Reverse-engineering Linux hosts into Ansible"
description: "Enroll inspects Debian-like and RedHat-like Linux hosts and generates Ansible configuration-management code from what it finds. Harvest → Manifest → Manage."
og_title: "Enroll - Reverse-engineering Linux hosts into Ansible"
og_description: "Harvest a host's real configuration and turn it into Ansible roles and playbooks. Safe-by-default, with optional SOPS encryption."
og_type: "website"
---
<header class="hero py-5">
<div class="container py-4">
<div class="row align-items-center g-4">
<div class="col-lg-6">
<div class="kicker mb-3"><i class="bi bi-magic"></i> Reverse-engineering servers into Ansible</div>
<h1 class="display-5 fw-800 lh-1 mb-3" style="letter-spacing:-0.03em;">Get an existing Linux host into Ansible in seconds.</h1>
<p class="lead mb-4">Enroll inspects a Debian-like or RedHat-like system, harvests the state that matters, and generates Ansible roles/playbooks so you can bring snowflakes under management fast.</p>
<div class="kicker mb-3"><i class="bi bi-magic"></i> Reverse-engineering Linux hosts into Ansible</div>
<h1 class="display-5 fw-800 lh-1 mb-3" style="letter-spacing:-0.03em;">Get an existing server into config management quickly.</h1>
<p class="lead mb-4">Enroll inspects a Debian-like or RedHat-like Linux machine, harvests the state that matters, and generates Ansible roles and playbooks from the captured bundle.</p>
<div class="d-flex flex-wrap gap-2 mb-4">
<a class="btn btn-dark btn-lg" href="#quickstart"><i class="bi bi-rocket-takeoff"></i> Quickstart</a>
<a class="btn btn-outline-dark btn-lg" href="#demos"><i class="bi bi-play-circle"></i> Watch demos</a>
<a class="btn btn-outline-dark btn-lg" href="docs.html"><i class="bi bi-journal-text"></i> Documentation</a>
<a class="btn btn-outline-secondary btn-lg" href="https://pypi.org/project/enroll/" target="_blank" rel="noreferrer"><i class="bi bi-box-seam"></i> PyPI</a>
<a class="btn btn-outline-secondary btn-lg" href="news.html"><i class="bi bi-megaphone"></i> News</a>
</div>
<div class="d-flex flex-wrap gap-2">
<span class="badge badge-soft rounded-pill px-3 py-2"><i class="bi bi-speedometer"></i> Super fast</span>
<span class="badge badge-soft rounded-pill px-3 py-2"><i class="bi bi-shield-check"></i> Safe by default</span>
<span class="badge badge-soft rounded-pill px-3 py-2"><i class="bi bi-lock"></i> Optional SOPS encryption</span>
<span class="badge badge-soft rounded-pill px-3 py-2"><i class="bi bi-hdd-network"></i> Remote over SSH</span>
</div>
@ -28,25 +29,17 @@ og_type: "website"
<div class="col-lg-6">
<div class="hero-card p-4 p-lg-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="fw-semibold"><i class="bi bi-terminal"></i> Config in the blink of an eye</div>
<div class="small text-muted">single-shot → ansible-playbook</div>
<div class="fw-semibold"><i class="bi bi-terminal"></i> Harvest once, render Ansible</div>
<div class="small text-muted">state.json + artifacts → roles/playbooks</div>
</div>
<div class="terminal">
<div class="mb-2"><span class="prompt">$</span> <code style="font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;">enroll single-shot --harvest ./harvest --out ./ansible</code></div>
<div class="mb-2"><span class="prompt">$</span> <code style="font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;">cd ./ansible && tree -L 2</code></div>
<pre class="mb-0" style="white-space:pre-wrap; font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.9rem;">.
├── ansible.cfg
├── playbook.yml
├── roles/
│ ├── cron/
│ ├── etc_custom/
│ ├── firewall/
│ ├── nginx/
│ ├── openssh-server/
│ ├── users/
└── README.md</pre>
<div class="mb-2"><span class="prompt">$</span> <code style="font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;">enroll harvest --out ./harvest</code></div>
<div class="mb-2"><span class="prompt">$</span> <code style="font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;">enroll manifest --harvest ./harvest --out ./ansible</code></div>
<div class="mb-2"><span class="prompt">$</span> <code style="font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;">ansible-galaxy collection install -r ./ansible/requirements.yml</code></div>
<pre class="mb-0" style="white-space:pre-wrap; font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.9rem;">./ansible → playbook.yml, roles/, requirements.yml
./ansible + --fqdn → playbooks/&lt;fqdn&gt;.yml + inventory/host_vars/...</pre>
</div>
<div class="smallprint mt-3">Tip: for multiple hosts, use <code>--fqdn</code> to generate inventory-driven, data-driven roles.</div>
<div class="smallprint mt-3">Current Enroll output is Ansible-only. Older examples that mention <code>--target puppet</code> or <code>--target salt</code> are from an experimental branch and no longer match the CLI.</div>
</div>
</div>
</div>
@ -57,88 +50,24 @@ og_type: "website"
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-5">
<h2 class="section-title display-6 fw-bold mb-3">A simple mental model</h2>
<p class="mb-4">Enroll is built around two phases, plus optional drift and reporting tools:</p>
<h2 class="section-title display-6 fw-bold mb-3">The mental model</h2>
<p class="mb-4">Enroll is built around a small pipeline: capture first, render later, validate before you trust the output.</p>
<div class="d-flex flex-column gap-3">
<div class="d-flex gap-3">
<div class="icon-pill"><i class="bi bi-bucket"></i></div>
<div>
<div class="fw-semibold">Harvest</div>
<div class="text-muted">Collect host facts + relevant files into a bundle.</div>
</div>
</div>
<div class="d-flex gap-3">
<div class="icon-pill"><i class="bi bi-diagram-3"></i></div>
<div>
<div class="fw-semibold">Manifest</div>
<div class="text-muted">Render Ansible roles &amp; playbooks from the harvest.</div>
</div>
</div>
<div class="d-flex gap-3">
<div class="icon-pill"><i class="bi bi-plus-slash-minus"></i></div>
<div>
<div class="fw-semibold">Diff</div>
<div class="text-muted">Compare two harvests and notify via webhook/email.</div>
</div>
</div>
<div class="d-flex gap-3">
<div class="icon-pill"><i class="bi bi-patch-question"></i></div>
<div>
<div class="fw-semibold">Explain</div>
<div class="text-muted">Analyze what's included/excluded in the harvest and why.</div>
</div>
</div>
<div class="d-flex gap-3">
<div class="icon-pill"><i class="bi bi-check-square"></i></div>
<div>
<div class="fw-semibold">Validate</div>
<div class="text-muted">Confirm that a harvest isn't corrupt or lacking artifacts.</div>
</div>
</div>
<div class="d-flex gap-3"><div class="icon-pill"><i class="bi bi-bucket"></i></div><div><div class="fw-semibold">Harvest</div><div class="text-muted">Collect host facts and selected files into a bundle.</div></div></div>
<div class="d-flex gap-3"><div class="icon-pill"><i class="bi bi-diagram-3"></i></div><div><div class="fw-semibold">Manifest</div><div class="text-muted">Turn that bundle into an Ansible repository-style output tree.</div></div></div>
<div class="d-flex gap-3"><div class="icon-pill"><i class="bi bi-plus-slash-minus"></i></div><div><div class="fw-semibold">Diff</div><div class="text-muted">Compare two harvests and optionally notify by webhook or email.</div></div></div>
<div class="d-flex gap-3"><div class="icon-pill"><i class="bi bi-patch-question"></i></div><div><div class="fw-semibold">Explain</div><div class="text-muted">Summarise what was included or excluded and why.</div></div></div>
<div class="d-flex gap-3"><div class="icon-pill"><i class="bi bi-check-square"></i></div><div><div class="fw-semibold">Validate</div><div class="text-muted">Check schema and artifact consistency before using a bundle.</div></div></div>
</div>
</div>
<div class="col-lg-7">
<div class="row g-3">
<div class="col-md-6">
<div class="card feature-card h-100">
<div class="card-body p-4">
<div class="fw-semibold mb-1">Safe-by-default harvesting</div>
<div class="text-muted">Enroll avoids likely secrets with a path denylist, content sniffing, and size caps - then lets you opt in to more aggressive collection when you're ready.</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card feature-card h-100">
<div class="card-body p-4">
<div class="fw-semibold mb-1">Multi-site without "shared role broke host2"</div>
<div class="text-muted">In <code>--fqdn</code> mode, roles are data-driven and host inventory decides what gets managed per host.</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card feature-card h-100">
<div class="card-body p-4">
<div class="fw-semibold mb-1">Remote over SSH</div>
<div class="text-muted">Harvest a remote host from your workstation, then manifest Ansible output locally.</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card feature-card h-100">
<div class="card-body p-4">
<div class="fw-semibold mb-1">Encrypt bundles at rest</div>
<div class="text-muted">Use <code>--sops</code> to store harvests/manifests as a single encrypted <code>.tar.gz.sops</code> file (GPG) for safer long-term storage as a DR strategy.</div>
</div>
</div>
</div>
</div>
<div class="callout p-4 mt-3">
<div class="fw-semibold mb-2"><i class="bi bi-lightning-charge"></i> Why sysadmins like it</div>
<div class="row g-3">
<div class="col-md-6 text-muted">• Rapid enrolling of existing infra into config management<br>• Tweak include/exclude paths as needed</div>
<div class="col-md-6 text-muted">• Capture what changed from package defaults<br><code>diff</code> mode detects and alerts about drift</div>
</div>
<div class="col-md-6"><div class="card feature-card h-100"><div class="card-body p-4"><div class="fw-semibold mb-1">Default secret avoidance</div><div class="text-muted">Likely private keys, credential assignments, tokens, secret paths, oversized files, and binary-looking files are avoided unless you opt into <code>--dangerous</code>.</div></div></div></div>
<div class="col-md-6"><div class="card feature-card h-100"><div class="card-body p-4"><div class="fw-semibold mb-1">Ansible output</div><div class="text-muted">Generated output includes roles, playbooks, requirements, defaults, templates, and host variables in <code>--fqdn</code> mode.</div></div></div></div>
<div class="col-md-6"><div class="card feature-card h-100"><div class="card-body p-4"><div class="fw-semibold mb-1">Grouped roles by default</div><div class="text-muted">Package/service snapshots are grouped by Debian Section or RPM Group where possible. Use <code>--no-common-roles</code> to keep one generated role per package or unit.</div></div></div></div>
<div class="col-md-6"><div class="card feature-card h-100"><div class="card-body p-4"><div class="fw-semibold mb-1">Multi-site mode</div><div class="text-muted"><code>--fqdn</code> moves host-specific state into Ansible inventory and implies <code>--no-common-roles</code>.</div></div></div></div>
<div class="col-md-6"><div class="card feature-card h-100"><div class="card-body p-4"><div class="fw-semibold mb-1">Remote over SSH</div><div class="text-muted">Run the harvest on another host through Paramiko/OpenSSH config support, then render the manifest locally.</div></div></div></div>
<div class="col-md-6"><div class="card feature-card h-100"><div class="card-body p-4"><div class="fw-semibold mb-1">SOPS bundles</div><div class="text-muted"><code>--sops</code> stores harvests and generated manifests as encrypted tarballs for safer at-rest storage.</div></div></div></div>
</div>
</div>
</div>
@ -148,236 +77,78 @@ og_type: "website"
<section class="py-5 bg-light" id="quickstart" aria-label="Quickstart">
<div class="container">
<div class="row align-items-end g-3 mb-3">
<div class="col-lg-7">
<h2 class="section-title display-6 fw-bold mb-2">Quickstart</h2>
</div>
<div class="col-lg-5 text-lg-end">
<a class="btn btn-outline-dark" href="https://git.mig5.net/mig5/enroll#readme" target="_blank" rel="noreferrer"><i class="bi bi-journal-text"></i> Full README</a>
</div>
<div class="col-lg-7"><h2 class="section-title display-6 fw-bold mb-2">Quickstart</h2><p class="text-secondary mb-0">Start with a local safe-mode harvest, inspect the generated output, then run Ansible when you are ready.</p></div>
<div class="col-lg-5 text-lg-end"><a class="btn btn-outline-dark" href="docs.html"><i class="bi bi-journal-text"></i> Full docs</a></div>
</div>
<ul class="nav nav-pills gap-2" id="qsTabs" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link active" id="qs1-tab" data-bs-toggle="tab" data-bs-target="#qs1" type="button" role="tab">Local</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="qs2-tab" data-bs-toggle="tab" data-bs-target="#qs2" type="button" role="tab">Remote</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="qs3-tab" data-bs-toggle="tab" data-bs-target="#qs3" type="button" role="tab">Multi-site</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="qs4-tab" data-bs-toggle="tab" data-bs-target="#qs4" type="button" role="tab">Diff</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="qs5-tab" data-bs-toggle="tab" data-bs-target="#qs5" type="button" role="tab">Explain</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="qs6-tab" data-bs-toggle="tab" data-bs-target="#qs6" type="button" role="tab">Validate</button></li>
<li class="nav-item" role="presentation"><button class="nav-link active" id="qs-local-tab" data-bs-toggle="tab" data-bs-target="#qs-local" type="button" role="tab">Local</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="qs-remote-tab" data-bs-toggle="tab" data-bs-target="#qs-remote" type="button" role="tab">Remote</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" id="qs-drift-tab" data-bs-toggle="tab" data-bs-target="#qs-drift" type="button" role="tab">Drift</button></li>
</ul>
<div class="tab-content mt-3">
<div class="tab-pane fade show active" id="qs1" role="tabpanel" aria-labelledby="qs1-tab">
<div class="row g-3">
<div class="col-lg-6">
<div class="codeblock">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#qs1code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="terminal mb-0"><code id="qs1code"># Harvest → Manifest in one go
enroll single-shot --harvest ./harvest --out ./ansible
# Then run Ansible locally
ansible-playbook -i "localhost," -c local ./ansible/playbook.yml</code></pre>
</div>
</div>
<div class="col-lg-6">
<div class="callout p-4 h-100">
<div class="fw-semibold mb-2">Good for</div>
<div class="text-muted">Disaster recovery snapshots, "make this one host reproducible", and carving a golden role set you'll refine over time.</div>
<hr>
<div class="smallprint">Want templates for structured configs? Install <a href="https://git.mig5.net/mig5/jinjaturtle" target="_blank" rel="noopener noreferrer">JinjaTurtle</a> and use <code>--jinjaturtle</code> (or let it auto-detect).</div>
</div>
</div>
</div>
<div class="tab-pane fade show active" id="qs-local" role="tabpanel">
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#quick-local"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="quick-local"><span class="prompt">$</span> enroll harvest --out /tmp/enroll-harvest
<span class="prompt">$</span> enroll validate /tmp/enroll-harvest
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
<span class="prompt">$</span> cd /tmp/enroll-ansible
<span class="prompt">$</span> ansible-galaxy collection install -r requirements.yml
<span class="prompt">$</span> ansible-playbook -i "localhost," -c local playbook.yml --check</code></pre></div>
</div>
<div class="tab-pane fade" id="qs2" role="tabpanel" aria-labelledby="qs2-tab">
<div class="codeblock">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#qs2code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="terminal mb-0"><code id="qs2code"># Remote harvest over SSH, then manifest locally
enroll single-shot \
--remote-host myhost.example.com \
--remote-user myuser \
--harvest /tmp/enroll-harvest \
--out ./ansible \
--fqdn myhost.example.com</code></pre>
</div>
<div class="smallprint mt-3">If you don't want/need sudo on the remote host, add <code>--no-sudo</code> (expect a less complete harvest). For remote sudo prompts use <code>--ask-become-pass</code>/<code>-K</code>. If your SSH private key is encrypted, use <code>--ask-key-passphrase</code> (interactive) or <code>--ssh-key-passphrase-env ENV_VAR</code> (non-interactive/CI).</div>
<div class="tab-pane fade" id="qs-remote" role="tabpanel">
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#quick-remote"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="quick-remote"><span class="prompt">$</span> enroll harvest \
--remote-host host.example.net \
--remote-user admin \
--remote-ssh-config ~/.ssh/config \
--out /tmp/enroll-harvest
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn host.example.net</code></pre></div>
</div>
<div class="tab-pane fade" id="qs3" role="tabpanel" aria-labelledby="qs3-tab">
<div class="codeblock">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#qs3code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="terminal mb-0"><code id="qs3code"># Multi-site mode: shared roles, host-specific state in inventory
enroll harvest --out /tmp/enroll-harvest
enroll manifest --harvest /tmp/enroll-harvest --out ./ansible --fqdn "$(hostname -f)"
# Run the per-host playbook
ansible-playbook ./ansible/playbooks/"$(hostname -f)".yml</code></pre>
</div>
<div class="smallprint mt-3">Rule of thumb: single-site for "one server, easy-to-read roles"; <code>--fqdn</code> for "many servers, high abstraction, fast adoption".</div>
</div>
<div class="tab-pane fade" id="qs4" role="tabpanel" aria-labelledby="qs4-tab">
<div class="codeblock">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#qs4code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="terminal mb-0"><code id="qs4code"># Compare two harvests and get a human-friendly report (ignoring noise)
enroll diff --old /path/to/harvestA --new /path/to/harvestB --format markdown \
--exclude-path /var/anacron \
--ignore-package-versions
# Send a webhook when differences are detected
enroll diff \
--old /path/to/harvestA \
--new /path/to/harvestB \
--webhook https://example.net/webhook \
--webhook-format json \
--webhook-header 'X-Enroll-Secret: ...' \
--ignore-package-versions \
--exit-code
# Ignore a path and changes to package versions, and optionally
# enforce the old state locally (requires ansible-playbook)
enroll diff --old /path/to/harvestA --new /path/to/harvestB \
--exclude-path /var/anacron \
--ignore-package-versions \
--enforce</code></pre>
</div>
<div class="smallprint mt-3">E-mail notifications are also supported. Run it on a systemd timer to alert to drift!</div>
</div>
<div class="tab-pane fade" id="qs5" role="tabpanel" aria-labelledby="qs5-tab">
<div class="codeblock">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#qs5code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="terminal mb-0"><code id="qs5code"># Explain what's in a harvest
enroll explain /path/to/harvest
# JSON format, and using a SOPS-encrypted harvest
enroll explain /path/to/harvest.sops \
--sops \
--format json
</code></pre>
</div>
<div class="smallprint mt-3">'explain' tells you why something was included, but also why something was <em>excluded</em>.</div>
</div>
<div class="tab-pane fade" id="qs6" role="tabpanel" aria-labelledby="qs6-tab">
<div class="codeblock">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#qs6code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="terminal mb-0"><code id="qs5code"># Validate a harvest is correct.
enroll validate /path/to/harvest
# Check against the latest published version of the state schema specification
enroll validate /path/to/harvest --schema https://enroll.sh/schema/state.schema.json
</code></pre>
</div>
<div class="smallprint mt-3">'validate' makes sure the harvest's state confirms to Enroll's <a href="/schema.html">state schema</a>, doesn't contain orphaned artifacts and isn't missing any artifacts needed by the state. By default, it checks against the schema packaged with Enroll, but you can also check against the latest version on this site.</div>
<div class="tab-pane fade" id="qs-drift" role="tabpanel">
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#quick-drift"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="quick-drift"><span class="prompt">$</span> enroll harvest --out /srv/enroll/baseline
<span class="prompt">$</span> enroll harvest --out /srv/enroll/current
<span class="prompt">$</span> enroll diff --old /srv/enroll/baseline --new /srv/enroll/current --format markdown --ignore-package-versions</code></pre></div>
</div>
</div>
</div>
</section>
<section class="py-5" id="demos" aria-label="Demos">
<section class="py-5" id="captures">
<div class="container">
<div class="row g-3 align-items-end mb-3">
<div class="col-lg-8">
<h2 class="section-title display-6 fw-bold mb-2">Demonstrations</h2>
</div>
</div>
<h2 class="section-title display-6 fw-bold mb-4">What Enroll tries to capture</h2>
<div class="row g-3">
<div class="col-md-6 col-lg-6">
<div class="card feature-card h-100">
<div class="card-body p-4">
<div class="fw-semibold mb-1">Harvest</div>
<div class="text-muted mb-3">Collect state into a bundle.</div>
<div class="asciicast" data-asciinema-id="765203"><script src="https://asciinema.org/a/765203.js" id="asciicast-765203" async="true"></script></div>
</div>
</div>
</div>
<div class="col-md-6 col-lg-6">
<div class="card feature-card h-100">
<div class="card-body p-4">
<div class="fw-semibold mb-1">Manifest</div>
<div class="text-muted mb-3">Render Ansible roles/playbooks.</div>
<div class="asciicast" data-asciinema-id="765204"><script src="https://asciinema.org/a/765204.js" id="asciicast-765204" async="true"></script></div>
</div>
</div>
</div>
<div class="col-md-4"><div class="feature-card p-4 h-100"><div class="fw-semibold mb-2">Packages and services</div><p class="text-secondary mb-0">Manual packages, service-linked packages, systemd enable/running state, and service-relevant changed or custom config.</p></div></div>
<div class="col-md-4"><div class="feature-card p-4 h-100"><div class="fw-semibold mb-2">Configuration files</div><p class="text-secondary mb-0">Changed package conffiles, unowned service files, miscellaneous <code>/etc</code>, APT/DNF/YUM config, selected symlinks, and explicit include paths.</p></div></div>
<div class="col-md-4"><div class="feature-card p-4 h-100"><div class="fw-semibold mb-2">Runtime and user state</div><p class="text-secondary mb-0">Non-system users, SSH public keys, Flatpak/Snap state, Docker/Podman images, writable sysctls, and fallback iptables/ipset state when persistent files are absent.</p></div></div>
</div>
<div class="row g-3">
<div class="col-md-6 col-lg-6">
<div class="card feature-card h-100">
<div class="card-body p-4">
<div class="fw-semibold mb-1">Single-shot</div>
<div class="text-muted mb-3">Harvest → Manifest in one command.</div>
<div class="asciicast" data-asciinema-id="765127"><script src="https://asciinema.org/a/765127.js" id="asciicast-765127" async="true"></script></div>
</div>
</div>
</div>
<div class="col-md-6 col-lg-6">
<div class="card feature-card h-100">
<div class="card-body p-4">
<div class="fw-semibold mb-1">Diff</div>
<div class="text-muted mb-3">Drift report + webhook/email notifications, or optionally <em>enforce</em> the previous state!</div>
<div class="asciicast" data-asciinema-id="767081"><script src="https://asciinema.org/a/767081.js" id="asciicast-767081" async="true"></script></div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="py-5 bg-light" id="install" aria-label="Install">
<section class="py-5 bg-light" id="commands">
<div class="container">
<div class="row g-3 align-items-end mb-3">
<div class="col-lg-8">
<h2 class="section-title display-6 fw-bold mb-2">Install</h2>
<p class="text-muted mb-0">Use your preferred packaging. An AppImage is also available.</p>
</div>
<div class="col-lg-4 text-lg-end">
<a class="btn btn-outline-dark" href="https://git.mig5.net/mig5/enroll#install" target="_blank" rel="noreferrer"><i class="bi bi-link-45deg"></i>Other install steps</a>
</div>
<h2 class="section-title display-6 fw-bold mb-4">Commands</h2>
<div class="row g-3">
<div class="col-md-4"><div class="card feature-card h-100"><div class="card-body p-4"><h3 class="h5 fw-bold"><code>harvest</code></h3><div class="text-muted mb-3">Collect host state into <code>state.json</code> and <code>artifacts/</code>.</div><a href="docs.html#harvest" class="btn btn-sm btn-outline-dark">Harvest docs</a></div></div></div>
<div class="col-md-4"><div class="card feature-card h-100"><div class="card-body p-4"><h3 class="h5 fw-bold"><code>manifest</code></h3><div class="text-muted mb-3">Validate a harvest and render Ansible output.</div><a href="docs.html#manifest" class="btn btn-sm btn-outline-dark">Manifest docs</a></div></div></div>
<div class="col-md-4"><div class="card feature-card h-100"><div class="card-body p-4"><h3 class="h5 fw-bold"><code>single-shot</code></h3><div class="text-muted mb-3">Run harvest and manifest in one command.</div><a href="docs.html#single-shot" class="btn btn-sm btn-outline-dark">Single-shot docs</a></div></div></div>
<div class="col-md-4"><div class="card feature-card h-100"><div class="card-body p-4"><h3 class="h5 fw-bold"><code>diff</code></h3><div class="text-muted mb-3">Compare harvests and send reports.</div><a href="docs.html#diff" class="btn btn-sm btn-outline-dark">Diff docs</a></div></div></div>
<div class="col-md-4"><div class="card feature-card h-100"><div class="card-body p-4"><h3 class="h5 fw-bold"><code>explain</code></h3><div class="text-muted mb-3">Explain what a harvest contains.</div><a href="docs.html#explain" class="btn btn-sm btn-outline-dark">Explain docs</a></div></div></div>
<div class="col-md-4"><div class="card feature-card h-100"><div class="card-body p-4"><h3 class="h5 fw-bold"><code>validate</code></h3><div class="text-muted mb-3">Check schema and bundle consistency.</div><a href="docs.html#validate" class="btn btn-sm btn-outline-dark">Validate docs</a></div></div></div>
</div>
</div>
</section>
<ul class="nav nav-pills gap-2" id="installTabs" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#inst1" type="button" role="tab">Debian/Ubuntu</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#inst2" type="button" role="tab">Fedora</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#inst3" type="button" role="tab">Pip</button></li>
</ul>
<div class="tab-content mt-3">
<div class="tab-pane fade show active" id="inst1" role="tabpanel">
<div class="codeblock">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#inst1code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="terminal mb-0"><code id="inst1code">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</code></pre>
</div>
<section class="py-5" id="safety">
<div class="container">
<div class="row g-4 align-items-start">
<div class="col-lg-6">
<h2 class="section-title display-6 fw-bold mb-3">Safe by default, not magic</h2>
<p class="text-secondary">Enroll avoids obvious secrets by default, validates harvest structure, freezes directory bundles into private temp trees before manifesting, rejects unknown SSH host keys, and warns when a root <code>PATH</code> looks unsafe.</p>
<p class="text-secondary mb-0">It cannot prove that a structurally valid harvest is semantically safe. Only apply manifests generated from harvests whose provenance you trust.</p>
</div>
<div class="tab-pane fade" id="inst2" role="tabpanel">
<div class="codeblock">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#inst2code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="terminal mb-0"><code id="inst2code">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</code></pre>
</div>
</div>
<div class="tab-pane fade" id="inst3" role="tabpanel">
<div class="codeblock">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#inst3code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="terminal mb-0"><code id="inst3code">pip install enroll
# or: pipx install enroll</code></pre>
<div class="col-lg-6">
<div class="alert alert-warning mb-0">
<div class="fw-semibold mb-2">Dangerous mode means dangerous</div>
<div>Use <code>--dangerous</code> only when you intentionally want to bypass likely-secret checks. Pair it with <code>--sops</code> or another appropriate at-rest encryption workflow whenever there is any doubt.</div>
</div>
</div>
</div>

View file

@ -1,253 +1,182 @@
---
title: "Docs"
html_title: "Enroll Docs"
description: "How Enroll works: harvest, manifest, modes, and configuration."
html_title: "Enroll documentation - Ansible host harvesting and manifesting"
description: "Documentation for the current Enroll CLI: harvest Linux host state and generate Ansible configuration-management code."
og_title: "Enroll documentation"
og_description: "Current Enroll docs for harvest, manifest, single-shot, diff, explain, validate, security, SOPS, and Ansible output."
og_type: "article"
---
<header class="py-5 hero">
<div class="container py-3">
<div class="kicker mb-3"><i class="bi bi-book"></i> Documentation</div>
<h1 class="display-6 fw-bold mb-2">How Enroll works</h1>
<p class="lead mb-0">The mental model, output modes, and the knobs you'll actually use day-to-day.</p>
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="row g-4 align-items-end">
<div class="col-lg-8">
<div class="kicker mb-3"><i class="bi bi-journal-text"></i> Documentation</div>
<h1 class="display-6 fw-bold mb-2">How Enroll works today</h1>
<p class="lead mb-0">Enroll harvests selected Linux host state, validates it, and renders Ansible configuration-management output. This documentation reflects the current Ansible-only CLI.</p>
</div>
<div class="col-lg-4 text-lg-end"><a class="btn btn-outline-dark" href="examples.html"><i class="bi bi-terminal"></i> Examples</a></div>
</div>
</div>
</header>
<main class="py-5">
<div class="container">
<div class="row g-4">
<div class="col-lg-3">
<div class="position-sticky" style="top: 96px;">
<div class="list-group">
<aside class="col-lg-3">
<div class="position-sticky" style="top: 6rem;">
<div class="list-group small shadow-sm">
<a class="list-group-item list-group-item-action" href="#install">Install</a>
<a class="list-group-item list-group-item-action" href="#model">Mental model</a>
<a class="list-group-item list-group-item-action" href="#harvest">How harvesting works</a>
<a class="list-group-item list-group-item-action" href="#schema">State schema</a>
<a class="list-group-item list-group-item-action" href="#modes">Single-site vs multi-site</a>
<a class="list-group-item list-group-item-action" href="#remote">Remote harvesting</a>
<a class="list-group-item list-group-item-action" href="#templates">JinjaTurtle templates</a>
<a class="list-group-item list-group-item-action" href="#config">Config file</a>
<a class="list-group-item list-group-item-action" href="#diff">Drift detection with <code>enroll diff</code></a>
<a class="list-group-item list-group-item-action" href="#explain">Why did Enroll include/exclude something? <code>enroll explain</code></a>
<a class="list-group-item list-group-item-action" href="#tips">Tips</a>
</div>
<div class="mt-3 small text-secondary">
Prefer the canonical docs?
<a class="link-secondary" href="https://git.mig5.net/mig5/enroll#readme" target="_blank" rel="noreferrer">README on Forgejo</a>.
<a class="list-group-item list-group-item-action" href="#modes">Output modes</a>
<a class="list-group-item list-group-item-action" href="#harvest">Harvest</a>
<a class="list-group-item list-group-item-action" href="#manifest">Manifest</a>
<a class="list-group-item list-group-item-action" href="#single-shot">Single-shot</a>
<a class="list-group-item list-group-item-action" href="#diff">Diff</a>
<a class="list-group-item list-group-item-action" href="#explain">Explain</a>
<a class="list-group-item list-group-item-action" href="#validate">Validate</a>
<a class="list-group-item list-group-item-action" href="#sensitive-data">Sensitive data</a>
<a class="list-group-item list-group-item-action" href="#jinjaturtle">JinjaTurtle</a>
<a class="list-group-item list-group-item-action" href="#config-file">Config file</a>
<a class="list-group-item list-group-item-action" href="#run-ansible">Run Ansible</a>
</div>
</div>
</div>
</aside>
<div class="col-lg-9">
<section id="install" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Install</h2>
<p class="text-secondary">Install from your preferred channel. The Python package requires Python 3.10 or newer.</p>
<div class="accordion" id="installAccordion">
<div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#install-pip">Pip / PipX</button></h3><div id="install-pip" class="accordion-collapse collapse show" data-bs-parent="#installAccordion"><div class="accordion-body"><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> pip install enroll
<span class="prompt">$</span> pipx install enroll</code></pre></div></div></div></div>
<div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#install-deb">Ubuntu / Debian apt repository</button></h3><div id="install-deb" class="accordion-collapse collapse" data-bs-parent="#installAccordion"><div class="accordion-body"><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> sudo mkdir -p /usr/share/keyrings
<span class="prompt">$</span> curl -fsSL https://mig5.net/static/mig5.asc | sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
<span class="prompt">$</span> 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
<span class="prompt">$</span> sudo apt update
<span class="prompt">$</span> sudo apt install enroll</code></pre></div></div></div></div>
<div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#install-rpm">Fedora / RPM-style repository</button></h3><div id="install-rpm" class="accordion-collapse collapse" data-bs-parent="#installAccordion"><div class="accordion-body"><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> sudo rpm --import https://mig5.net/static/mig5.asc
<span class="prompt">$</span> sudo tee /etc/yum.repos.d/mig5.repo &gt; /dev/null &lt;&lt; '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
<span class="prompt">$</span> sudo dnf upgrade --refresh
<span class="prompt">$</span> sudo dnf install enroll</code></pre></div></div></div></div>
<div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#install-dev">Development checkout</button></h3><div id="install-dev" class="accordion-collapse collapse" data-bs-parent="#installAccordion"><div class="accordion-body"><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> poetry install
<span class="prompt">$</span> poetry run enroll --help</code></pre></div></div></div></div>
</div>
</section>
<section id="model" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Mental model</h2>
<p class="text-secondary">Enroll is intentionally simple: it collects facts first, then renders Ansible from those facts.</p>
<p class="text-secondary">Enroll works in two main phases. <strong>Harvest</strong> collects host facts and relevant files into a bundle. <strong>Manifest</strong> turns that bundle into Ansible configuration-management code.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#docs-model"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="docs-model"><span class="prompt">$</span> enroll harvest --out ./harvest
<span class="prompt">$</span> enroll validate ./harvest
<span class="prompt">$</span> enroll manifest --harvest ./harvest --out ./ansible</code></pre></div>
<p class="small text-secondary mt-3 mb-0"><code>diff</code>, <code>explain</code>, and <code>validate</code> all operate on harvest bundles too.</p>
</section>
<section id="modes" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Output modes: single-site and multi-site</h2>
<div class="row g-3">
<div class="col-md-6">
<div class="feature-card p-4 h-100">
<div class="d-flex align-items-center gap-3 mb-2">
<div class="icon-pill"><i class="bi bi-archive"></i></div>
<div>
<div class="fw-semibold">1) Harvest</div>
<div class="small text-secondary">Snapshot state into a bundle</div>
</div>
</div>
<ul class="mb-0 small">
<li>Detect installed packages and services</li>
<li>Collect config that deviates from packaged defaults (where possible)</li>
<li>Grab relevant custom/unowned files in service dirs</li>
<li>Capture non-system users & SSH public keys, .bashrc files etc</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="feature-card p-4 h-100">
<div class="d-flex align-items-center gap-3 mb-2">
<div class="icon-pill"><i class="bi bi-boxes"></i></div>
<div>
<div class="fw-semibold">2) Manifest</div>
<div class="small text-secondary">Generate an Ansible repo structure</div>
</div>
</div>
<ul class="mb-0 small">
<li>Roles with files/templates and defaults</li>
<li>Playbooks to apply the captured state</li>
<li>Optional inventory structure for multi-host runs: each host gets its own playbook</li>
</ul>
</div>
</div>
</div>
<div class="terminal mt-4">
<div class="small text-secondary mb-2">Typical flow</div>
<pre class="mb-0"><code><span class="prompt">$</span> enroll harvest --out /tmp/enroll-harvest
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
<span class="prompt">$</span> ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml</code></pre>
<div class="col-md-6"><div class="callout p-4 h-100"><h3 class="h5 fw-bold">Single-site mode</h3><p class="small text-secondary">Default mode. Use it for one server or for a golden role set. Raw files live in each role's <code>files/</code>, and template variables live in <code>defaults/main.yml</code>.</p><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll manifest --harvest ./harvest --out ./ansible</code></pre></div></div></div>
<div class="col-md-6"><div class="callout p-4 h-100"><h3 class="h5 fw-bold">Multi-site mode</h3><p class="small text-secondary">Enable with <code>--fqdn</code>. Roles are generic; host-specific state lives under Ansible inventory <code>host_vars</code>. This mode implies <code>--no-common-roles</code>.</p><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll manifest --harvest ./harvest --out ./ansible --fqdn host.example.net</code></pre></div></div></div>
</div>
</section>
<section id="harvest" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">How harvesting works</h2>
<p class="text-secondary">At a high level, this is what happens when <code>enroll harvest</code> runs on a host:</p>
<ul class="mb-0 small">
<li>Detects the OS and its package backend (e.g dpkg vs rpm)</li>
<li>Detects what packages are installed</li>
<li>For each package, it tries to detect files in <code>/etc</code> that have been modified from the default that get shipped with the package.</li>
<li>It detects running/enabled services and timers via systemd. For each of these, it looks for the unit files, any 'drop-in' files, environment variable files, etc, as well as what executable it executes, and tries to map those systemd services to the packages it's already learned about earlier (that way, those 'packages' or future Ansible roles, can also be associated with 'handlers' in Ansible, to handle restart of the services if/when the configs change)</li>
<li>Aside from known packages already learned, it optimistically tries to capture extra system configuration in <code>/etc</code> that is common for config management. This is stuff like the apt or dnf configuration, crons, logrotate configs, networking settings, hosts files, etc.</li>
<li>For applications that commonly make use of symlinks (think Apache2 or Nginx's <code>sites-enabled</code> or <code>mods-enabled</code>), it notes what symlinks exist so that it can capture those in Ansible</li>
<li>It also looks for other snowflake stuff in <code>/etc</code> not associated with packages/services or other typical system config, and will put these into an <code>etc_custom</code> role.</li>
<li>Likewise, it looks in <code>/usr/local</code> for stuff, on the assumption that this is an area that custom apps/configs might've been placed in. These go into a <code>usr_local_custom</code> role.</li>
<li>It captures non-system user accounts, their group memberships and files such as their <code>.ssh/authorized_keys</code>, and <code>.bashrc</code>, <code>.profile</code>, <code>.bash_aliases</code>, <code>.bash_logout</code> if these files differ from the <code>skel</code> defaults</li>
<li>It takes into account anything the user set with <code>--exclude-path</code> or <code>--include-path</code>. For anything extra that is included, it will put these into an '<code>extra_paths</code>' role. The location could be anywhere e.g something in <code>/opt</code>, <code>/srv</code>, whatever you want.</li>
<li>It writes the state.json and captures the artifacts.</li>
<h2 class="section-title fw-bold"><code>enroll harvest</code></h2>
<p class="text-secondary">Harvest writes a bundle containing <code>state.json</code> and captured artifacts. It detects packages, services, changed config, unowned service files, non-system users and SSH public keys, miscellaneous <code>/etc</code>, selected symlinks, Flatpak/Snap state, Docker/Podman image metadata, <code>/usr/local</code> scripts/config, writable sysctls, and fallback iptables/ipset runtime state when persistent files are absent.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#docs-harvest"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="docs-harvest"><span class="prompt">$</span> enroll harvest --out /tmp/enroll-harvest
<span class="prompt">$</span> enroll harvest --remote-host host.example.net --remote-user admin --out /tmp/enroll-harvest
<span class="prompt">$</span> enroll harvest --out /tmp/enroll-harvest --include-path '/home/*/.bashrc' --exclude-path '/usr/local/bin/docker-*'</code></pre></div>
<h3 class="h5 fw-bold mt-4">Common flags</h3>
<ul class="text-secondary">
<li><code>--remote-host</code>, <code>--remote-user</code>, <code>--remote-port</code>, <code>--remote-ssh-config</code> for SSH harvesting.</li>
<li><code>--ask-become-pass</code> / <code>-K</code> for remote sudo prompts.</li>
<li><code>--ask-key-passphrase</code> or <code>--ssh-key-passphrase-env ENV_VAR</code> for encrypted SSH private keys.</li>
<li><code>--no-sudo</code> for a less complete remote harvest without sudo.</li>
<li><code>--include-path</code> and <code>--exclude-path</code> for plain paths, globs, <code>glob:</code>, <code>re:</code>, or <code>regex:</code> patterns. Excludes win over includes.</li>
<li><code>--dangerous</code> disables likely-secret filtering.</li>
<li><code>--sops GPG_FINGERPRINT...</code> writes an encrypted <code>harvest.tar.gz.sops</code>.</li>
<li><code>--assume-safe-path</code> skips the root unsafe-<code>PATH</code> prompt in trusted automation.</li>
</ul>
<br />
<p class="text-secondary">Other things to be aware of:</p>
<ul class="mb-0 small">
<li>You can use multiple invocations of <code>--exclude-path</code> to skip the bits you don't want. You also can always comment out from the playbook.yml or delete certain roles it generates once you've run the <code>enroll manifest</code>.</li>
<li>In terms of safety measures: it doesn't traverse into symlinks, and it has an 'IgnorePolicy' that makes it ignore most binary files (except GPG binary keys used with apt) - though if you specify certain paths with <code>--include-path</code> and use <code>--dangerous</code>, it will skip some policy statements such as what types of content to ignore.</li>
<li>It will skip files that are too large, and it also currently has a hardcoded cap of the number of files that it will harvest (4000 for <code>/etc</code>, <code>/usr/local/etc</code> and <code>/usr/local/bin</code>, and 500 files per 'role'), to avoid unintentional 'runaway' situations.</li>
<li>If you are using the 'remote' mode to harvest, and your remote user requires a password for sudo, you can pass in <code>--ask-become-pass</code> (or <code>-K</code>) and it will prompt for the password. If you forget, and remote requires password for sudo, it'll still fall back to prompting for a password, but will be a bit slower to do so.</li>
<li>If your SSH private key is encrypted, use <code>--ask-key-passphrase</code> to prompt up-front, or <code>--ssh-key-passphrase-env ENV_VAR</code> for non-interactive/CI runs. If neither is provided and Enroll detects an encrypted key in an interactive session, it will prompt on-demand. (The two key-passphrase flags are mutually exclusive.)</li>
</section>
<section id="manifest" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold"><code>enroll manifest</code></h2>
<p class="text-secondary">Manifest validates a harvest and renders Ansible output. It does not support <code>--target</code>; current Enroll has no Puppet or Salt renderer.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#docs-manifest"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="docs-manifest"><span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn host.example.net
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --no-common-roles</code></pre></div>
<div class="alert alert-warning mt-3"><strong>Trust warning:</strong> Enroll validates structure, but it cannot prove the desired state is safe. Only apply manifests generated from harvests whose provenance you trust.</div>
<h3 class="h5 fw-bold mt-4">Ansible output</h3>
<ul class="text-secondary">
<li>Single-site output contains <code>playbook.yml</code>, <code>roles/</code>, role files, defaults, templates, and <code>requirements.yml</code>.</li>
<li>Multi-site output uses host-specific inventory under <code>inventory/host_vars/&lt;fqdn&gt;/...</code> and playbooks under <code>playbooks/</code>.</li>
<li>Playbooks tag roles as <code>role_&lt;role_name&gt;</code>, such as <code>role_users</code> or <code>role_services</code>.</li>
</ul>
<div class="alert alert-secondary mt-3 mb-0">
<div class="fw-semibold">Does Enroll use Ansible community/galaxy roles?</div>
<div class="small mb-0">No, Enroll doesn't have any knowledge of Ansible Galaxy roles or community plugins. It generates all the roles itself. If you really want to use roles from the community, Enroll may not be the tool for you, other than perhaps to help get you started.</div>
<br />
<div class="small mb-0">Keep in mind that a lot of software config files are also good candidates for being Jinja templates with abstracted vars for separate hosts.</div>
<br />
<div class="small mb-0">Enroll does use my companion tool <a href="https://git.mig5.net/mig5/jinjaturtle" target="_blank" rel="noopener noreferrer">JinjaTurtle</a> if it's installed, but JinjaTurtle only recognises certain types of files (.ini style, .json, .xml, .yaml, .toml, but not special ones like Nginx or Apache conf files which have their own special syntax). When Enroll can't turn a config file into a template, it copies the raw file instead and uses it with <code>ansible.builtin.copy</code> in role tasks.</div>
</div>
</section>
<section id="schema" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">State schema</h2>
<p class="text-secondary">Enroll writes a <code>state.json</code> file describing what was harvested. The canonical definition of that file format is the JSON Schema below.</p>
<p class="text secondary">You can also validate a harvest state file against the schema by using <code>enroll validate /path/to/harvest</code>.</p>
<div class="callout p-4">
<div class="d-flex flex-wrap gap-2 align-items-center">
<a class="btn btn-sm btn-dark" href="schema.html"><i class="bi bi-eye"></i> View formatted schema</a>
<a class="btn btn-sm btn-outline-dark" href="schema/state.schema.json"><i class="bi bi-braces"></i> state.schema.json</a>
</div>
</div>
<section id="single-shot" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold"><code>enroll single-shot</code></h2>
<p class="text-secondary">Single-shot runs harvest then manifest in one command. It supports the relevant harvest and manifest flags, including remote SSH, include/exclude paths, <code>--dangerous</code>, <code>--sops</code>, <code>--fqdn</code>, <code>--no-common-roles</code>, and JinjaTurtle flags.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#docs-single"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="docs-single"><span class="prompt">$</span> enroll single-shot --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
<span class="prompt">$</span> enroll single-shot --remote-host host.example.net --remote-user admin --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn host.example.net</code></pre></div>
</section>
<section id="modes" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Single-site vs multi-site</h2>
<p class="text-secondary">Manifest output has two styles. Choose based on how you'll use the result.</p>
<div class="row g-3">
<div class="col-md-6">
<div class="callout p-4 h-100">
<div class="fw-semibold mb-2"><i class="bi bi-house"></i> Single-site (default)</div>
<p class="small mb-2">Best when you are enrolling one host, or you're producing a reusable "golden" role set that could be applied anywhere.</p>
<ul class="small mb-0">
<li>Roles are self-contained</li>
<li>Raw files live in each role's <code>files/</code></li>
<li>Template variables live in <code>defaults/main.yml</code></li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="callout p-4 h-100">
<div class="fw-semibold mb-2"><i class="bi bi-diagram-3"></i> Multi-site (<code>--fqdn</code>)</div>
<p class="small mb-2">Best when you want to enroll several existing servers quickly, especially if they differ.</p>
<ul class="small mb-0">
<li>Roles are shared; raw files live in host-specific inventory</li>
<li>Inventory decides what gets managed on each host (files/packages/services)</li>
<li>Non-templated files go under <code>inventory/host_vars/&lt;fqdn&gt;/&lt;role&gt;/.files</code></li>
</ul>
</div>
</div>
</div>
<div class="terminal mt-4">
<div class="small text-secondary mb-2">Multi-site example</div>
<pre class="mb-0"><code><span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
<span class="prompt">$</span> ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml</code></pre>
</div>
<div class="alert alert-secondary mt-3 mb-0">
<div class="fw-semibold">Tip: role tags</div>
<div class="small mb-0">Generated playbooks tag each role as <code>role_&lt;name&gt;</code> (e.g. <code>role_users</code>, <code>role_services</code>, <code>role_other</code>). You can target a subset with <code>ansible-playbook ... --tags role_users</code>.</div>
</div>
<section id="diff" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold"><code>enroll diff</code></h2>
<p class="text-secondary">Diff compares two harvest bundles and reports package, service, user, and managed-file drift. Inputs can be bundle directories, <code>state.json</code> paths, tarballs, or SOPS bundles when <code>--sops</code> is enabled.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#docs-diff"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="docs-diff"><span class="prompt">$</span> enroll diff --old ./baseline --new ./current --format markdown
<span class="prompt">$</span> enroll diff --old ./baseline --new ./current --ignore-package-versions --exclude-path /var/anacron
<span class="prompt">$</span> enroll diff --old ./baseline --new ./current --webhook https://example.net/hook --webhook-format json --webhook-header 'Authorization: Bearer ...'</code></pre></div>
<p class="small text-secondary mt-3 mb-0">Use <code>--exit-code</code> to return status 2 when differences exist. Use <code>--notify-always</code> to send webhook/email even when there are no differences.</p>
</section>
<section id="remote" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Remote harvesting over SSH</h2>
<p class="text-secondary">Run Enroll on your workstation, harvest a remote host over SSH. The harvest is pulled locally.</p>
<div class="terminal">
<pre class="mb-0"><code><span class="prompt">$</span> enroll harvest --remote-host myhost.example.com --remote-user myuser --out /tmp/enroll-harvest
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-manifest
# Alternatively, run both commands combined together with the 'single-shot' mode:
<span class="prompt">$</span> enroll single-shot --remote-host myhost.example.com --remote-user myuser \
--harvest /tmp/enroll-harvest --out /tmp/enroll-ansible \
--fqdn myhost.example.com
# Encrypted SSH key examples:
<span class="prompt">$</span> enroll harvest --remote-host myhost.example.com --remote-user myuser \
--ask-key-passphrase --out /tmp/enroll-harvest
<span class="prompt">$</span> export ENROLL_SSH_KEY_PASSPHRASE='correct horse battery staple'
<span class="prompt">$</span> enroll harvest --remote-host myhost.example.com --remote-user myuser \
--ssh-key-passphrase-env ENROLL_SSH_KEY_PASSPHRASE --out /tmp/enroll-harvest</code></pre>
</div>
<div class="alert alert-secondary mt-3">
<div class="fw-semibold">Tip</div>
<div class="small mb-0">If you don't want/need sudo on the remote side, add <code>--no-sudo</code>. However, be aware that you may get a more limited harvest depending on permissions.</div>
<br />
<div class="small mb-0">If your remote user requires a password for sudo, pass <code>--ask-become-pass</code> or <code>-K</code> and you'll be prompted to enter the password. If you forget, Enroll will still prompt for the password if it detects it's needed, but will be slightly slower to do so.</div>
<br />
<div class="small mb-0">If your SSH private key is encrypted, use <code>--ask-key-passphrase</code> to prompt up-front. For non-interactive/CI runs, use <code>--ssh-key-passphrase-env ENV_VAR</code>. If neither is set and Enroll detects an encrypted key in an interactive session, it'll still prompt on-demand.</div>
<br />
<div class="small mb-0">If your remote host requires additional SSH configuration that you've defined in your <code>~/.ssh/config</code>, pass <code>--remote-ssh-config ~/.ssh/config</code>. Enroll will understand how to translate the Host alias, IdentityFile, ProxyCommand, ConnectTimeout and AddressFamily values. You must still pass a value for <code>--remote-host</code> that matches the <code>Host</code> value of the entry in the SSH config file.</div>
</div>
<section id="explain" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold"><code>enroll explain</code></h2>
<p class="text-secondary">Explain produces a human or JSON summary of a harvest: package counts, roles, included reasons, excluded reasons, and example paths.</p>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll explain ./harvest
<span class="prompt">$</span> enroll explain ./harvest --format json --max-examples 25
<span class="prompt">$</span> enroll explain ./harvest.tar.gz.sops --sops</code></pre></div>
</section>
<section id="templates" class="scroll-mt-nav mb-5">
<section id="validate" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold"><code>enroll validate</code></h2>
<p class="text-secondary">Validate checks that <code>state.json</code> exists, is valid JSON, matches the vendored JSON Schema unless skipped, and references artifacts that actually exist. It also reports unreferenced files in <code>artifacts/</code>.</p>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll validate ./harvest
<span class="prompt">$</span> enroll validate ./harvest --format json --out validate.json
<span class="prompt">$</span> enroll validate ./harvest --fail-on-warnings
<span class="prompt">$</span> enroll validate ./harvest --schema ./state.schema.json
<span class="prompt">$</span> enroll validate ./harvest --schema https://enroll.sh/schema/state.schema.json --allow-remote-schema</code></pre></div>
<p class="small text-secondary mt-3 mb-0">Remote schemas are not fetched unless <code>--allow-remote-schema</code> is explicitly supplied. Use <code>--no-schema</code> to skip schema checks while keeping consistency checks.</p>
</section>
<section id="sensitive-data" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Sensitive data</h2>
<p class="text-secondary">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.</p>
<div class="alert alert-danger"><strong><code>--dangerous</code> disables those checks.</strong> 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 <code>--sops</code>.</div>
<p class="text-secondary">Value-less comment mentions such as <code># token</code> are tolerated so stock config files do not become impossible to harvest. A commented-out populated credential assignment is still treated as sensitive.</p>
</section>
<section id="jinjaturtle" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">JinjaTurtle integration</h2>
<p class="text-secondary">If <a href="https://git.mig5.net/mig5/jinjaturtle" target="_blank" rel="noopener noreferrer">JinjaTurtle</a> (one of my other projects) is installed, Enroll can also produce Jinja2 templates for ini/json/xml/toml-style config and extract variables cleanly into Ansible, instead of just storing the 'raw' files.</p>
<div class="row g-3">
<div class="col-md-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2">Modes</div>
<ul class="small mb-0">
<li><code>--jinjaturtle</code> to force on</li>
<li><code>--no-jinjaturtle</code> to force off</li>
<li>Default is auto</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2">Where variables land</div>
<ul class="small mb-0">
<li>Single-site: <code>roles/&lt;role&gt;/defaults/main.yml</code></li>
<li>Multi-site: <code>inventory/host_vars/&lt;fqdn&gt;/&lt;role&gt;.yml</code></li>
</ul>
</div>
</div>
</div>
<p class="text-secondary">If the <code>jinjaturtle</code> executable is on <code>PATH</code>, Enroll can turn supported config files into Ansible templates. The default mode uses JinjaTurtle when available. <code>--jinjaturtle</code> makes it required; <code>--no-jinjaturtle</code> disables it.</p>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll manifest --harvest ./harvest --out ./ansible --jinjaturtle
<span class="prompt">$</span> enroll manifest --harvest ./harvest --out ./ansible --no-jinjaturtle</code></pre></div>
<p class="small text-secondary mt-3 mb-0">Supported suffixes include INI, CFG, JSON, TOML, YAML, XML, repo files, and common systemd unit file suffixes. Templates live in role <code>templates/</code>; variables live in role defaults or host vars depending on output mode.</p>
</section>
<section id="config" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">INI config file</h2>
<p class="text-secondary">If you're repeating flags (include/exclude patterns, SOPS settings, etc.), store defaults in <code>enroll.ini</code> and keep your muscle memory intact.</p>
<div class="callout p-4 mb-3">
<div class="fw-semibold mb-1">Discovery order</div>
<div class="small text-secondary mb-0">You can pass <code>-c/--config</code>, set <code>ENROLL_CONFIG</code>, or let Enroll auto-discover <code>./enroll.ini</code>, <code>./.enroll.ini</code>, or <code>~/.config/enroll/enroll.ini</code>.</div>
</div>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ini-example"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="ini-example">[enroll]
# (future global flags may live here)
<section id="config-file" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Configuration file</h2>
<p class="text-secondary">Enroll can load INI-style defaults from <code>--config</code>, <code>ENROLL_CONFIG</code>, <code>$XDG_CONFIG_HOME/enroll/enroll.ini</code>, or <code>~/.config/enroll/enroll.ini</code>. Current-directory config files are not auto-loaded; pass them explicitly.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#docs-config"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="docs-config">[enroll]
assume_safe_path = false
[harvest]
dangerous = false
@ -255,324 +184,36 @@ 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
# remote_host = host.example.net
# remote_user = admin
[manifest]
no_jinjaturtle = true
sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
# sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
[diff]
# ignore noisy drift
exclude_path = /var/anacron
ignore_package_versions = true
# enforce = true # requires ansible-playbook on PATH</code></pre>
</div>
<div class="alert alert-secondary mt-3 mb-0">
<div class="fw-semibold">Note</div>
<div class="small mb-0">In INI sections, option names use underscores (e.g. <code>include_path</code>) even when the CLI flag uses hyphens (e.g. <code>--include-path</code>).</div>
</div>
[single-shot]
include_path = re:^/home/[^/]+/\.config/myapp/.*$</code></pre></div>
<p class="small text-secondary mt-3 mb-0">Precedence is explicit CLI flags, then INI config, then argparse defaults. For hyphenated flags, use underscores in the INI file.</p>
</section>
<section id="run-ansible" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Run generated Ansible</h2>
<p class="text-secondary">Install generated collection requirements first, then run in check mode before applying for real.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#docs-run-ansible"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="docs-run-ansible"><span class="prompt">$</span> cd /tmp/enroll-ansible
<span class="prompt">$</span> ansible-galaxy collection install -r requirements.yml
<span class="prompt">$</span> ansible-playbook -i "localhost," -c local playbook.yml --check
<span class="prompt">$</span> ansible-playbook -i "localhost," -c local playbook.yml
<section id="diff" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Drift detection with <code>enroll diff</code></h2>
<p class="text-secondary">One of the things I miss from my Puppet days, was the way the Puppet 'agent' would check in with the server and realign itself to the declared desired state. With Ansible, it's easy for systems to fall 'out of date', especially if someone is doing the wrong thing and changing things on-the-fly instead of via config management!</p>
<p class="text-secondary">The purpose of <code>enroll diff</code> is to compare two 'harvests' and detect what has changed - be it adding/removing of programs, change to systemd unit state, modifications, addition or removal of files, and so on.</p>
<div class="callout p-4 mb-3">
<div class="fw-semibold mb-1">Notifications for diff</div>
<div class="small text-secondary mb-0">The <code>enroll diff</code> feature supports sending the difference to a webhook of your choosing, or by e-mail. The payload can be sent in json, plain text, or markdown.</div>
</div>
<div class="callout p-4 mb-3">
<div class="fw-semibold mb-1">Noise suppression</div>
<div class="small text-secondary mb-0">Use <code>--exclude-path</code> to ignore file/dir drift under specific paths (e.g. <code>/var/anacron</code>). Use <code>--ignore-package-versions</code> to ignore routine package upgrades/downgrades while still reporting added/removed packages.</div>
</div>
<div class="terminal mb-4">
<pre class="mb-0"><code><span class="prompt">$</span> enroll diff \
--old /path/to/harvestA \
--new /path/to/harvestB \
--exclude-path /var/spool/anacron \
--ignore-package-versions</code></pre>
</div>
<div class="callout p-4 mb-3">
<div class="fw-semibold mb-1">Optional: enforce the old harvest state (<code>--enforce</code>)</div>
<div class="small text-secondary mb-0">If drift exists and <code>ansible-playbook</code> is on <code>PATH</code>, Enroll can generate a manifest from the <em>old</em> harvest and apply it locally to restore expected state. It avoids package downgrades, and will often run Ansible with <code>--tags role_...</code> so only the roles implicated by the drift are applied. This is very much like a return to Puppet's agent mode!</div>
</div>
<div class="terminal mb-4">
<pre class="mb-0"><code><span class="prompt">$</span> enroll diff \
--old /path/to/harvestA \
--new /path/to/harvestB \
--enforce
</code></pre>
</div>
<h3 class="section-title fw-bold">How to run <code>enroll diff</code> automatically on a timer</h3>
<p class="text-secondary">A great way to use <code>enroll diff</code> is to run it periodically (e.g via cron or a systemd timer). Below is an example.</p>
<p class="text-secondary">Store the below file at <code>/usr/local/bin/enroll-harvest-diff.sh</code> and make it executable.</p>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#enroll-harvest-diff"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="enroll-harvest-diff">#!/usr/bin/env bash
set -euo pipefail
# Required env
: "${WEBHOOK_URL:?Set WEBHOOK_URL in /etc/enroll/enroll-harvest-diff}"
: "${ENROLL_SECRET:?Set ENROLL_SECRET in /etc/enroll/enroll-harvest-diff}"
# Optional env
STATE_DIR="${ENROLL_STATE_DIR:-/var/lib/enroll}"
GOLDEN_DIR="${STATE_DIR}/golden"
PROMOTE_NEW="${PROMOTE_NEW:-1}" # 1=promote new->golden; 0=keep golden fixed
KEEP_BACKUPS="${KEEP_BACKUPS:-7}" # only used if PROMOTE_NEW=1
LOCKFILE="${STATE_DIR}/.enroll-harvest-diff.lock"
mkdir -p "${STATE_DIR}"
chmod 700 "${STATE_DIR}" || true
# single-instance lock (avoid overlapping timer runs)
exec 9>"${LOCKFILE}"
flock -n 9 || exit 0
tmp_new=""
cleanup() {
if [[ -n "${tmp_new}" && -d "${tmp_new}" ]]; then
rm -rf "${tmp_new}"
fi
}
trap cleanup EXIT
make_tmp_dir() {
mktemp -d "${STATE_DIR}/.harvest.XXXXXX"
}
run_harvest() {
local out_dir="$1"
rm -rf "${out_dir}"
mkdir -p "${out_dir}"
chmod 700 "${out_dir}" || true
enroll harvest --out "${out_dir}" >/dev/null
}
# A) create golden if missing
if [[ ! -f "${GOLDEN_DIR}/state.json" ]]; then
tmp="$(make_tmp_dir)"
run_harvest "${tmp}"
rm -rf "${GOLDEN_DIR}"
mv "${tmp}" "${GOLDEN_DIR}"
echo "Golden harvest created at ${GOLDEN_DIR}"
exit 0
fi
# B) create new harvest
tmp_new="$(make_tmp_dir)"
run_harvest "${tmp_new}"
# C) diff + webhook notify
enroll diff \
--old "${GOLDEN_DIR}" \
--new "${tmp_new}" \
--webhook "${WEBHOOK_URL}" \
--webhook-format json \
--webhook-header "X-Enroll-Secret: ${ENROLL_SECRET}" # You can send multiple --webhook-header params as you need
# Promote or discard new harvest
if [[ "${PROMOTE_NEW}" == "1" || "${PROMOTE_NEW,,}" == "true" || "${PROMOTE_NEW}" == "yes" ]]; then
ts="$(date -u +%Y%m%d-%H%M%S)"
backup="${STATE_DIR}/golden.prev.${ts}"
mv "${GOLDEN_DIR}" "${backup}"
mv "${tmp_new}" "${GOLDEN_DIR}"
tmp_new="" # don't delete it in trap
# Keep only latest N backups
if [[ "${KEEP_BACKUPS}" =~ ^[0-9]+$ ]] && (( KEEP_BACKUPS > 0 )); then
ls -1dt "${STATE_DIR}"/golden.prev.* 2>/dev/null | tail -n +"$((KEEP_BACKUPS+1))" | xargs -r rm -rf
fi
echo "Diff complete; baseline updated."
else
# tmp_new will be deleted by trap
echo "Diff complete; baseline unchanged (PROMOTE_NEW=${PROMOTE_NEW})."
fi
</code></pre>
</div>
<br />
<p class="text-secondary">Save these environment variables in <code>/etc/enroll/enroll-harvest-diff</code></p>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#enroll-harvest-diff-env"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="enroll-harvest-diff-env">
# Where to store golden + temp harvests
ENROLL_STATE_DIR=/var/lib/enroll
# 1 = each run becomes new baseline ("since last harvest")
# 0 = compare against a fixed baseline ("since golden")
PROMOTE_NEW=1
# If PROMOTE_NEW=1, keep this many old baselines
KEEP_BACKUPS=7
WEBHOOK_URL=https://example.com/webhook/xxxxxxxx
ENROLL_SECRET=xxxxxxxxxxxxxxxxxxxx
</code></pre>
</div>
<br />
<div class="callout p-4 mb-3">
<div class="fw-semibold mb-1">Webhook headers</div>
<div class="small text-secondary mb-0">The <code>--webhook-header</code> parameter can be used multiple times. You can, for example, send <code>X-Enroll-Secret</code> and a secret value of your choice, to help secure your webhook endpoint.</div>
</div>
<p class="text-secondary">Save this systemd unit file to <code>/etc/systemd/system/enroll-harvest-diff.service</code></p>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#enroll-harvest-diff-service"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="enroll-harvest-diff-service">
[Unit]
Description=Enroll harvest + diff + webhook notify
Wants=network-online.target
After=network-online.target
ConditionPathExists=/etc/enroll/enroll-harvest-diff
[Service]
Type=oneshot
EnvironmentFile=/etc/enroll/enroll-harvest-diff
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
UMask=0077
# Create /var/lib/enroll automatically
StateDirectory=enroll
ExecStart=/usr/local/bin/enroll-harvest-diff.sh
</code></pre>
</div>
<br />
<p class="text-secondary">Save this systemd timer to <code>/etc/systemd/system/enroll-harvest-diff.timer</code></p>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#enroll-harvest-diff-timer"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="enroll-harvest-diff-timer">
[Unit]
Description=Run Enroll harvest diff hourly
[Timer]
OnCalendar=hourly
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
</code></pre>
</div>
<br />
<p class="text-secondary">Now you can enable and test it!</p>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#enroll-harvest-diff-enable"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="enroll-harvest-diff-enable">
sudo systemctl daemon-reload
sudo systemctl enable --now enroll-harvest-diff.timer
# run once now
sudo systemctl start enroll-harvest-diff.service
# watch it in the logs
sudo journalctl -u enroll-harvest-diff.service -n 200 --no-pager
</code></pre>
</div>
<br />
<div class="callout p-4 mb-3">
<div class="fw-semibold mb-1">Need help with writing webhooks?</div>
<div class="small text-secondary mb-0">I use Node-RED. Here's a <a href="https://git.mig5.net/mig5/enroll/wiki/enroll-diff#example-node-red-webhook-receiver-flow" target="_blank" rel="noopener noreferrer">sample Node-RED flow</a> that might help run your webhook, pre-configured to parse the <code>enroll diff</code> JSON payload!</div>
</div>
# multi-site
<span class="prompt">$</span> ansible-playbook playbooks/host.example.net.yml --check
# targeted role tags
<span class="prompt">$</span> ansible-playbook -i "localhost," -c local playbook.yml --tags role_users,role_services</code></pre></div>
</section>
<section id="explain" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Why did Enroll include/exclude something? <code>enroll explain</code></h2>
<p class="text-secondary">When you run <code>enroll harvest</code>, Enroll records <em>why</em> it chose to include or exclude each path in <code>state.json</code>. The <code>enroll explain</code> subcommand summarizes that data so you can quickly sanity-check a harvest, tune include/exclude rules, and understand where packages/services came from.</p>
<div class="callout p-4 mb-3">
<div class="fw-semibold mb-1">What can it read?</div>
<div class="small text-secondary mb-0"><code>enroll explain</code> accepts a harvest bundle directory, a direct path to <code>state.json</code>, a <code>.tar.gz</code>/<code>.tgz</code> bundle, or an encrypted <code>.tar.gz.sops</code> bundle.</div>
</div>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#enroll-explain-basic"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="enroll-explain-basic"><span class="prompt">$</span> enroll explain /tmp/enroll-harvest
# or point at the state.json path directly
<span class="prompt">$</span> enroll explain /tmp/enroll-harvest/state.json</code></pre>
</div>
<br />
<p class="text-secondary">The default output is human-readable text. For scripting or deeper inspection, use JSON output:</p>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#enroll-explain-json"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="enroll-explain-json"><span class="prompt">$</span> enroll explain /tmp/enroll-harvest --format json | jq .
# show more example paths per reason
<span class="prompt">$</span> enroll explain /tmp/enroll-harvest --max-examples 10</code></pre>
</div>
<br />
<p class="text-secondary">If you stored a harvest as a single SOPS-encrypted bundle, <code>enroll explain</code> can decrypt it on the fly (it will also auto-detect files ending with <code>.sops</code>):</p>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#enroll-explain-sops"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="enroll-explain-sops"><span class="prompt">$</span> enroll explain /var/lib/enroll/harvest.tar.gz.sops --sops</code></pre>
</div>
<br />
<p class="text-secondary">What you get back:</p>
<ul class="mb-0 small">
<li>A summary of what roles were collected (users, services, package snapshots, <code>etc_custom</code>, <code>usr_local_custom</code>, etc.).</li>
<li>Why packages ended up in inventory (<code>observed_via</code>), e.g. user-installed vs referenced by a harvested systemd unit.</li>
<li>Breakdowns of <code>managed_files.reason</code>, <code>managed_dirs.reason</code>, and <code>excluded.reason</code>, with a few example paths for each reason.</li>
</ul>
<div class="alert alert-secondary mt-3 mb-0">
<div class="fw-semibold">Tip</div>
<div class="small mb-0">Use <code>enroll explain</code> after a first harvest to decide what to exclude (noise) and what to include (snowflake app/config under <code>/opt</code>, <code>/srv</code>, etc.) before you generate a manifest.</div>
<br />
<div class="small mb-0"><strong>Security note:</strong> <code>enroll explain</code> doesn't print file contents, but it can print path names and unit/package names. Treat the output as sensitive if your environment uses revealing path conventions (and especially if you harvested with <code>--dangerous</code>).</div>
</div>
</section>
<section id="tips" class="scroll-mt-nav">
<h2 class="section-title fw-bold">Tips</h2>
<div class="row g-3">
<div class="col-md-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2"><i class="bi bi-shield-check"></i> Start safe</div>
<p class="small text-secondary mb-0">Default harvesting tries to avoid likely secrets via path rules, content sniffing, and size caps. Use <code>--dangerous</code> only when you've planned where the output will live.</p>
</div>
</div>
<div class="col-md-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2"><i class="bi bi-lock"></i> Encrypt at rest</div>
<p class="small text-secondary mb-0">If you plan to keep harvests/manifests long term (especially in git), use <code>--sops</code> to produce a single encrypted bundle file. <strong>Note:</strong> <code>enroll diff</code> can be passed <code>--sops</code> to decrypt and compare two harvests on-the-fly!</p>
</div>
</div>
<div class="col-md-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2"><i class="bi bi-diagram-2"></i> Multi-host safety</div>
<p class="small text-secondary mb-0">For fleets, prefer multi-site output so roles stay generic and host inventory controls what is applied per host - reducing "shared role breaks other host" surprises.</p>
</div>
</div>
<div class="col-md-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2"><i class="bi bi-gear"></i> Keep it reproducible</div>
<p class="small text-secondary mb-0">Commit the manifest output, run it in CI, and use <code>enroll diff</code> as a drift alarm (webhook/email).</p>
</div>
</div>
</div>
</section>
</div>
</div>
</div>

View file

@ -1,142 +1,165 @@
---
title: "Examples"
html_title: "Enroll Examples"
description: "Copy/paste recipes for Enroll: one host, fleets, drift detection, and safe storage."
html_title: "Enroll examples - Ansible harvest and manifest recipes"
description: "Copy/paste recipes for current Enroll: Ansible output, remote harvests, drift detection, SOPS, and validation."
og_title: "Enroll examples"
og_description: "Practical Enroll recipes for local and remote harvests, Ansible manifesting, SOPS bundles, drift reports, and validation."
og_type: "article"
---
<header class="py-5 hero">
<div class="container py-3">
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="kicker mb-3"><i class="bi bi-terminal"></i> Examples</div>
<h1 class="display-6 fw-bold mb-2">Copy/paste recipes</h1>
<p class="lead mb-0">Practical flows you can adapt to your environment.</p>
<h1 class="display-6 fw-bold mb-2">Copy/paste Enroll recipes</h1>
<p class="lead mb-0">Current examples for Ansible output, remote harvests, safe storage, drift reports, and troubleshooting-friendly validation.</p>
</div>
</header>
<main class="py-5">
<div class="container">
<div class="row g-4">
<div class="col-lg-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2">Enroll a single host (local)</div>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-single-local"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="ex-single-local"><span class="prompt">$</span> enroll harvest --out /tmp/enroll-harvest
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest \
--out /tmp/enroll-ansible
<span class="prompt">$</span> ansible-playbook -i "localhost," -c local \
/tmp/enroll-ansible/playbook.yml --diff --check</code></pre>
</div>
<p class="small text-secondary mt-2 mb-0">Great for "make this box reproducible" or building a golden role set.</p>
</div>
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Local safe-mode baseline</h2>
<p class="text-secondary">Harvest the current host, validate the bundle, generate Ansible, install requirements, and run a check-mode playbook.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-local"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-local"><span class="prompt">$</span> enroll harvest --out /tmp/enroll-harvest
<span class="prompt">$</span> enroll validate /tmp/enroll-harvest
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
<span class="prompt">$</span> cd /tmp/enroll-ansible
<span class="prompt">$</span> ansible-galaxy collection install -r requirements.yml
<span class="prompt">$</span> ansible-playbook -i "localhost," -c local playbook.yml --check</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2">Enroll a remote host (over SSH)</div>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-remote"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="ex-remote"><span class="prompt">$</span> enroll harvest \
--remote-host myhost.example.com \
--remote-user myuser \
--out /tmp/enroll-harvest
<span class="prompt">$</span> enroll manifest \
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Remote host through SSH config</h2>
<p class="text-secondary">Use an OpenSSH config alias while still passing <code>--remote-host</code>. The harvested bundle lands locally.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-remote"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-remote"><span class="prompt">$</span> enroll harvest \
--remote-host production-web-1 \
--remote-ssh-config ~/.ssh/config \
--out /tmp/enroll-web-1
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-web-1 --out /tmp/enroll-web-1-ansible --fqdn web-1.example.net</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Encrypted SSH key in CI</h2>
<p class="text-secondary">Provide a key passphrase through an environment variable for non-interactive remote harvesting.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-key"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-key"><span class="prompt">$</span> export ENROLL_SSH_KEY_PASSPHRASE='correct horse battery staple'
<span class="prompt">$</span> enroll single-shot \
--remote-host host.example.net \
--remote-user admin \
--ssh-key-passphrase-env ENROLL_SSH_KEY_PASSPHRASE \
--harvest /tmp/enroll-harvest \
--out /tmp/enroll-ansible</code></pre>
</div>
<p class="small text-secondary mt-2 mb-0">No need to manually run commands on the server - your bundle lands locally. If your remote user needs a password for sudo, pass in <code>--ask-become-pass</code> or <code>-K</code>, just like in Ansible. If you don't want to use sudo, pass <code>--no-sudo</code>, but your harvest may contain less data.</p>
</div>
</div>
<div class="col-lg-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2">Fleets: multi-site output</div>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-multisite"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="ex-multisite"><span class="prompt">$</span> fqdn="$(hostname -f)"
<span class="prompt">$</span> enroll single-shot --remote-host "$fqdn" \
--remote-user myuser \
--out /tmp/enroll-ansible \
--fqdn "$fqdn"
<span class="prompt">$</span> ansible-playbook "/tmp/enroll-ansible/playbooks/${fqdn}.yml"</code></pre>
</div>
<p class="small text-secondary mt-2 mb-0">Shared roles + host inventory keeps one host's differences from breaking another.</p>
</div>
--fqdn host.example.net</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2">Drift detection with <code>enroll diff</code></div>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-diff"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="ex-diff"><span class="prompt">$</span> enroll diff --old /path/to/harvestA --new /path/to/harvestB --format markdown --exclude-path /var/anacron --ignore-package-versions
<span class="prompt">$</span> enroll diff --old /path/to/golden --new /path/to/current \
--webhook https://example.net/webhook \
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Include and exclude paths</h2>
<p class="text-secondary">Add targeted files outside the standard scan paths while keeping safe-mode secret checks enabled.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-paths"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-paths"><span class="prompt">$</span> enroll harvest --out /tmp/enroll-harvest \
--include-path '/home/*/.bashrc' \
--include-path 're:^/home/[^/]+/\.config/myapp/.*$' \
--exclude-path '/home/*/.config/myapp/cache/**'</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Dangerous harvest, encrypted at rest</h2>
<p class="text-secondary">When you deliberately need aggressive capture, encrypt the harvest bundle immediately.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-sops-harvest"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-sops-harvest"><span class="prompt">$</span> enroll harvest \
--out /srv/enroll/host-1 \
--dangerous \
--sops 54A91143AE0AB4F7743B01FE888ED1B423A3BC99</code></pre></div>
<p class="small text-secondary mt-2 mb-0">This writes an encrypted <code>harvest.tar.gz.sops</code> rather than a plaintext bundle directory.</p>
</section>
</div>
<div class="col-lg-6">
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Encrypted manifest bundle</h2>
<p class="text-secondary">Generate Ansible into a temporary directory, tar it, and encrypt it as one SOPS file.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-sops-manifest"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-sops-manifest"><span class="prompt">$</span> enroll manifest \
--harvest /srv/enroll/host-1/harvest.tar.gz.sops \
--out /srv/enroll/host-1-manifest \
--sops 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
<span class="prompt">$</span> cd /srv/enroll/host-1-manifest
<span class="prompt">$</span> sops -d manifest.tar.gz.sops | tar -xzvf -
<span class="prompt">$</span> cd manifest</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Multi-site output</h2>
<p class="text-secondary">Use <code>--fqdn</code> when enrolling multiple already-running servers. Host-specific data goes to inventory host vars.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-fqdn"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-fqdn"><span class="prompt">$</span> fqdn="$(hostname -f)"
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$fqdn"
<span class="prompt">$</span> ansible-playbook /tmp/enroll-ansible/playbooks/"$fqdn".yml --check</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Targeted Ansible role runs</h2>
<p class="text-secondary">Generated playbooks tag roles as <code>role_&lt;name&gt;</code>, so you can run only a subset.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-tags"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-tags"><span class="prompt">$</span> ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_users,role_services</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Drift report for cron or CI</h2>
<p class="text-secondary">Ignore routine version churn while still catching package additions/removals, service changes, user changes, and file drift.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-diff"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-diff"><span class="prompt">$</span> enroll diff \
--old /srv/enroll/baseline \
--new /srv/enroll/current \
--format json \
--ignore-package-versions \
--exclude-path /var/anacron \
--exit-code</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Webhook notification</h2>
<p class="text-secondary">Send the diff report when changes are detected. Use <code>--notify-always</code> when a heartbeat is useful.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-webhook"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-webhook"><span class="prompt">$</span> enroll diff \
--old /srv/enroll/baseline \
--new /srv/enroll/current \
--webhook https://example.net/hooks/enroll \
--webhook-format json \
--webhook-header 'X-Enroll-Secret: ...' \
--ignore-package-versions --exit-code
</code></pre>
</div>
<p class="small text-secondary mt-2 mb-0">Use it in cron or CI to alert on change.</p>
</div>
--webhook-header 'X-Enroll-Secret: xxxx'</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2">Explain a harvest with <code>enroll explain</code></div>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-explain"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="ex-explain"><span class="prompt">$</span> enroll explain /tmp/enroll-harvest
# machine-readable (reasons, examples, inventory breakdown)
<span class="prompt">$</span> enroll explain /tmp/enroll-harvest --format json | jq .
# encrypted bundle
<span class="prompt">$</span> enroll explain /var/lib/enroll/harvest.tar.gz.sops --sops</code></pre>
</div>
<p class="small text-secondary mt-2 mb-0">Great for answering "why did it include/exclude that file?" before you generate a manifest.</p>
</div>
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Explain a harvest</h2>
<p class="text-secondary">Use explain when you want to understand why Enroll included or skipped things.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-explain"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-explain"><span class="prompt">$</span> enroll explain /tmp/enroll-harvest
<span class="prompt">$</span> enroll explain /tmp/enroll-harvest --format json --max-examples 25</code></pre></div>
</section>
</div>
<div class="col-lg-6">
<div class="feature-card p-4 h-100">
<div class="fw-semibold mb-2">Enforce the previous state with <code>enroll diff --enforce</code></div>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-diff"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="ex-diff"><span class="prompt">$</span> enroll diff \
--old /path/to/harvestA \
--new /path/to/harvestB \
--enforce \
--format json
</code></pre>
</div>
<p class="small text-secondary mt-2 mb-0">Enforcing the old harvest will restore its files/perms, missing packages, changed services or users, if <code>ansible-playbook</code> is on the PATH.</p>
</div>
<section class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Validate with a pinned schema</h2>
<p class="text-secondary">CI can validate a harvested bundle against a local schema file and fail on warnings.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#ex-validate"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="ex-validate"><span class="prompt">$</span> enroll validate /srv/enroll/current \
--schema ./state.schema.json \
--format json \
--out validate.json \
--fail-on-warnings</code></pre></div>
</section>
</div>
</div>
<hr class="my-5">
<div class="row g-4">
<div class="col-lg-6">
<div class="callout p-4 h-100">
<div class="fw-semibold mb-2"><i class="bi bi-shield-check"></i> Safe harvesting (default)</div>
<p class="small text-secondary mb-3">Enroll tries to avoid harvesting files that might contain secrets. If you need to capture "everything", pass <code>--dangerous</code> and treat the output as sensitive.</p>
<p class="small text-secondary mb-3">You can still control what gets collected and what doesn't by using <code>--include</code> and <code>--exclude</code> flags.</p>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll harvest --dangerous --out /tmp/enroll-harvest</code></pre></div>
</div>
</div>
<div class="col-lg-6">
<div class="callout p-4 h-100">
<div class="fw-semibold mb-2"><i class="bi bi-lock"></i> Encrypt bundles at rest (SOPS)</div>
<p class="small text-secondary mb-3">Produce a single encrypted file for harvest and/or manifest output (requires SOPS to be installed).</p>
<p class="small text-secondary mb-3">This is especially a good idea if you are using <code>--dangerous</code>, which might sweep up secrets (see above).</p>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll harvest --dangerous --out /tmp/harvest \
--sops &lt;FINGERPRINT&gt;
<span class="prompt">$</span> enroll manifest --harvest /tmp/harvest/harvest.tar.gz.sops \
--out /tmp/enroll-ansible --sops &lt;FINGERPRINT&gt;</code></pre></div>
</div>
</div>
</div>
</div>
</main>

View file

@ -0,0 +1,62 @@
---
title: "Enroll 0.7.0: stronger safety tooling and new features"
date: "2026-06-30"
description: "Enroll 0.7.0 has much more robust defenses against malicious harvested data and what gets manifested into Ansible."
summary: "Enroll 0.7.0 focuses on making Enroll very robust against many forms of attack, introduces 'combined' roles for faster and simpler execution, and adds Docker/Flatpak/Snap detection."
html_title: "Enroll 0.7.0 release notes"
og_title: "Enroll 0.7.0 release notes"
og_description: "0.7.0 focuses on making Enroll very robust against many forms of attack, introduces 'combined' roles for faster and simpler execution, and adds Docker/Flatpak/Snap detection."
og_type: "article"
---
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="kicker mb-3"><i class="bi bi-megaphone"></i> News</div>
<h1 class="display-6 fw-bold mb-2">Enroll 0.7.0: stronger safety tooling and other improvements</h1>
<p class="lead mb-0">The new release focuses on hardening Enroll's defenses against various types of attack/content injection, along with some other new features and improvements.</p>
</div>
</header>
<main class="py-5">
<div class="container">
<article class="mx-auto" style="max-width: 900px;">
<h2 class="h3 fw-bold mt-5">What Enroll is doing</h2>
<ul class="text-secondary">
<li><strong>Harvest bundles</strong> with <code>state.json</code> and captured artifacts.</li>
<li><strong>Ansible manifesting</strong> in single-site mode or <code>--fqdn</code> multi-site mode.</li>
<li><strong>Role grouping</strong> by package section/group where possible, with <code>--no-common-roles</code> available when you prefer one generated role per package or unit.</li>
<li><strong>Remote harvesting</strong> over SSH, including OpenSSH config support, sudo prompts, and encrypted key passphrase handling.</li>
<li><strong>Diff, explain, and validate</strong> workflows for drift reporting and bundle inspection.</li>
<li><strong>SOPS at-rest encryption</strong> for harvest and manifest bundles.</li>
<li><strong>JinjaTurtle integration</strong> for supported Ansible templates when the executable is available.</li>
</ul>
<h2 class="h3 fw-bold mt-5">A current workflow</h2>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#news-workflow"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="news-workflow"><span class="prompt">$</span> enroll harvest --out ./harvest
<span class="prompt">$</span> enroll validate ./harvest
<span class="prompt">$</span> enroll manifest --harvest ./harvest --out ./ansible
<span class="prompt">$</span> cd ./ansible
<span class="prompt">$</span> ansible-galaxy collection install -r requirements.yml
<span class="prompt">$</span> ansible-playbook -i "localhost," -c local playbook.yml --check</code></pre></div>
<h2 class="h3 fw-bold mt-5">Security and provenance changes</h2>
<p class="text-secondary">Version 0.7.0 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 <code>PATH</code> foot-guns. It still cannot prove that a harvested desired state is semantically safe to apply.</p>
<p class="text-secondary">When manifesting from a harvest, you'll see a message like this:</p>
<blockquote class="blockquote border-start ps-3 text-secondary">This harvest is structurally valid, but Enroll cannot prove it is semantically safe. Only apply manifests generated from harvests whose provenance you trust.</blockquote>
<h2 class="h3 fw-bold mt-5">Other notable changes</h2>
<ul class="text-secondary">
<li>In 'single site' mode, Enroll now combines packages/configs into 'common' roles where the 'Section' name of that software is common. This makes Ansible faster to run, and is easier to digest cognitively (you might now get 20 roles instead of 200!). This behaviour can be disabled with <code>--no-common-roles</code> or when using the <code>--fqdn</code> mode.</li>
<li>Enroll now tries to detect Docker images on your system. This requires the docker galaxy role in Ansible. It will enforce that the specific SHA256 of that image is present (not floating tagsZ), when running Ansible.</li>
<li>Enroll now tries to detect system and user-level Flatpaks, as well as Snaps.</li>
<li>Safe-mode content scanning tolerates value-less credential words in comments, but populated credential assignments are still treated as sensitive even if commented out.</li>
<li>Automatic per-user shell dotfile harvesting is only enabled in <code>--dangerous</code> mode.</li>
<li>Plain directory harvests are frozen into a private temp tree before manifesting, reducing TOCTOU exposure between validation and rendering.</li>
<li>Remote harvests reject unknown SSH host keys by default.</li>
<li>Enroll now implicitly 'validates' a harvest before trying to manifest it to Ansible code.</li>
<li>Root runs refuse unsafe non-interactive <code>PATH</code> setups unless <code>--assume-safe-path</code> is supplied.</li>
<li>Enroll will not want to harvest or manifest to a directory that is writable by others. It will try to make the harvest or manifest readable only by the user that is executing the command. This is in an effort to resist tampering attacks.</li>
<li>Enroll's <code>--enforce</code> argument to <code>enroll diff</code> has been removed. Non-interactive application of manifests is considered too dangerous by the maintainer, despite all the additional hardening of the harvesting described above. If you really want to enforce the previous harvest, you can still code up an <code>enroll manifest [...] && ansible-playbook [....]</code> yourself when detecting that <code>enroll diff</code> sees changes between two harvests.</li>
</ul>
</article>
</div>
</main>

View file

@ -0,0 +1,30 @@
---
title: "Enroll 0.8.0: more hardening"
date: "2026-07-14"
description: "Enroll 0.8.0 adds more hardening to prevent obscure issues."
summary: "Enroll 0.8.0 builds on the 0.7.0 release by adding several more hardening measures in an effort to keep it as safe as possible to use."
html_title: "Enroll 0.8.0 release notes"
og_title: "Enroll 0.8.0 release notes"
og_description: "Enroll 0.8.0 builds on the 0.7.0 release by adding several more hardening measures in an effort to keep it as safe as possible to use."
og_type: "article"
---
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="kicker mb-3"><i class="bi bi-megaphone"></i> News</div>
<h1 class="display-6 fw-bold mb-2">Enroll 0.8.0: more hardening</h1>
<p class="lead mb-0">0.8.0 builds on the 0.7.0 release by adding several more hardening measures in an effort to keep it as safe as possible to use.</p>
</div>
</header>
<main class="py-5">
<div class="container">
<article class="mx-auto" style="max-width: 900px;">
<h2 class="h3 fw-bold mt-5">Changelog</h2>
<ul class="text-secondary">
<li>Keep sudo-created remote harvest bundles root-owned while root packages and hashes them, expose only the archive to the authenticated SSH uid, and verify the root-computed digest after download. This removes a tiny post-harvest tampering window created by recursively chowning the bundle before packaging, yet without making the plaintext archive world-readable.</li>
<li>Enforce tar member limits while lazily parsing untrusted archives rather than after `TarFile.getmembers()` has already indexed the entire archive; count repeated `.` entries and cap remote compressed downloads as well.</li>
<li>Apply aggregate byte and total filesystem-entry limits when freezing directory harvest bundles, reject symlinked bundle roots, and abort when files or discovered directories change during the copy, so direct directory inputs remain bounded and fail closed under mutation.</li>
</ul>
</article>
</div>
</main>

View file

@ -0,0 +1,15 @@
---
title: "News"
html_title: "Enroll news"
description: "News and release notes for Enroll."
og_title: "Enroll news"
og_description: "News and release notes for Enroll."
og_type: "website"
---
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="kicker mb-3"><i class="bi bi-megaphone"></i> News</div>
<h1 class="display-6 fw-bold mb-2">Enroll news</h1>
<p class="lead mb-0">Release notes and project notes for the current Enroll CLI.</p>
</div>
</header>

View file

@ -1,14 +1,17 @@
---
title: "Schema"
html_title: "Enroll State Schema"
description: "JSON Schema describing the Enroll harvest state.json format."
layout: "schema"
html_title: "Enroll state.json schema"
description: "The current JSON Schema for Enroll harvest state.json bundles."
og_title: "Enroll state.json schema"
og_description: "View the current Enroll JSON Schema and use it to validate harvest bundles."
og_type: "article"
---
<header class="py-5 hero">
<div class="container py-3">
<div class="kicker mb-3"><i class="bi bi-braces"></i> Schema</div>
<h1 class="display-6 fw-bold mb-2">Harvest <code>state.json</code> schema</h1>
<p class="lead mb-0"><code>enroll harvest</code> generates a state file. This is its structure.</p>
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="kicker mb-3"><i class="bi bi-braces"></i> Harvest schema</div>
<h1 class="display-6 fw-bold mb-2">Current <code>state.json</code> schema</h1>
<p class="lead mb-0">Enroll validates harvest bundles against the vendored JSON Schema unless you pass <code>--no-schema</code>. The same schema is published here for inspection and CI pinning.</p>
</div>
</header>
@ -16,35 +19,20 @@ layout: "schema"
<div class="container">
<div class="row g-4">
<div class="col-lg-4">
<div class="callout p-4 mb-2">
<div class="fw-semibold mb-2">Tips</div>
<div class="d-grid gap-2">
<p class="secondary-text">You can validate a harvest against the schema by running <code>enroll validate /path/to/harvest</code> .</p>
</div>
</div>
<div class="callout p-4">
<div class="fw-semibold mb-2">Links</div>
<div class="d-grid gap-2">
<a class="btn btn-sm btn-outline-dark" href="schema/state.schema.json"><i class="bi bi-download"></i> Raw JSON Schema</a>
<a class="btn btn-sm btn-outline-dark" href="docs.html#schema"><i class="bi bi-book"></i> Docs section</a>
</div>
<hr class="my-3">
<div class="feature-card p-4 h-100">
<h2 class="h4 fw-bold">Validate with the vendored schema</h2>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll validate ./harvest</code></pre></div>
<h2 class="h4 fw-bold mt-4">Validate with this hosted schema</h2>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll validate ./harvest \
--schema https://enroll.sh/schema/state.schema.json \
--allow-remote-schema</code></pre></div>
<p class="small text-secondary mt-3 mb-0">Remote schema fetching is disabled by default and must be explicitly allowed.</p>
</div>
</div>
<div class="col-lg-8">
<div class="feature-card p-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h2 class="h4 fw-bold mb-0">state.schema.json</h2>
<a class="btn btn-sm btn-outline-secondary" href="schema/state.schema.json" target="_blank" rel="noreferrer"><i class="bi bi-box-arrow-up-right"></i> Open raw</a>
</div>
<div class="codeblock terminal mt-3">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#schema-code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="schema-code">Loading…</code></pre>
</div>
<div class="small text-secondary mt-3">Tip: you can validate a harvest with <code>python -m jsonschema -i state.json schema/state.schema.json</code></div>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#schema-code"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="schema-code">Loading schema...</code></pre>
</div>
</div>
</div>

View file

@ -1,110 +1,91 @@
---
title: "Security Design"
html_title: "Enroll Security"
description: "Security posture and safe workflows for Enroll outputs."
html_title: "Enroll security design and threat model"
description: "Threat model and security scope for Enroll, a privileged Linux host harvesting CLI that generates Ansible output."
og_title: "Enroll security design"
og_description: "Enroll threat model, trusted harvest guidance, in-scope hardening, and vulnerability report guidance."
og_type: "article"
---
<header class="py-5 hero">
<div class="container py-3">
<div class="kicker mb-3"><i class="bi bi-shield-lock"></i> Security</div>
<h1 class="display-6 fw-bold mb-2">Safe by default. Powerful when you opt in.</h1>
<p class="lead mb-0">Enroll can touch sensitive files. This page helps you use it confidently.</p>
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="kicker mb-3"><i class="bi bi-shield-lock"></i> Security design</div>
<h1 class="display-6 fw-bold mb-2">Threat model and security scope</h1>
<p class="lead mb-0">Enroll is a privileged systems-administration CLI, not a sandbox. It tries to protect careful administrators from common mistakes, while assuming the operator controls the environment they run it in.</p>
</div>
</header>
<main class="py-5">
<div class="container">
<div class="row g-4">
<div class="col-lg-8">
<div class="feature-card p-4">
<h2 class="h4 fw-bold mb-2">Default behavior</h2>
<p class="text-secondary mb-0">In normal mode, Enroll attempts to avoid harvesting likely secrets using a combination of path deny-lists, content sniffing, and size caps. This means you may see some files intentionally skipped.</p>
</div>
<div class="feature-card p-4 mt-4 border border-warning">
<div class="d-flex align-items-start gap-3">
<div class="icon-pill" style="background: rgba(255,193,7,0.20); border-color: rgba(255,193,7,0.45);"><i class="bi bi-exclamation-triangle"></i></div>
<div>
<h2 class="h4 fw-bold mb-2">The <code>--dangerous</code> flag</h2>
<p class="mb-2">This disables secret-safety checks. It can copy private keys, API tokens, DB passwords, TLS key material, etc.</p>
<p class="small text-secondary mb-0"><strong>Rule:</strong> if you use <code>--dangerous</code>, treat the output as sensitive data and plan secure storage before you run it. Don't store secrets in plaintext in a public place!</p>
</div>
<aside class="col-lg-3">
<div class="position-sticky" style="top: 6rem;">
<div class="list-group small shadow-sm">
<a class="list-group-item list-group-item-action" href="#assumptions">Core assumptions</a>
<a class="list-group-item list-group-item-action" href="#in-scope">In scope</a>
<a class="list-group-item list-group-item-action" href="#out-of-scope">Out of scope</a>
<a class="list-group-item list-group-item-action" href="#trusted-harvests">Trusted harvests</a>
<a class="list-group-item list-group-item-action" href="#reporting">Report guidance</a>
</div>
</div>
<div class="feature-card p-4 mt-4">
<h2 class="h4 fw-bold mb-2">Encrypt bundles at rest with SOPS</h2>
<p class="text-secondary">You can install <a href="https://github.com/getsops/sops" target="_blank" rel="noopener noreferrer">SOPS</a> on your <code>$PATH</code>, then use <code>--sops</code> to write a single encrypted <code>.tar.gz.sops</code> file for harvests and/or manifests. This is meant for storage-at-rest and backups.</p>
<div class="codeblock terminal">
<button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#sec-sops"><i class="bi bi-clipboard"></i> Copy</button>
<pre class="mb-0"><code id="sec-sops"><span class="prompt">$</span> enroll harvest --out /tmp/enroll-harvest --dangerous --sops &lt;FINGERPRINT&gt;
<span class="prompt">$</span> enroll manifest --harvest /tmp/enroll-harvest/harvest.tar.gz.sops \
--out /tmp/enroll-ansible --sops &lt;FINGERPRINT&gt;</code></pre>
</div>
<div class="alert alert-secondary mt-3 mb-0">
<div class="fw-semibold">Important</div>
<div class="small mb-0">In manifest <code>--sops</code> mode, you'll need to decrypt and extract the bundle before running <code>ansible-playbook</code>.</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="callout p-4">
<div class="fw-semibold mb-2">Recommended workflow</div>
<ol class="small mb-0">
<li>Start with default mode (no <code>--dangerous</code>).</li>
<li>Add <code>--include-path</code> for a small set of extra files you genuinely want managed.</li>
<li>If you must capture secrets, use <code>--dangerous</code> <strong>and</strong> <code>--sops</code>.</li>
<li>Keep outputs out of public repos; review before committing.</li>
<li>Rotate credentials if you ever suspect they were captured or exposed.</li>
</ol>
</div>
<div class="callout p-4 mt-3">
<div class="fw-semibold mb-2">Storage ideas</div>
<ul class="small mb-0">
<li>Encrypted SOPS bundle stored in a password manager vault</li>
<li>Private git repo with additional encryption at rest</li>
<li>Offline backup in an encrypted volume</li>
</aside>
<div class="col-lg-9">
<section id="assumptions" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Core assumptions</h2>
<p class="text-secondary">Enroll is designed to be executed intentionally by a system administrator, often as root, to inspect a host and generate Ansible output. If an attacker controls the administrator's command line, environment, config file, working directory, <code>PATH</code>, SSH config, SOPS binary, Ansible installation, or harvested input bundle, they may influence what Enroll does. That is considered a local trust-boundary failure outside Enroll's intended security model.</p>
<ul class="text-secondary">
<li>The operator understands options such as <code>--dangerous</code>, <code>--assume-safe-path</code>, <code>--sops</code>, <code>--remote-host</code>, and <code>--remote-ssh-config</code>.</li>
<li>Configuration files loaded from <code>enroll.ini</code> are selected and trusted by the operator.</li>
<li>Harvest bundles used for <code>manifest</code> or <code>diff</code> come from a trusted source unless the operator is deliberately inspecting untrusted input without applying it.</li>
<li>External tools invoked by Enroll, including Ansible, SOPS, SSH, sudo, Docker, Podman, Flatpak, Snap, package managers, and system utilities, are the trusted tools the operator intended to use.</li>
</ul>
</div>
</section>
<div class="callout p-4 mt-3">
<div class="fw-semibold mb-2">Scope control</div>
<p class="small text-secondary mb-3">You can explicitly include or exclude paths. Excludes take precedence over includes.</p>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll harvest \
--out /tmp/enroll-harvest \
--include-path '/home/*/.profile' \
--exclude-path '/home/*/.ssh/**'</code></pre></div>
</div>
<section id="in-scope" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">What Enroll tries to defend against</h2>
<div class="row g-3">
<div class="col-md-6"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Accidental secret capture</div><p class="small text-secondary mb-0">Default mode avoids obvious sensitive paths, private keys, token/password assignments, authorization headers, credential URIs, and similar material.</p></div></div>
<div class="col-md-6"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Unsafe filesystem behavior</div><p class="small text-secondary mb-0">Enroll avoids symlink traversal, hardlinks, device nodes, tar path traversal, unsafe artifact paths, and common TOCTOU issues when copying and consuming artifacts.</p></div></div>
<div class="col-md-6"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Unsafe generated Ansible</div><p class="small text-secondary mb-0">Harvested values are serialized as Ansible data, template-looking values are tagged <code>!unsafe</code>, and raw task scaffold identifiers are allowlisted.</p></div></div>
<div class="col-md-6"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Risky privileged automation</div><p class="small text-secondary mb-0">Plain harvest outputs are private by default. Root-run output paths and unsafe root <code>PATH</code> entries are checked. Remote harvest rejects unknown SSH host keys by default.</p></div></div>
</div>
</section>
<section id="out-of-scope" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">What is out of scope</h2>
<p class="text-secondary">The following are normally operator trust failures, not Enroll vulnerabilities by themselves:</p>
<ul class="text-secondary">
<li>A malicious local user already controlling root's shell, environment, config files, <code>PATH</code>, binaries, SSH config, or working directory.</li>
<li>A root user intentionally loading a config file or passing options that request dangerous behavior.</li>
<li>Using <code>--dangerous</code> and observing that Enroll may collect sensitive information.</li>
<li>Using <code>--assume-safe-path</code> and observing that Enroll does not prompt about <code>PATH</code>.</li>
<li>Applying generated Ansible from an untrusted harvest.</li>
<li>Trusting an untrusted webhook, email target, SSH proxy command, SOPS binary, package manager, or Ansible toolchain.</li>
</ul>
</section>
<section id="trusted-harvests" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Trusted harvests</h2>
<p class="text-secondary">Harvest bundles are sensitive administrative artifacts. They may contain hostnames, usernames, package lists, service state, filesystem metadata, configuration files, firewall snapshots, container image references, Flatpak/Snap state, and other operational details. In <code>--dangerous</code> mode they may contain much more sensitive material.</p>
<div class="alert alert-warning"><strong>Important:</strong> Enroll validates harvest structure and artifact safety. It cannot prove that the desired state represented by a harvest is safe to apply. Before running <code>manifest</code>, <code>diff</code>, or Ansible, be confident the harvest came from a trusted source and has not been tampered with.</div>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll validate ./harvest
<span class="prompt">$</span> enroll manifest --harvest ./harvest --out ./ansible</code></pre></div>
</section>
<section id="reporting" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Useful security reports</h2>
<p class="text-secondary">Useful reports show Enroll behaving unsafely despite the documented trust model. Examples include:</p>
<ul class="text-secondary">
<li>Capturing a clearly sensitive default-denied file without <code>--dangerous</code>.</li>
<li>Following a symlink or hardlink in a way that causes privileged file disclosure or overwrite.</li>
<li>Extracting a tar member outside the intended harvest directory.</li>
<li>Accepting a malicious harvest artifact that escapes the artifact root.</li>
<li>Generating Ansible where ordinary harvested data can cause command injection or YAML/template structure injection.</li>
<li>Writing root-run output into an unsafe attacker-controlled path despite safety checks.</li>
<li>Accepting an unknown SSH host key unexpectedly.</li>
</ul>
<p class="text-secondary mb-0">Less useful reports are variations of “root deliberately asked Enroll to do dangerous things”. Those are expected consequences of running a privileged administration tool with trusted operator-controlled input.</p>
</section>
</div>
</div>
<hr class="my-5">
<div class="feature-card p-4">
<h2 class="h4 fw-bold mb-2">Threat model</h2>
<div class="row g-3">
<div class="col-md-6">
<div class="fw-semibold">What Enroll tries to prevent</div>
<ul class="small mb-0">
<li>Accidentally copying obvious secrets in default mode</li>
<li>Harvesting huge/unbounded file sets by mistake</li>
<li>One host's difference causing problems for other hosts in terms of Ansible task steps (multi-site mode)</li>
</ul>
</div>
<div class="col-md-6">
<div class="fw-semibold">What you still need to think about</div>
<ul class="small mb-0">
<li>Where outputs are stored and who can access them</li>
<li>Reviewing what was captured before committing/sharing</li>
<li>Choosing encryption and secret-management strategy</li>
</ul>
</div>
</div>
</div>
</div>
</main>

View file

@ -0,0 +1,111 @@
---
title: "Troubleshooting"
html_title: "Enroll troubleshooting - current Ansible CLI"
description: "Common Enroll errors and fixes for the current Ansible-only CLI."
og_title: "Enroll troubleshooting"
og_description: "Fix unsafe root PATH, missing Ansible collections, JinjaTurtle, remote SSH auth, validation, and SOPS extraction."
og_type: "article"
---
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="kicker mb-3"><i class="bi bi-life-preserver"></i> Troubleshooting</div>
<h1 class="display-6 fw-bold mb-2">Common Enroll problems and fixes</h1>
<p class="lead mb-0">Current fixes for the Ansible-only Enroll CLI, including stale old examples, unsafe root <code>PATH</code>, JinjaTurtle, remote SSH, SOPS, and generated collection requirements.</p>
</div>
</header>
<main class="py-5">
<div class="container">
<div class="row g-4">
<aside class="col-lg-3">
<div class="position-sticky" style="top: 6rem;">
<div class="list-group small shadow-sm">
<a class="list-group-item list-group-item-action" href="#no-target">Unknown <code>--target</code></a>
<a class="list-group-item list-group-item-action" href="#root-path">Unsafe root PATH</a>
<a class="list-group-item list-group-item-action" href="#collections">Ansible collections</a>
<a class="list-group-item list-group-item-action" href="#jinjaturtle">JinjaTurtle</a>
<a class="list-group-item list-group-item-action" href="#missing-files">Missing files</a>
<a class="list-group-item list-group-item-action" href="#validate">Validation</a>
<a class="list-group-item list-group-item-action" href="#remote">Remote auth</a>
<a class="list-group-item list-group-item-action" href="#sops">SOPS bundles</a>
</div>
</div>
</aside>
<div class="col-lg-9">
<section id="root-path" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Root run refuses to continue because <code>PATH</code> is unsafe</h2>
<p class="text-secondary">When running as root, Enroll refuses non-interactive execution if <code>PATH</code> contains <code>.</code>, an empty entry, a relative entry, a non-root-owned directory, or group/world-writable directories/parents that could let an untrusted binary be executed.</p>
<div class="callout p-4">
<div class="fw-semibold mb-2">Fix the environment, or explicitly accept it</div>
<div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
<span class="prompt">$</span> enroll harvest --out ./harvest
# only for trusted CI environments where the PATH is intentional
<span class="prompt">$</span> enroll harvest --out ./harvest --assume-safe-path</code></pre></div>
</div>
</section>
<section id="collections" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Ansible complains about missing modules or unsupported parameters</h2>
<p class="text-secondary">Generated roles can use collections for Docker, Podman, Flatpak, Snap, or other resources. Install the generated requirements from the manifest output directory before running the playbook.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#trouble-collections"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="trouble-collections"><span class="prompt">$</span> cd /tmp/enroll-ansible
<span class="prompt">$</span> ansible-galaxy collection install -r requirements.yml
<span class="prompt">$</span> ansible-galaxy collection list</code></pre></div>
<p class="small text-secondary mt-3 mb-0">If Ansible still uses an older collection, check your configured collection paths and remove or override stale copies.</p>
</section>
<section id="jinjaturtle" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">JinjaTurtle is missing or template generation was skipped</h2>
<p class="text-secondary">By default, Enroll uses JinjaTurtle if the <code>jinjaturtle</code> executable exists on <code>PATH</code>; otherwise it safely copies raw files. <code>--jinjaturtle</code> makes missing JinjaTurtle a hard error, and <code>--no-jinjaturtle</code> disables templating entirely.</p>
<div class="row g-3">
<div class="col-md-6"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Require JinjaTurtle</div><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll manifest --harvest ./harvest --out ./ansible --jinjaturtle</code></pre></div></div></div>
<div class="col-md-6"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Disable templating</div><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll manifest --harvest ./harvest --out ./ansible --no-jinjaturtle</code></pre></div></div></div>
</div>
</section>
<section id="missing-files" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">A file I expected was not harvested</h2>
<p class="text-secondary">Default harvesting is conservative. A file may be skipped because it matched an exclude pattern, looked sensitive, looked binary, was too large, was already captured elsewhere, was unreadable, or was outside standard scan paths.</p>
<div class="row g-3">
<div class="col-md-6"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Explicitly include a path</div><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll harvest --out ./harvest --include-path /opt/myapp/config.yml</code></pre></div></div></div>
<div class="col-md-6"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Inspect the result</div><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll explain ./harvest
<span class="prompt">$</span> enroll validate ./harvest</code></pre></div></div></div>
</div>
<div class="alert alert-warning mt-3 mb-0"><strong>Be careful with <code>--dangerous</code>:</strong> it can harvest secrets such as private keys, tokens, credentials, and application config containing passwords.</div>
</section>
<section id="validate" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Validation fails, or a manifest references a missing artifact</h2>
<p class="text-secondary">Validate the original harvest. Validation checks the schema, referenced artifacts, artifact safety, and unreferenced artifact warnings.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#trouble-validate"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="trouble-validate"><span class="prompt">$</span> enroll validate /path/to/harvest
<span class="prompt">$</span> enroll validate /path/to/harvest --format json --out validate.json
<span class="prompt">$</span> enroll validate /path/to/harvest --fail-on-warnings</code></pre></div>
<p class="small text-secondary mt-3 mb-0">If validation passes but Ansible cannot find a file, check whether the generated output was partially copied, edited, or moved after rendering.</p>
</section>
<section id="remote" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">Remote harvest cannot sudo or cannot unlock an SSH key</h2>
<p class="text-secondary">Remote mode can prompt for sudo and private-key passphrases. In non-interactive shells, provide the required inputs explicitly.</p>
<div class="row g-3">
<div class="col-md-4"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Prompt for sudo</div><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll harvest --remote-host host.example.net --ask-become-pass --out ./harvest</code></pre></div></div></div>
<div class="col-md-4"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">Prompt for SSH key</div><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> enroll harvest --remote-host host.example.net --ask-key-passphrase --out ./harvest</code></pre></div></div></div>
<div class="col-md-4"><div class="callout p-4 h-100"><div class="fw-semibold mb-2">CI key passphrase</div><div class="terminal"><pre class="mb-0"><code><span class="prompt">$</span> export ENROLL_SSH_KEY_PASSPHRASE='...'
<span class="prompt">$</span> enroll harvest --remote-host host.example.net --ssh-key-passphrase-env ENROLL_SSH_KEY_PASSPHRASE --out ./harvest</code></pre></div></div></div>
</div>
<p class="small text-secondary mt-3 mb-0">If host key verification fails, connect with normal <code>ssh</code> first so the expected key is in <code>known_hosts</code>, or use <code>--remote-ssh-config</code>.</p>
</section>
<section id="sops" class="scroll-mt-nav mb-5">
<h2 class="section-title fw-bold">I have a SOPS manifest bundle. How do I run it?</h2>
<p class="text-secondary"><code>manifest --sops</code> produces one encrypted <code>manifest.tar.gz.sops</code> file. It is an at-rest storage format, not something Ansible runs directly. Decrypt and extract it first.</p>
<div class="codeblock terminal"><button class="btn btn-sm btn-outline-secondary copy-btn" data-copy-target="#trouble-sops"><i class="bi bi-clipboard"></i> Copy</button><pre class="mb-0"><code id="trouble-sops"><span class="prompt">$</span> cd /path/to/output
<span class="prompt">$</span> sops -d manifest.tar.gz.sops | tar -xzvf -
<span class="prompt">$</span> cd manifest
<span class="prompt">$</span> ansible-galaxy collection install -r requirements.yml
<span class="prompt">$</span> ansible-playbook -i "localhost," -c local playbook.yml --check</code></pre></div>
</section>
</div>
</div>
</div>
</main>

View file

@ -6,4 +6,4 @@ uglyURLs = true
disableKinds = ["taxonomy", "term", "RSS", "sitemap"]
[params]
description = "Enroll inspects Debian-like and RedHat-like Linux hosts and generates Ansible roles/playbooks from what it finds. Harvest → Manifest → Manage."
description = "Enroll inspects Debian-like and RedHat-like Linux hosts and generates Ansible configuration-management code from what it finds. Harvest → Manifest → Manage."

View file

@ -16,6 +16,181 @@
],
"unevaluatedProperties": false
},
"ContainerImageTagAlias": {
"additionalProperties": false,
"properties": {
"ref": {
"minLength": 1,
"type": "string"
},
"repository": {
"minLength": 1,
"type": "string"
},
"tag": {
"minLength": 1,
"type": "string"
}
},
"required": [
"ref",
"repository",
"tag"
],
"type": "object"
},
"ContainerImage": {
"additionalProperties": false,
"properties": {
"architecture": {
"type": [
"string",
"null"
]
},
"created": {
"type": [
"string",
"null"
]
},
"engine": {
"enum": [
"docker",
"podman"
],
"type": "string"
},
"home": {
"type": [
"string",
"null"
]
},
"image_id": {
"type": [
"string",
"null"
]
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
},
"os": {
"type": [
"string",
"null"
]
},
"platform": {
"type": [
"string",
"null"
]
},
"pull_ref": {
"type": [
"string",
"null"
]
},
"repo_digests": {
"items": {
"type": "string"
},
"type": "array"
},
"repo_tags": {
"items": {
"type": "string"
},
"type": "array"
},
"scope": {
"enum": [
"system",
"user"
],
"type": "string"
},
"size": {
"type": [
"integer",
"null"
]
},
"source": {
"type": "string"
},
"tag_aliases": {
"items": {
"$ref": "#/$defs/ContainerImageTagAlias"
},
"type": "array"
},
"user": {
"type": [
"string",
"null"
]
},
"variant": {
"type": [
"string",
"null"
]
}
},
"required": [
"engine",
"scope",
"user",
"home",
"image_id",
"repo_tags",
"repo_digests",
"pull_ref",
"tag_aliases",
"os",
"architecture",
"variant",
"platform",
"size",
"created",
"source",
"notes"
],
"type": "object"
},
"ContainerImagesSnapshot": {
"additionalProperties": false,
"properties": {
"images": {
"items": {
"$ref": "#/$defs/ContainerImage"
},
"type": "array"
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
},
"role_name": {
"const": "container_images"
}
},
"required": [
"role_name",
"images",
"notes"
],
"type": "object"
},
"DnfConfigSnapshot": {
"allOf": [
{
@ -117,6 +292,14 @@
"minLength": 1,
"type": "string"
},
"group": {
"minLength": 1,
"type": "string"
},
"section": {
"minLength": 1,
"type": "string"
},
"version": {
"minLength": 1,
"type": "string"
@ -228,7 +411,7 @@
},
"src_rel": {
"minLength": 1,
"pattern": "^[^/].*",
"pattern": "^(?!\\.\\.?(?:/|$))[^/\\u0000-\\u001f\\\\]+(?:/(?!\\.\\.?(?:/|$))[^/\\u0000-\\u001f\\\\]+)*$",
"type": "string"
}
},
@ -364,6 +547,12 @@
},
"type": "array"
},
"section": {
"type": [
"string",
"null"
]
},
"version": {
"type": [
"string",
@ -390,6 +579,16 @@
"package": {
"minLength": 1,
"type": "string"
},
"has_config": {
"type": "boolean",
"default": true
},
"section": {
"type": [
"string",
"null"
]
}
},
"required": [
@ -571,6 +770,21 @@
"$ref": "#/$defs/UserEntry"
},
"type": "array"
},
"user_flatpaks": {
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/$defs/FlatpakInstall"
}
},
"type": "object"
},
"user_flatpak_remotes": {
"type": "array",
"items": {
"$ref": "#/$defs/FlatpakRemote"
}
}
},
"required": [
@ -652,6 +866,256 @@
"notes"
],
"type": "object"
},
"SysctlSnapshot": {
"additionalProperties": false,
"properties": {
"role_name": {
"const": "sysctl"
},
"managed_files": {
"items": {
"$ref": "#/$defs/ManagedFile"
},
"type": "array"
},
"parameters": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"role_name",
"managed_files",
"parameters",
"notes"
],
"type": "object"
},
"FlatpakInstall": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"method": {
"type": "string",
"enum": [
"system",
"user"
]
},
"remote": {
"type": [
"string",
"null"
]
},
"branch": {
"type": [
"string",
"null"
]
},
"arch": {
"type": [
"string",
"null"
]
},
"kind": {
"type": [
"string",
"null"
],
"enum": [
"app",
"runtime",
null
]
},
"ref": {
"type": [
"string",
"null"
]
},
"user": {
"type": [
"string",
"null"
]
},
"home": {
"type": [
"string",
"null"
]
},
"source": {
"type": "string"
},
"from_url": {
"type": "string",
"minLength": 1
}
},
"required": [
"name",
"method"
],
"type": "object"
},
"FlatpakRemote": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"method": {
"type": "string",
"enum": [
"system",
"user"
]
},
"url": {
"type": "string",
"minLength": 1
},
"user": {
"type": [
"string",
"null"
]
},
"home": {
"type": [
"string",
"null"
]
},
"source": {
"type": "string"
}
},
"required": [
"name",
"method",
"url"
],
"type": "object"
},
"SnapInstall": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"channel": {
"type": [
"string",
"null"
]
},
"revision": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"classic": {
"type": "boolean"
},
"devmode": {
"type": "boolean"
},
"dangerous": {
"type": "boolean"
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
},
"source": {
"type": "string"
},
"install_revision": {
"type": "boolean"
}
},
"required": [
"name"
],
"type": "object"
},
"FlatpakSnapshot": {
"additionalProperties": false,
"properties": {
"role_name": {
"const": "flatpak"
},
"system_flatpaks": {
"type": "array",
"items": {
"$ref": "#/$defs/FlatpakInstall"
}
},
"remotes": {
"type": "array",
"items": {
"$ref": "#/$defs/FlatpakRemote"
}
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"role_name"
],
"type": "object"
},
"SnapSnapshot": {
"additionalProperties": false,
"properties": {
"role_name": {
"const": "snap"
},
"system_snaps": {
"type": "array",
"items": {
"$ref": "#/$defs/SnapInstall"
}
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"role_name"
],
"type": "object"
}
},
"$id": "https://enroll.sh/schema/state.schema.json",
@ -762,6 +1226,18 @@
},
"firewall_runtime": {
"$ref": "#/$defs/FirewallRuntimeSnapshot"
},
"sysctl": {
"$ref": "#/$defs/SysctlSnapshot"
},
"flatpak": {
"$ref": "#/$defs/FlatpakSnapshot"
},
"snap": {
"$ref": "#/$defs/SnapSnapshot"
},
"container_images": {
"$ref": "#/$defs/ContainerImagesSnapshot"
}
},
"required": [

View file

@ -0,0 +1,19 @@
{{ define "main" }}
{{ .Content | safeHTML }}
<main class="py-5">
<div class="container">
<div class="row g-4">
{{ range .Pages.ByDate.Reverse }}
<div class="col-lg-6">
<article class="feature-card p-4 h-100">
<div class="small text-secondary mb-2">{{ .Date.Format "2 January 2006" }}</div>
<h2 class="h4 fw-bold mb-2"><a class="link-dark text-decoration-none" href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
{{ with .Params.summary }}<p class="text-secondary">{{ . }}</p>{{ end }}
<a class="btn btn-sm btn-outline-dark" href="{{ .RelPermalink }}">Read more</a>
</article>
</div>
{{ end }}
</div>
</div>
</main>
{{ end }}

View file

@ -0,0 +1,3 @@
{{ define "main" }}
{{ .Content | safeHTML }}
{{ end }}

View file

@ -3,12 +3,13 @@
<div class="row g-4 align-items-start">
<div class="col-lg-6">
<div class="d-flex align-items-center gap-2 mb-2">
<img class="brand-mark" src="assets/img/enroll.svg" alt="Enroll">
<img class="brand-mark" src="{{ "assets/img/enroll.svg" | relURL }}" alt="Enroll">
<div class="fw-bold">Enroll (a mig5 project)</div>
<span class="badge badge-soft rounded-pill">CLI</span>
<span class="badge badge-soft rounded-pill">Ansible</span>
<span class="badge badge-soft rounded-pill">SSH</span>
</div>
<div class="text-secondary mb-3">Reverse-engineering servers into Ansible.</div>
<div class="text-secondary mb-3">Reverse-engineering Linux hosts into Ansible.</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-dark" href="https://git.mig5.net/mig5/enroll" target="_blank" rel="noreferrer">
@ -26,25 +27,23 @@
<div class="col-lg-3">
<div class="fw-semibold mb-2">Site</div>
<ul class="list-unstyled small mb-0">
<li><a class="link-secondary text-decoration-none" href="docs.html">Docs</a></li>
<li><a class="link-secondary text-decoration-none" href="examples.html">Examples</a></li>
<li><a class="link-secondary text-decoration-none" href="schema.html">Schema</a></li>
<li><a class="link-secondary text-decoration-none" href="security.html">Security Design</a></li>
<li><a class="link-secondary text-decoration-none" href="{{ "docs.html" | relURL }}">Docs</a></li>
<li><a class="link-secondary text-decoration-none" href="{{ "examples.html" | relURL }}">Examples</a></li>
<li><a class="link-secondary text-decoration-none" href="{{ "news.html" | relURL }}">News</a></li>
<li><a class="link-secondary text-decoration-none" href="{{ "troubleshooting.html" | relURL }}">Troubleshooting</a></li>
<li><a class="link-secondary text-decoration-none" href="{{ "schema.html" | relURL }}">Schema</a></li>
<li><a class="link-secondary text-decoration-none" href="{{ "security.html" | relURL }}">Security Design</a></li>
</ul>
</div>
<div class="col-lg-3">
<div class="fw-semibold mb-2">Contact</div>
<div class="fw-semibold mb-2">Bugs or feature suggestions?</div>
<ul class="list-unstyled small mb-0">
<li>
<a class="link-secondary text-decoration-none" href="https://nr.mig5.net/forms/mig5/contact" target="_blank" rel="noreferrer">
<i class="bi bi-envelope"></i> Form
</a>
</li>
<li class="mt-1">
<span class="text-secondary">Fediverse:</span>
<a class="link-secondary text-decoration-none ms-1" href="https://goto.mig5.net/@mig5" target="_blank" rel="noreferrer">@mig5</a>
</li>
</ul>
</div>
</div>

View file

@ -21,4 +21,4 @@
<!-- Bootstrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="assets/css/site.css" rel="stylesheet">
<link href="{{ "assets/css/site.css" | relURL }}" rel="stylesheet">

View file

@ -1,7 +1,7 @@
<nav class="navbar navbar-expand-lg bg-white bg-opacity-75 sticky-top border-bottom" data-bs-theme="light">
<div class="container py-1">
<a class="navbar-brand fw-bold d-flex align-items-center gap-2" href="index.html">
<img class="brand-mark" src="assets/img/enroll.svg" alt="Enroll">
<a class="navbar-brand fw-bold d-flex align-items-center gap-2" href="{{ "index.html" | relURL }}">
<img class="brand-mark" src="{{ "assets/img/enroll.svg" | relURL }}" alt="Enroll">
<span>Enroll</span>
</a>
@ -13,13 +13,19 @@
<div class="collapse navbar-collapse" id="nav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item">
<a class="nav-link" href="docs.html">Docs</a>
<a class="nav-link" href="{{ "news.html" | relURL }}">News</a>
</li>
<li class="nav-item">
<a class="nav-link" href="examples.html">Examples</a>
<a class="nav-link" href="{{ "docs.html" | relURL }}">Docs</a>
</li>
<li class="nav-item">
<a class="nav-link" href="security.html">Security Design</a>
<a class="nav-link" href="{{ "examples.html" | relURL }}">Examples</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ "security.html" | relURL }}">Security Design</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ "troubleshooting.html" | relURL }}">Troubleshooting</a>
</li>
<li class="nav-item ms-lg-2">

View file

@ -5,4 +5,4 @@
})();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/site.js"></script>
<script src="{{ "assets/js/site.js" | relURL }}"></script>