Add enroll diff

Miguel Jacq 2025-12-17 22:00:50 -06:00
parent 973e6756e3
commit ebe49553e9

260
enroll-diff.md Normal file

@ -0,0 +1,260 @@
The Enroll `diff` subcommand will compare two 'harvests' and see if there are any differences.
# What does diff do?
These differences include:
* Packages added/removed
* Files added/removed/changed
* Users added/removed/changed
* Services added/removed/changed
The `diff` command can also understand SOPS-encrypted harvests, assume the user executing the command is able to decrypt the SOPS files.
# How do I run it?
Assuming you have two harvests on your filesystem:
```
enroll diff \
--old harvest.v1 \
--new harvest.v2
```
# Output formats
The default output is just a plain text format, which is the same as passing `--format text`.
You can pass `--format json` or `--format markdown` to get structured formats instead.
# --exit-code mode
You can pass `--exit-code` as an argument, which will return a return code of 2 if there was any 'diff'.
You can use this to script any further action your cron service might take, such as sending e-mail output, or rely on it to trigger failures in CI builds.
# Webhooks
You can also send the output to a webhook under your control. Here is an example:
```
enroll diff \
--old harvest.v1 \
--new harvest.v2 \
--webhook https://example.com/some/endpoint \
--webhook-format json \
--webhook-header 'X-Enroll-Secret: Some-Secret-Here'
```
You can pass `--webhook-format markdown` if you wanted the Markdown format in the payload, but most people probably want JSON structured data for consumption at the other end.
Use the `--webhook-header` to send some sort of authentication/authorization attribute (e.g an OAuth2.0 bearer token, static secret, etc) to help lock down your webhook from unauthorised requests. You can send multiple headers.
# Email
You can send the output via email using email and smtp arguments. See 'Full usage info' below for more information.
# Example: Running the diff on a systemd timer
In the example below, we set up a systemd timer to run on a periodic basis and trigger the webhook.
It will set up a 'golden' harvest if it didn't already exist, then periodically run a second harvest to compare against it.
You can optionally promote the new harvest to become the new 'golden' harvest for future invocations if you want a continuous, rolling 'diff' scenario.
## Create script
Store in `/usr/local/bin/enroll-harvest-diff.sh` and make it `chmod 0700`
```
#!/usr/bin/env bash
set -euo pipefail
# Required env
: "${WEBHOOK_URL:?Set WEBHOOK_URL in /etc/enroll/enroll-harvest-diff.env}"
: "${ENROLL_SECRET:?Set ENROLL_SECRET in /etc/enroll/enroll-harvest-diff.env}"
# 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}"
# 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
```
## Environment file
Save as: `/etc/enroll/enroll-harvest-diff` and `chmod 0600` it.
```
# 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
```
## Systemd service
`/etc/systemd/system/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
```
## Systemd timer
`/etc/systemd/system/enroll-harvest-diff.timer`
```
[Unit]
Description=Run Enroll harvest diff hourly
[Timer]
OnCalendar=hourly
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
```
## Enable + test
```
sudo systemctl daemon-reload
sudo systemctl enable --now enroll-harvest-diff.timer
# run once now
sudo systemctl start enroll-harvest-diff.service
sudo journalctl -u enroll-harvest-diff.service -n 200 --no-pager
```
If you want the “fixed baseline” behavior (always diff against the original golden snapshot), set `PROMOTE_NEW=0` and you're done.
# Full usage info
```
usage: enroll diff [-h] --old OLD --new NEW [--sops] [--format {text,markdown,json}] [--out OUT] [--exit-code] [--notify-always] [--webhook WEBHOOK]
[--webhook-format {json,text,markdown}] [--webhook-header K:V] [--email-to EMAIL_TO] [--email-from EMAIL_FROM] [--email-subject EMAIL_SUBJECT] [--smtp SMTP]
[--smtp-user SMTP_USER] [--smtp-password-env SMTP_PASSWORD_ENV]
options:
-h, --help show this help message and exit
--old OLD Old/baseline harvest (directory, a path to state.json, a tarball, or a SOPS-encrypted bundle).
--new NEW New/current harvest (directory, a path to state.json, a tarball, or a SOPS-encrypted bundle).
--sops Allow SOPS-encrypted harvest bundle inputs (requires `sops` on PATH).
--format {text,markdown,json}
Report output format (default: text).
--out OUT Write the report to this file instead of stdout.
--exit-code Exit with status 2 if differences are detected.
--notify-always Send webhook/email even when there are no differences.
--webhook WEBHOOK POST the report to this URL (only when differences are detected, unless --notify-always).
--webhook-format {json,text,markdown}
Payload format for --webhook (default: json).
--webhook-header K:V Extra HTTP header for --webhook (repeatable), e.g. 'Authorization: Bearer ...'.
--email-to EMAIL_TO Email the report to this address (repeatable; only when differences are detected unless --notify-always).
--email-from EMAIL_FROM
From address for --email-to (default: enroll@<hostname>).
--email-subject EMAIL_SUBJECT
Subject for --email-to (default: 'enroll diff report').
--smtp SMTP SMTP server host[:port] for --email-to. If omitted, uses local sendmail.
--smtp-user SMTP_USER
SMTP username (optional).
--smtp-password-env SMTP_PASSWORD_ENV
Environment variable containing SMTP password (optional).
```
The main purpose of the `diff` command