diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5bec45b..7ba122f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
* 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)
# 0.3.0
diff --git a/Dockerfile.rpmbuild b/Dockerfile.rpmbuild
index 05bfd48..dd83546 100644
--- a/Dockerfile.rpmbuild
+++ b/Dockerfile.rpmbuild
@@ -35,25 +35,8 @@ set -euo pipefail
SRC="${SRC:-/src}"
WORKROOT="${WORKROOT:-/work}"
OUT="${OUT:-/out}"
-DEPS_DIR="${DEPS_DIR:-/deps}"
VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)"
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
:/deps" >&2
-fi
mkdir -p "${WORKROOT}" "${OUT}"
WORK="${WORKROOT}/src"
diff --git a/README.md b/README.md
index 1bafd55..4ba536b 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,17 @@ Generate Ansible output from an existing harvest bundle.
**Common flags**
- `--fqdn `: enables **multi-site** output style
+**Role tags**
+Generated playbooks tag each role so you can target just the parts you need:
+
+- Tag format: `role_` (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`
@@ -131,7 +142,26 @@ Compare two harvest bundles and report what changed.
**Inputs**
- `--old ` and `--new ` (directories or `state.json` paths)
- `--sops` when comparing SOPS-encrypted harvest bundles
-- `--exclude-path` if you want to ignore certain files that changed in the diff
+- `--exclude-path ` (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 /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**
- `--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
```
+### 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
@@ -492,6 +532,12 @@ ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml
ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml
```
+### Run only specific roles (tags)
+Generated playbooks tag each role as `role_` (e.g. `role_users`, `role_services`), so you can speed up targeted runs:
+```bash
+ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_users
+```
+
## Configuration file
As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands.
@@ -541,6 +587,12 @@ exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
no_jinjaturtle = true
sops = 00AE817C24A10C2540461A9C1D7CDE0234DB458D
+[diff]
+# ignore noisy drift
+exclude_path = /var/anacron
+ignore_package_versions = true
+# enforce = true # requires ansible-playbook on PATH
+
[single-shot]
# if you use single-shot, put its defaults here.
# It does not inherit those of the subsections above, so you
diff --git a/debian/changelog b/debian/changelog
index adcbe0c..adf0ff1 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -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 Sat, 10 Jan 2026 10:30:00 +1100
+
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.
diff --git a/enroll/cli.py b/enroll/cli.py
index 3e10d3f..c1f0870 100644
--- a/enroll/cli.py
+++ b/enroll/cli.py
@@ -567,6 +567,14 @@ def main() -> None:
"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(
"--enforce",
action="store_true",
@@ -854,6 +862,9 @@ def main() -> None:
args.new,
sops_mode=bool(getattr(args, "sops", False)),
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
@@ -865,7 +876,7 @@ def main() -> None:
"requested": True,
"status": "skipped",
"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"
),
}
@@ -874,6 +885,7 @@ def main() -> None:
info = enforce_old_harvest(
args.old,
sops_mode=bool(getattr(args, "sops", False)),
+ report=report,
)
except Exception as e:
raise SystemExit(
diff --git a/enroll/diff.py b/enroll/diff.py
index aa5b926..9d4b62b 100644
--- a/enroll/diff.py
+++ b/enroll/diff.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import json
import os
+import re
import shutil
import subprocess # nosec
import tarfile
@@ -291,6 +292,7 @@ def compare_harvests(
*,
sops_mode: bool = False,
exclude_paths: Optional[List[str]] = None,
+ ignore_package_versions: bool = False,
) -> Tuple[Dict[str, Any], bool]:
"""Compare two harvests.
@@ -317,17 +319,21 @@ def compare_harvests(
pkgs_removed = sorted(old_pkgs - new_pkgs)
pkgs_version_changed: List[Dict[str, Any]] = []
+ pkgs_version_changed_ignored_count = 0
for pkg in sorted(old_pkgs & new_pkgs):
a = old_inv.get(pkg) or {}
b = new_inv.get(pkg) or {}
if _pkg_version_key(a) != _pkg_version_key(b):
- pkgs_version_changed.append(
- {
- "package": pkg,
- "old": _pkg_version_display(a),
- "new": _pkg_version_display(b),
- }
- )
+ if ignore_package_versions:
+ pkgs_version_changed_ignored_count += 1
+ else:
+ pkgs_version_changed.append(
+ {
+ "package": pkg,
+ "old": _pkg_version_display(a),
+ "new": _pkg_version_display(b),
+ }
+ )
old_units = _service_units(old_state)
new_units = _service_units(new_state)
@@ -477,6 +483,7 @@ def compare_harvests(
"generated_at": _utc_now_iso(),
"filters": {
"exclude_paths": list(exclude_paths or []),
+ "ignore_package_versions": bool(ignore_package_versions),
},
"old": {
"input": old_path,
@@ -494,6 +501,9 @@ def compare_harvests(
"added": pkgs_added,
"removed": pkgs_removed,
"version_changed": pkgs_version_changed,
+ "version_changed_ignored_count": int(
+ pkgs_version_changed_ignored_count
+ ),
},
"services": {
"enabled_added": units_added,
@@ -529,13 +539,6 @@ def compare_harvests(
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:
"""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
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
for ch in sv.get("changed", []) or []:
@@ -567,28 +572,136 @@ def has_enforceable_drift(report: Dict[str, Any]) -> bool:
return True
us = report.get("users", {}) or {}
- if (
- (us.get("added") or [])
- or (us.get("removed") or [])
- or (us.get("changed") or [])
- ):
+ # We restore baseline users (missing/changed). We do not remove newly-added users.
+ if (us.get("removed") or []) or (us.get("changed") or []):
return True
fl = report.get("files", {}) or {}
- if (
- (fl.get("added") or [])
- or (fl.get("removed") or [])
- or (fl.get("changed") or [])
- ):
+ # We restore baseline files (missing/changed). We do not delete newly-managed files.
+ if (fl.get("removed") or []) or (fl.get("changed") or []):
return True
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(
old_path: str,
*,
sops_mode: bool = False,
+ report: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Enforce the *old* (baseline) harvest state on the current machine.
@@ -616,6 +729,17 @@ def enforce_old_harvest(
if old_b.tempdir:
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:
td_path = Path(td)
try:
@@ -646,6 +770,8 @@ def enforce_old_harvest(
"local",
str(playbook),
]
+ if tags:
+ cmd.extend(["--tags", ",".join(tags)])
p = subprocess.run(
cmd,
cwd=str(td_path),
@@ -666,16 +792,14 @@ def enforce_old_harvest(
"returncode": int(p.returncode),
}
- # Include a small tail for observability in webhooks/emails.
- if p.stdout:
- info["stdout_tail"] = _tail_text(p.stdout)
- if p.stderr:
- info["stderr_tail"] = _tail_text(p.stderr)
+ # Record tag selection (if we could attribute drift to specific roles).
+ info["roles"] = roles
+ info["tags"] = list(tags or [])
+ if not tags:
+ info["scope"] = "full_playbook"
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip()
- if err:
- err = _tail_text(err)
raise RuntimeError(
"ansible-playbook failed"
+ (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:
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 {}
if enf:
lines.append("\nEnforcement")
status = str(enf.get("status") or "").strip().lower()
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(
f" applied old harvest via ansible-playbook (rc={enf.get('returncode')})"
+ + extra
+ (
f" (finished {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(f" added: {len(pk.get('added', []) 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 []:
lines.append(f" + {p}")
for p in pk.get("removed", []) or []:
@@ -848,13 +992,30 @@ def _report_markdown(report: Dict[str, Any]) -> str:
+ "\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 {}
if enf:
out.append("\n## Enforcement\n")
status = str(enf.get("status") or "").strip().lower()
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(
"- ✅ Applied old harvest via ansible-playbook"
+ + extra
+ (
f" (rc={enf.get('returncode')})"
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 []:
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 []:
out.append(
f" - `~ {ch.get('package')}`: `{ch.get('old')}` → `{ch.get('new')}`\n"
diff --git a/enroll/manifest.py b/enroll/manifest.py
index 1447c0b..0186621 100644
--- a/enroll/manifest.py
+++ b/enroll/manifest.py
@@ -163,6 +163,19 @@ def _write_role_scaffold(role_dir: str) -> None:
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:
pb_lines = [
"---",
@@ -173,7 +186,8 @@ def _write_playbook_all(path: str, roles: List[str]) -> None:
" 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:
f.write("\n".join(pb_lines) + "\n")
@@ -188,7 +202,8 @@ def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
" 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:
f.write("\n".join(pb_lines) + "\n")
diff --git a/release.sh b/release.sh
index 3b8c0f1..7937741 100755
--- a/release.sh
+++ b/release.sh
@@ -72,7 +72,7 @@ for dist in ${DISTS[@]}; do
rm -rf "$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"
for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do
diff --git a/rpm/enroll.spec b/rpm/enroll.spec
index c35525a..2df784e 100644
--- a/rpm/enroll.spec
+++ b/rpm/enroll.spec
@@ -1,4 +1,4 @@
-%global upstream_version 0.3.0
+%global upstream_version 0.4.0
Name: enroll
Version: %{upstream_version}
@@ -19,7 +19,6 @@ Requires: python3-yaml
Requires: python3-paramiko
Requires: python3-jsonschema
-# Make sure private repo dependency is pulled in by package name as well.
Recommends: jinjaturtle
%description
@@ -44,6 +43,15 @@ Enroll a server's running state retrospectively into Ansible.
%{_bindir}/enroll
%changelog
+* Sat Jan 10 2026 Miguel Jacq - %{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 - %{version}-%{release}
- 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.