Add --enforce mode to enroll diff and add --ignore-package-versions
Some checks failed
CI / test (push) Failing after 1m48s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 22s

If there is diff detected between the two harvests, and it can
enforce restoring the state from the older harvest, it will
manifest the state and apply it with ansible. Only the specific
roles that had diffed will be applied (via the new tags capability).

`--ignore-package-versions` will skip reporting when packages are
upgraded/downgraded in the diff.
This commit is contained in:
Miguel Jacq 2026-01-10 10:51:41 +11:00
parent 9a249cc973
commit ebd30247d1
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
9 changed files with 309 additions and 59 deletions

View file

@ -1,9 +1,12 @@
# 0.4.0 (not yet released) # 0.4.0
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest. * Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
* Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it) * Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
* Update pynacl dependency to resolve CVE-2025-69277 * Update pynacl dependency to resolve CVE-2025-69277
* Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day) * Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
* Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
* Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
* Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible. Only the specific roles that had diffed will be applied (via the new tags capability)
# 0.3.0 # 0.3.0

View file

@ -35,25 +35,8 @@ set -euo pipefail
SRC="${SRC:-/src}" SRC="${SRC:-/src}"
WORKROOT="${WORKROOT:-/work}" WORKROOT="${WORKROOT:-/work}"
OUT="${OUT:-/out}" OUT="${OUT:-/out}"
DEPS_DIR="${DEPS_DIR:-/deps}"
VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)" VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)"
echo "Version ID is ${VERSION_ID}" echo "Version ID is ${VERSION_ID}"
# Install jinjaturtle from local rpm
# Filter out .src.rpm and debug* subpackages if present.
if [ -d "${DEPS_DIR}" ] && compgen -G "${DEPS_DIR}/*.rpm" > /dev/null; then
mapfile -t rpms < <(ls -1 "${DEPS_DIR}"/*.rpm | grep -vE '(\.src\.rpm$|-(debuginfo|debugsource)-)' | grep "${VERSION_ID}")
if [ "${#rpms[@]}" -gt 0 ]; then
echo "Installing dependency RPMs from ${DEPS_DIR}:"
printf ' - %s\n' "${rpms[@]}"
dnf -y install "${rpms[@]}"
dnf -y clean all
else
echo "NOTE: Only src/debug RPMs found in ${DEPS_DIR}; nothing installed." >&2
fi
else
echo "NOTE: No RPMs found in ${DEPS_DIR}. If the build fails with missing python3dist(jinjaturtle)," >&2
echo " mount your jinjaturtle RPM directory as -v <dir>:/deps" >&2
fi
mkdir -p "${WORKROOT}" "${OUT}" mkdir -p "${WORKROOT}" "${OUT}"
WORK="${WORKROOT}/src" WORK="${WORKROOT}/src"

View file

@ -108,6 +108,17 @@ Generate Ansible output from an existing harvest bundle.
**Common flags** **Common flags**
- `--fqdn <host>`: enables **multi-site** output style - `--fqdn <host>`: enables **multi-site** output style
**Role tags**
Generated playbooks tag each role so you can target just the parts you need:
- Tag format: `role_<role_name>` (e.g. `role_services`, `role_users`)
- Fallback/safe tag: `role_other`
Example:
```bash
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_services,role_users
```
--- ---
### `enroll single-shot` ### `enroll single-shot`
@ -131,7 +142,26 @@ Compare two harvest bundles and report what changed.
**Inputs** **Inputs**
- `--old <harvest>` and `--new <harvest>` (directories or `state.json` paths) - `--old <harvest>` and `--new <harvest>` (directories or `state.json` paths)
- `--sops` when comparing SOPS-encrypted harvest bundles - `--sops` when comparing SOPS-encrypted harvest bundles
- `--exclude-path` if you want to ignore certain files that changed in the diff - `--exclude-path <PATTERN>` (repeatable) to ignore file/dir drift under matching paths (same pattern syntax as harvest)
- `--ignore-package-versions` to ignore package version-only drift (upgrades/downgrades)
- `--enforce` to apply the **old** harvest state locally (requires `ansible-playbook` on `PATH`)
**Noise suppression**
- `--exclude-path` is useful for things that change often but you still want in the harvest baseline (e.g. `/var/anacron`).
- `--ignore-package-versions` keeps routine upgrades from alerting; package add/remove drift is still reported.
**Enforcement (`--enforce`)**
If a diff exists and `ansible-playbook` is available, Enroll will:
1) generate a manifest from the **old** harvest into a temporary directory
2) run `ansible-playbook -i localhost, -c local <tmp>/playbook.yml` (often with `--tags role_<...>` to limit runtime)
3) record in the diff report that the old harvest was enforced
Enforcement is intentionally “safe”:
- reinstalls packages that were removed (`state: present`), but does **not** attempt downgrades/pinning
- restores users, files (contents + permissions/ownership), and service enable/start state
If `ansible-playbook` is not on `PATH`, Enroll returns an error and does not enforce.
**Output formats** **Output formats**
- `--format json` (default for webhooks) - `--format json` (default for webhooks)
@ -417,6 +447,16 @@ enroll diff --old /path/to/golden/harvest --new /path/to/new/harvest --web
enroll diff --old /path/to/harvestA --new /path/to/harvestB --exclude-path /var/anacron enroll diff --old /path/to/harvestA --new /path/to/harvestB --exclude-path /var/anacron
``` ```
### Ignore package version drift (routine upgrades) but still alert on add/remove
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --ignore-package-versions
```
### Enforce the old harvest state when drift is detected (requires Ansible)
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --enforce --ignore-package-versions --exclude-path /var/anacron
```
--- ---
## Explain ## Explain
@ -492,6 +532,12 @@ ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml
ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml
``` ```
### Run only specific roles (tags)
Generated playbooks tag each role as `role_<name>` (e.g. `role_users`, `role_services`), so you can speed up targeted runs:
```bash
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_users
```
## Configuration file ## Configuration file
As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands. As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands.
@ -541,6 +587,12 @@ exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
no_jinjaturtle = true no_jinjaturtle = true
sops = 00AE817C24A10C2540461A9C1D7CDE0234DB458D sops = 00AE817C24A10C2540461A9C1D7CDE0234DB458D
[diff]
# ignore noisy drift
exclude_path = /var/anacron
ignore_package_versions = true
# enforce = true # requires ansible-playbook on PATH
[single-shot] [single-shot]
# if you use single-shot, put its defaults here. # if you use single-shot, put its defaults here.
# It does not inherit those of the subsections above, so you # It does not inherit those of the subsections above, so you

13
debian/changelog vendored
View file

@ -1,3 +1,16 @@
enroll (0.4.0) unstable; urgency=medium
* Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
* Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
* Update pynacl dependency to resolve CVE-2025-69277
* Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
* Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
* Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
* Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible.
Only the specific roles that had diffed will be applied (via the new tags capability)
-- Miguel Jacq <mig@mig5.net> Sat, 10 Jan 2026 10:30:00 +1100
enroll (0.3.0) unstable; urgency=medium enroll (0.3.0) unstable; urgency=medium
* Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why. * Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.

View file

@ -567,6 +567,14 @@ def main() -> None:
"This affects file drift reporting only (added/removed/changed files), not package/service/user diffs." "This affects file drift reporting only (added/removed/changed files), not package/service/user diffs."
), ),
) )
d.add_argument(
"--ignore-package-versions",
action="store_true",
help=(
"Ignore package version changes in the diff report and exit status. "
"Package additions/removals are still reported. Useful when routine upgrades would otherwise create noisy drift."
),
)
d.add_argument( d.add_argument(
"--enforce", "--enforce",
action="store_true", action="store_true",
@ -854,6 +862,9 @@ def main() -> None:
args.new, args.new,
sops_mode=bool(getattr(args, "sops", False)), sops_mode=bool(getattr(args, "sops", False)),
exclude_paths=list(getattr(args, "exclude_path", []) or []), exclude_paths=list(getattr(args, "exclude_path", []) or []),
ignore_package_versions=bool(
getattr(args, "ignore_package_versions", False)
),
) )
# Optional enforcement: if drift is detected, attempt to restore the # Optional enforcement: if drift is detected, attempt to restore the
@ -865,7 +876,7 @@ def main() -> None:
"requested": True, "requested": True,
"status": "skipped", "status": "skipped",
"reason": ( "reason": (
"no enforceable drift detected (only package additions and/or version changes); " "no enforceable drift detected (only additions and/or package version changes); "
"enroll does not attempt to downgrade packages" "enroll does not attempt to downgrade packages"
), ),
} }
@ -874,6 +885,7 @@ def main() -> None:
info = enforce_old_harvest( info = enforce_old_harvest(
args.old, args.old,
sops_mode=bool(getattr(args, "sops", False)), sops_mode=bool(getattr(args, "sops", False)),
report=report,
) )
except Exception as e: except Exception as e:
raise SystemExit( raise SystemExit(

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import os import os
import re
import shutil import shutil
import subprocess # nosec import subprocess # nosec
import tarfile import tarfile
@ -291,6 +292,7 @@ def compare_harvests(
*, *,
sops_mode: bool = False, sops_mode: bool = False,
exclude_paths: Optional[List[str]] = None, exclude_paths: Optional[List[str]] = None,
ignore_package_versions: bool = False,
) -> Tuple[Dict[str, Any], bool]: ) -> Tuple[Dict[str, Any], bool]:
"""Compare two harvests. """Compare two harvests.
@ -317,10 +319,14 @@ def compare_harvests(
pkgs_removed = sorted(old_pkgs - new_pkgs) pkgs_removed = sorted(old_pkgs - new_pkgs)
pkgs_version_changed: List[Dict[str, Any]] = [] pkgs_version_changed: List[Dict[str, Any]] = []
pkgs_version_changed_ignored_count = 0
for pkg in sorted(old_pkgs & new_pkgs): for pkg in sorted(old_pkgs & new_pkgs):
a = old_inv.get(pkg) or {} a = old_inv.get(pkg) or {}
b = new_inv.get(pkg) or {} b = new_inv.get(pkg) or {}
if _pkg_version_key(a) != _pkg_version_key(b): if _pkg_version_key(a) != _pkg_version_key(b):
if ignore_package_versions:
pkgs_version_changed_ignored_count += 1
else:
pkgs_version_changed.append( pkgs_version_changed.append(
{ {
"package": pkg, "package": pkg,
@ -477,6 +483,7 @@ def compare_harvests(
"generated_at": _utc_now_iso(), "generated_at": _utc_now_iso(),
"filters": { "filters": {
"exclude_paths": list(exclude_paths or []), "exclude_paths": list(exclude_paths or []),
"ignore_package_versions": bool(ignore_package_versions),
}, },
"old": { "old": {
"input": old_path, "input": old_path,
@ -494,6 +501,9 @@ def compare_harvests(
"added": pkgs_added, "added": pkgs_added,
"removed": pkgs_removed, "removed": pkgs_removed,
"version_changed": pkgs_version_changed, "version_changed": pkgs_version_changed,
"version_changed_ignored_count": int(
pkgs_version_changed_ignored_count
),
}, },
"services": { "services": {
"enabled_added": units_added, "enabled_added": units_added,
@ -529,13 +539,6 @@ def compare_harvests(
return report, has_changes return report, has_changes
def _tail_text(s: str, *, max_chars: int = 4000) -> str:
s = s or ""
if len(s) <= max_chars:
return s
return "" + s[-max_chars:]
def has_enforceable_drift(report: Dict[str, Any]) -> bool: def has_enforceable_drift(report: Dict[str, Any]) -> bool:
"""Return True if the diff report contains drift that is safe/meaningful to enforce. """Return True if the diff report contains drift that is safe/meaningful to enforce.
@ -554,7 +557,9 @@ def has_enforceable_drift(report: Dict[str, Any]) -> bool:
return True return True
sv = report.get("services", {}) or {} sv = report.get("services", {}) or {}
if (sv.get("enabled_added") or []) or (sv.get("enabled_removed") or []): # We do not try to disable newly-enabled services; we only restore units
# that were enabled in the baseline but are now missing.
if sv.get("enabled_removed") or []:
return True return True
for ch in sv.get("changed", []) or []: for ch in sv.get("changed", []) or []:
@ -567,28 +572,136 @@ def has_enforceable_drift(report: Dict[str, Any]) -> bool:
return True return True
us = report.get("users", {}) or {} us = report.get("users", {}) or {}
if ( # We restore baseline users (missing/changed). We do not remove newly-added users.
(us.get("added") or []) if (us.get("removed") or []) or (us.get("changed") or []):
or (us.get("removed") or [])
or (us.get("changed") or [])
):
return True return True
fl = report.get("files", {}) or {} fl = report.get("files", {}) or {}
if ( # We restore baseline files (missing/changed). We do not delete newly-managed files.
(fl.get("added") or []) if (fl.get("removed") or []) or (fl.get("changed") or []):
or (fl.get("removed") or [])
or (fl.get("changed") or [])
):
return True return True
return False return False
def _role_tag(role: str) -> str:
"""Return the Ansible tag name for a role (must match manifest generation)."""
r = str(role or "").strip()
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", r).strip("_")
if not safe:
safe = "other"
return f"role_{safe}"
def _enforcement_plan(
report: Dict[str, Any],
old_state: Dict[str, Any],
old_bundle_dir: Path,
) -> Dict[str, Any]:
"""Return a best-effort enforcement plan (roles/tags) for this diff report.
We only plan for drift that the baseline manifest can safely restore:
- packages that were removed (reinstall, no downgrades)
- baseline users that were removed/changed
- baseline files that were removed/changed
- baseline systemd units that were disabled/changed
We do NOT plan to remove newly-added packages/users/files/services.
"""
roles: set[str] = set()
# --- Packages (only removals)
pk = report.get("packages", {}) or {}
removed_pkgs = set(pk.get("removed") or [])
if removed_pkgs:
pkg_to_roles: Dict[str, set[str]] = {}
for svc in _roles(old_state).get("services") or []:
r = str(svc.get("role_name") or "").strip()
for p in svc.get("packages", []) or []:
if p:
pkg_to_roles.setdefault(str(p), set()).add(r)
for pr in _roles(old_state).get("packages") or []:
r = str(pr.get("role_name") or "").strip()
p = pr.get("package")
if p:
pkg_to_roles.setdefault(str(p), set()).add(r)
for p in removed_pkgs:
for r in pkg_to_roles.get(str(p), set()):
if r:
roles.add(r)
# --- Users (removed/changed)
us = report.get("users", {}) or {}
if (us.get("removed") or []) or (us.get("changed") or []):
u = _roles(old_state).get("users") or {}
u_role = str(u.get("role_name") or "users")
if u_role:
roles.add(u_role)
# --- Files (removed/changed)
fl = report.get("files", {}) or {}
file_paths: List[str] = []
for e in fl.get("removed", []) or []:
if isinstance(e, dict):
p = e.get("path")
else:
p = e
if p:
file_paths.append(str(p))
for e in fl.get("changed", []) or []:
if isinstance(e, dict):
p = e.get("path")
else:
p = e
if p:
file_paths.append(str(p))
if file_paths:
idx = _file_index(old_bundle_dir, old_state)
for p in file_paths:
rec = idx.get(p)
if rec and rec.role:
roles.add(str(rec.role))
# --- Services (enabled_removed + meaningful changes)
sv = report.get("services", {}) or {}
units: List[str] = []
for u in sv.get("enabled_removed", []) or []:
if u:
units.append(str(u))
for ch in sv.get("changed", []) or []:
if not isinstance(ch, dict):
continue
unit = ch.get("unit")
changes = ch.get("changes") or {}
if unit and any(k != "packages" for k in changes.keys()):
units.append(str(unit))
if units:
old_units = _service_units(old_state)
for u in units:
snap = old_units.get(u)
if snap and snap.get("role_name"):
roles.add(str(snap.get("role_name")))
# Drop empty/unknown roles.
roles = {r for r in roles if r and str(r).strip() and str(r).strip() != "unknown"}
tags = sorted({_role_tag(r) for r in roles})
return {
"roles": sorted(roles),
"tags": tags,
}
def enforce_old_harvest( def enforce_old_harvest(
old_path: str, old_path: str,
*, *,
sops_mode: bool = False, sops_mode: bool = False,
report: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Enforce the *old* (baseline) harvest state on the current machine. """Enforce the *old* (baseline) harvest state on the current machine.
@ -616,6 +729,17 @@ def enforce_old_harvest(
if old_b.tempdir: if old_b.tempdir:
stack.callback(old_b.tempdir.cleanup) stack.callback(old_b.tempdir.cleanup)
old_state = _load_state(old_b.dir)
plan: Optional[Dict[str, Any]] = None
tags: Optional[List[str]] = None
roles: List[str] = []
if report is not None:
plan = _enforcement_plan(report, old_state, old_b.dir)
roles = list(plan.get("roles") or [])
t = list(plan.get("tags") or [])
tags = t if t else None
with tempfile.TemporaryDirectory(prefix="enroll-enforce-") as td: with tempfile.TemporaryDirectory(prefix="enroll-enforce-") as td:
td_path = Path(td) td_path = Path(td)
try: try:
@ -646,6 +770,8 @@ def enforce_old_harvest(
"local", "local",
str(playbook), str(playbook),
] ]
if tags:
cmd.extend(["--tags", ",".join(tags)])
p = subprocess.run( p = subprocess.run(
cmd, cmd,
cwd=str(td_path), cwd=str(td_path),
@ -666,16 +792,14 @@ def enforce_old_harvest(
"returncode": int(p.returncode), "returncode": int(p.returncode),
} }
# Include a small tail for observability in webhooks/emails. # Record tag selection (if we could attribute drift to specific roles).
if p.stdout: info["roles"] = roles
info["stdout_tail"] = _tail_text(p.stdout) info["tags"] = list(tags or [])
if p.stderr: if not tags:
info["stderr_tail"] = _tail_text(p.stderr) info["scope"] = "full_playbook"
if p.returncode != 0: if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip() err = (p.stderr or p.stdout or "").strip()
if err:
err = _tail_text(err)
raise RuntimeError( raise RuntimeError(
"ansible-playbook failed" "ansible-playbook failed"
+ (f" (rc={p.returncode})" if p.returncode is not None else "") + (f" (rc={p.returncode})" if p.returncode is not None else "")
@ -709,13 +833,30 @@ def _report_text(report: Dict[str, Any]) -> str:
if ex_paths: if ex_paths:
lines.append(f"file exclude patterns: {', '.join(str(p) for p in ex_paths)}") lines.append(f"file exclude patterns: {', '.join(str(p) for p in ex_paths)}")
if filt.get("ignore_package_versions"):
ignored = int(
(report.get("packages", {}) or {}).get("version_changed_ignored_count") or 0
)
msg = "package version drift: ignored (--ignore-package-versions)"
if ignored:
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
lines.append(msg)
enf = report.get("enforcement") or {} enf = report.get("enforcement") or {}
if enf: if enf:
lines.append("\nEnforcement") lines.append("\nEnforcement")
status = str(enf.get("status") or "").strip().lower() status = str(enf.get("status") or "").strip().lower()
if status == "applied": if status == "applied":
extra = ""
tags = enf.get("tags") or []
scope = enf.get("scope")
if tags:
extra = f" (tags={','.join(str(t) for t in tags)})"
elif scope:
extra = f" ({scope})"
lines.append( lines.append(
f" applied old harvest via ansible-playbook (rc={enf.get('returncode')})" f" applied old harvest via ansible-playbook (rc={enf.get('returncode')})"
+ extra
+ ( + (
f" (finished {enf.get('finished_at')})" f" (finished {enf.get('finished_at')})"
if enf.get("finished_at") if enf.get("finished_at")
@ -737,7 +878,10 @@ def _report_text(report: Dict[str, Any]) -> str:
lines.append("\nPackages") lines.append("\nPackages")
lines.append(f" added: {len(pk.get('added', []) or [])}") lines.append(f" added: {len(pk.get('added', []) or [])}")
lines.append(f" removed: {len(pk.get('removed', []) or [])}") lines.append(f" removed: {len(pk.get('removed', []) or [])}")
lines.append(f" version_changed: {len(pk.get('version_changed', []) or [])}") ignored_v = int(pk.get("version_changed_ignored_count") or 0)
vc = len(pk.get("version_changed", []) or [])
suffix = f" (ignored {ignored_v})" if ignored_v else ""
lines.append(f" version_changed: {vc}{suffix}")
for p in pk.get("added", []) or []: for p in pk.get("added", []) or []:
lines.append(f" + {p}") lines.append(f" + {p}")
for p in pk.get("removed", []) or []: for p in pk.get("removed", []) or []:
@ -848,13 +992,30 @@ def _report_markdown(report: Dict[str, Any]) -> str:
+ "\n" + "\n"
) )
if filt.get("ignore_package_versions"):
ignored = int(
(report.get("packages", {}) or {}).get("version_changed_ignored_count") or 0
)
msg = "- **Package version drift**: ignored (`--ignore-package-versions`)"
if ignored:
msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
out.append(msg + "\n")
enf = report.get("enforcement") or {} enf = report.get("enforcement") or {}
if enf: if enf:
out.append("\n## Enforcement\n") out.append("\n## Enforcement\n")
status = str(enf.get("status") or "").strip().lower() status = str(enf.get("status") or "").strip().lower()
if status == "applied": if status == "applied":
extra = ""
tags = enf.get("tags") or []
scope = enf.get("scope")
if tags:
extra = " (tags=" + ",".join(str(t) for t in tags) + ")"
elif scope:
extra = f" ({scope})"
out.append( out.append(
"- ✅ Applied old harvest via ansible-playbook" "- ✅ Applied old harvest via ansible-playbook"
+ extra
+ ( + (
f" (rc={enf.get('returncode')})" f" (rc={enf.get('returncode')})"
if enf.get("returncode") is not None if enf.get("returncode") is not None
@ -892,7 +1053,10 @@ def _report_markdown(report: Dict[str, Any]) -> str:
for p in pk.get("removed", []) or []: for p in pk.get("removed", []) or []:
out.append(f" - `- {p}`\n") out.append(f" - `- {p}`\n")
out.append(f"- Version changed: {len(pk.get('version_changed', []) or [])}\n") ignored_v = int(pk.get("version_changed_ignored_count") or 0)
vc = len(pk.get("version_changed", []) or [])
suffix = f" (ignored {ignored_v})" if ignored_v else ""
out.append(f"- Version changed: {vc}{suffix}\n")
for ch in pk.get("version_changed", []) or []: for ch in pk.get("version_changed", []) or []:
out.append( out.append(
f" - `~ {ch.get('package')}`: `{ch.get('old')}` → `{ch.get('new')}`\n" f" - `~ {ch.get('package')}`: `{ch.get('old')}` → `{ch.get('new')}`\n"

View file

@ -163,6 +163,19 @@ def _write_role_scaffold(role_dir: str) -> None:
os.makedirs(os.path.join(role_dir, "templates"), exist_ok=True) os.makedirs(os.path.join(role_dir, "templates"), exist_ok=True)
def _role_tag(role: str) -> str:
"""Return a stable Ansible tag name for a role.
Used by `enroll diff --enforce` to run only the roles needed to repair drift.
"""
r = str(role or "").strip()
# Ansible tag charset is fairly permissive, but keep it portable and consistent.
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", r).strip("_")
if not safe:
safe = "other"
return f"role_{safe}"
def _write_playbook_all(path: str, roles: List[str]) -> None: def _write_playbook_all(path: str, roles: List[str]) -> None:
pb_lines = [ pb_lines = [
"---", "---",
@ -173,7 +186,8 @@ def _write_playbook_all(path: str, roles: List[str]) -> None:
" roles:", " roles:",
] ]
for r in roles: for r in roles:
pb_lines.append(f" - {r}") pb_lines.append(f" - role: {r}")
pb_lines.append(f" tags: [{_role_tag(r)}]")
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(pb_lines) + "\n") f.write("\n".join(pb_lines) + "\n")
@ -188,7 +202,8 @@ def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
" roles:", " roles:",
] ]
for r in roles: for r in roles:
pb_lines.append(f" - {r}") pb_lines.append(f" - role: {r}")
pb_lines.append(f" tags: [{_role_tag(r)}]")
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(pb_lines) + "\n") f.write("\n".join(pb_lines) + "\n")

View file

@ -72,7 +72,7 @@ for dist in ${DISTS[@]}; do
rm -rf "$PWD/dist/rpm"/* rm -rf "$PWD/dist/rpm"/*
mkdir -p "$PWD/dist/rpm" mkdir -p "$PWD/dist/rpm"
docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/jinjaturtle/dist/rpm":/deps:ro enroll-rpm:${release} docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out enroll-rpm:${release}
sudo chown -R "${USER}" "$PWD/dist" sudo chown -R "${USER}" "$PWD/dist"
for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do

View file

@ -1,4 +1,4 @@
%global upstream_version 0.3.0 %global upstream_version 0.4.0
Name: enroll Name: enroll
Version: %{upstream_version} Version: %{upstream_version}
@ -19,7 +19,6 @@ Requires: python3-yaml
Requires: python3-paramiko Requires: python3-paramiko
Requires: python3-jsonschema Requires: python3-jsonschema
# Make sure private repo dependency is pulled in by package name as well.
Recommends: jinjaturtle Recommends: jinjaturtle
%description %description
@ -44,6 +43,15 @@ Enroll a server's running state retrospectively into Ansible.
%{_bindir}/enroll %{_bindir}/enroll
%changelog %changelog
* Sat Jan 10 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
- Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
- Update pynacl dependency to resolve CVE-2025-69277
- Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
- Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
- Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
- Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible.
Only the specific roles that had diffed will be applied (via the new tags capability)
* Mon Jan 05 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release} * Mon Jan 05 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why. - Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
- Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery. - Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.