Repository navigation
Run the maintenance jobs on timers, and alert on not having run - #202
Merged
Merged
Conversation
…having run Five commands existed and nothing ran any of them (#77). `backups/` did not exist on the host, so `make backup` had never been run once. A timer alone would not have been the fix. A job that fails is loud; a job that stops being run is silent, and silence is also what a healthy week looks like — the same inversion watchdog.rules.yaml is built on, and the one security.rules.yaml already states when it explains why there is no SuricataStopped rule. So the requirement was to make NOT having run observable, which is most of this change. Four systemd timers under systemd/, installed by `make install-timers`. Every run — timer or human — goes through scripts/run-scheduled.sh, which records four gauges into a textfile Alloy's existing unix exporter scrapes. No compose change was needed: compose.yaml already bind-mounts / at /rootfs:ro, so only the textfile block in config.alloy is new. Three findings that shaped it, each measured rather than assumed: The label is homelab_job, not job. config.alloy's discovery.relabel sets `job` on every target from that exporter and prometheus.scrape defaults to honor_labels: false, so a `job` label in a .prom file arrives as exported_job and every rule keyed on it matches nothing — loaded, healthy, unfireable, the #63 shape exactly. backup.test.yaml has a case that pins this. `> on(homelab_job)` silently drops the instance label. Caught by the unit test asserting instance and promtool returning an alert without it. group_left() is what keeps the left side's label set, and without it a second monitored host would collide with the first. backup-volumes.sh's own flock does not cover --verify-only: the case block handling it returns before the lock is taken. So a nightly verify and a weekly backup could overlap today, and the backup's prune could delete a set the verifier was mid-read on. The two units share a lock name, which is what closes that; the clock gap is only belt-and-braces. The thresholds are not literals in the rules. install-timers.sh writes homelab_job_max_age_seconds from the same JOBS table that decides the cadence, and the rules join against it — so no rule names a job, a sixth timer needs no rule edit, and "declared but never ran" is `unless on(homelab_job)` rather than six hardcoded absent() calls. `make check-timers` derives each timer's real period from `systemd-analyze calendar` and refuses a threshold under twice it; that assertion caught snmp-verify declaring ten days against a weekly timer while this was being written. alertmanager.yaml is unchanged and no ROUTES row was added. warning+correctness already reaches `default`, and a new assertion row would traverse the same route as the existing capacity row — breaking the one-row-one-assertion correspondence and making both "(7 assertions)" comments false. check-digests went to a scheduled GitHub Actions workflow instead of a host timer. It needs no host and no secret, which makes it the only one of the five that is genuinely off-host. A separate file rather than a schedule: on ci.yml, whose cancel-in-progress concurrency would race a scheduled run against pushes. secrets-verify-backup deliberately has no timer. verify-key-backup.sh refuses the live key by device:inode precisely so what gets tested is a copy on removable media, and no timer can mount that — so a successful human run records its timestamp and SecretsKeyBackupUnproven fires at ninety days. Nagging is not as good as running it; it beats remembering. Verified: six rule mutations each caught by the unit tests (hardcoded threshold, severity drift, dropped exclusion, removed for:, removed group_left, wrong join label); the wrapper's failure path leaves last_success intact and its lock timeout exits 75; four classes of schedule drift caught by --check; and the textfile path end to end against the pinned Alloy image — 13 series, correct labels, node_textfile_scrape_error 0. Corrected while verifying: a truncated .prom does NOT blank the whole directory. It raises node_textfile_scrape_error and costs only that file. The atomic write still matters, but for one job rather than all of them, and four comments said otherwise. What this does not solve: the host still verifies its own backups. The external cron-monitor bounds that — a dead host does not read as healthy backups — but genuinely off-host verification is #92 and #99, both still open, and the runbook says so in its first section rather than burying it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflicts were five counted claims that both sides bumped: #76 added one network rule and network.test.yaml, this branch added backup.rules.yaml with five. Resolved by keeping both, which check_docs.py then confirmed: 45 Prometheus rules across seven files, 58 total with Loki's 13. The test-coverage sentence took main's wording rather than mine — it names the rules it counts, records that the sentence it replaced had been wrong for weeks, and carries the warning that check_docs.py reads prose line by line so a claim wrapped mid-phrase is one it cannot see. Merged count is fourteen of 45, and the untested remainder stays 31 because both sides added rules and tests together. The two ROUTES heredocs picked up main's eighth assertion (warning/hardware) identically, so the pair that drifted in #68 is still in step. backup.rules.yaml uses warning/correctness, which still reaches `default` — re-verified with amtool against the merged config rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #77.
Five commands existed and nothing ran any of them.
backups/did not exist on the host, somake backuphad never been run once.A timer alone would not have been the fix. The issue is explicit about the bar — "whatever runs it should alert on not having run, not just on failing" — and that is most of this change. A job that fails is loud; a job that stops being run is silent, and silence is also what a healthy week looks like. Same inversion
watchdog.rules.yamlis built on.What runs
backup-volumesmake backupverify-backupsmake backup ARGS='--verify-only --all'backup-firewallmake backup-firewallsnmp-verifymake snmp-verifyverify-key-backupmake secrets-verify-backup KEY=…make check-digestswent to a scheduled GitHub Actions workflow instead — it needs no host and no secret, which makes it the only one of the five that is genuinely off-host.How the "did not run" part works
Every run, timer or human, goes through
scripts/run-scheduled.sh, which records four gauges into a textfile Alloy's existing unix exporter scrapes. No compose change was needed —compose.yamlalready bind-mounts/at/rootfs:ro, so only thetextfileblock inconfig.alloyis new.Thresholds are not literals in the rules.
install-timers.shwriteshomelab_job_max_age_secondsfrom the sameJOBStable that decides the cadence, and the rules join against it. So no rule names a job, a sixth timer needs no rule edit, and "declared but never ran" isunless on(homelab_job)rather than six hardcodedabsent()calls.make check-timersderives each timer's real period fromsystemd-analyze calendarand refuses a threshold under twice it. That assertion caughtsnmp-verifydeclaring ten days against a weekly timer while this was being written.Three findings that shaped it
The label is
homelab_job, notjob.discovery.relabel "metrics"setsjobon every target from that exporter and scrapes default tohonor_labels: false, so ajoblabel in a.promfile arrives asexported_joband every rule keyed on it matches nothing — loaded, healthy, unfireable, the #63 shape exactly.backup.test.yamlhas a case that pins this.> on(homelab_job)silently dropsinstance. Caught by the unit test assertinginstanceand promtool returning an alert without it.group_left()is what keeps the left side's labels; without it a second monitored host would collide with the first.backup-volumes.sh's flock does not cover--verify-only— thecaseblock handling it returns before the lock is taken. So a nightly verify and a weekly backup could overlap today, and the backup'sprunecould delete a set the verifier was mid-read on. The two units share a lock name, which is what closes that.Alertmanager is unchanged
warning+correctnessalready reachesdefault. No new route, and deliberately no newROUTESrow: it would traverse the same route as the existingcapacityrow, breaking the one-row-one-assertion correspondence and making both "(7 assertions)" comments false.Verification
for:, removedgroup_left, wrong join labellast_successintact, lock timeout exits 75, SIGTERM still records--checknode_textfile_scrape_error 0make validateandmake lintcleanCorrected while verifying: a truncated
.promdoes not blank the whole directory. It raisesnode_textfile_scrape_errorand costs only that file. The atomic write still matters, but for one job rather than all of them — four comments said otherwise and are fixed.What this does not solve
The host still verifies its own backups. The external cron-monitor bounds that — a dead host does not read as healthy backups — but genuinely off-host verification is #92 and #99, both still open. The runbook says so in its first section rather than burying it.
Caution
The weekly backup stops the monitoring stack, which stops the
Watchdogheartbeat. The run is normally ~90s against a 15m grace, but check yours —docs/runbooks/verify-the-alert-path.mdnow records the coupling. This is why that timer is the only one withRandomizedDelaySec=0.Deploy step:
make install-timerson the monitoring host. The installer refuses to run from a worktree, since the units carry absolute paths.🤖 Generated with Claude Code