Converge the host on main hourly instead of deploying over SSH (#99) - #273
Merged
Conversation
…ver SSH (#99) Deployment was `make up` typed into an SSH session. Nothing recorded what got deployed, so `make up` on an uncommitted tree and `make up` on `main` were indistinguishable; and nothing compared the host to the repository, so a config edited in place stayed edited until a deploy silently overwrote it. scripts/converge.sh runs hourly on #77's existing machinery — the same timer pattern, the same run-scheduled.sh wrapper, the same textfile directory. It fetches `main`, refuses to move unless the tip verifies, fast-forwards, and runs `make up`. It does not reimplement deploying: `make up` stays the one deployment path, so every runbook that names it stays true. The issue's stated blocker was an age key on a host that pulls from a public repository. Checked, and it dissolves: the key was already on that host and has decrypted every `make up` there since the stack was deployed — *public* means readable, and a key that decrypts local files is not endangered by strangers being able to read the repository. The real question is unattended execution, which is about write access, so the gate is a pinned signing fingerprint: - measured, not assumed — all 110 consecutive first-parent commits on `main` back to 2026-08-19 are GitHub-signed merges, and all 110 verify against 968479A1AFF927E37D1A566BB5690EEEBB952194; - fetches an anonymous https:// URL rather than `origin`, so the agent holds no credential and cannot push; - refuses a direct push to `main` and a non-fast-forward, and does not roll back. ADR-0019 states plainly what the gate does not buy: it cannot stop a compromised GitHub account, and the compensating control is the record, not the gate. A dirty tree is a hard stop with no --force. Refusing is the point — overwriting is what the old model did, and losing the edit while never reporting it is half of what #99 is about. Five gauges are recorded on every exit path including the refusals, so a run that declined to move still reports what the host is on. `converge` is an ordinary JOBS row, so ScheduledJobStale/Failed/NeverRan cover it with no rule edit — which tests backup.rules.yaml's claim that adding a timer is a row in a table. deploy.rules.yaml adds only what those cannot say: which refusal, and how far behind the host is. All four new rules have promtool unit tests. HOMELAB_CONVERGE_APPLY=0 in /etc/default/homelab-timers makes every run report-only, for watching it decide before letting it act. Verified against a real clone: converged no-op, behind + dry-run, dirty tree, missing key, unsigned tip, non-fast-forward, wrong branch, and wrong checkout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces unattended deployment behavior on a production-like host, so a final human review is warranted despite only minor inline issues found.
Pull request overview
Introduces a pull-based, hourly convergence mechanism so the monitoring host fast-forwards to main and deploys via the existing make up path, while recording deployment state as Prometheus textfile metrics and alerting on drift/verification/lag.
Changes:
- Adds
homelab-convergesystemd timer/service plus a newscripts/converge.shandmake convergeentrypoint. - Adds
deploy.rules.yaml+ promtool tests to alert on deploy drift, verification failures, lag behindmain, and missing deploy metrics. - Updates operational documentation/runbooks/ADR and various rule-count summaries to reflect the new deploy alerting surface.
File summaries
| File | Description |
|---|---|
| systemd/homelab-converge.timer | Defines hourly convergence cadence (with jitter and persistence). |
| systemd/homelab-converge.service | Runs convergence via the scheduled wrapper with the shared backups lock. |
| systemd/homelab-backup-volumes.service | Updates commentary to reflect an additional systemd unit in the set. |
| stacks/observability/README.md | Updates rule count and categorization to include deploy rules. |
| stacks/observability/prometheus/tests/deploy.test.yaml | Adds promtool unit tests covering the new deploy alert rules. |
| stacks/observability/prometheus/rules/deploy.rules.yaml | Introduces alert rules based on deployment convergence metrics. |
| stacks/observability/prometheus/rules/backup.rules.yaml | Updates documentation to reflect that deploy results live in a separate rules file. |
| scripts/install-timers.sh | Adds converge to the JOBS table and timer installation/priming flow. |
| scripts/converge.sh | Implements fetch/verify/fast-forward + make up, and writes deployment gauges. |
| README.md | Updates repo summary, topology counts, and documents the new self-deploy behavior. |
| Makefile | Adds make converge target as the human/timer entrypoint. |
| docs/security.md | Updates alert-rule count reference. |
| docs/runbooks/schedule-maintenance.md | Adds converge to the scheduled-jobs table and troubleshooting. |
| docs/runbooks/deploy-stack.md | Documents convergence as the normal update path post-initial deploy. |
| docs/runbooks/converge-the-host.md | Adds a dedicated runbook for setup, refusal modes, and disablement. |
| docs/roadmap.md | Marks #99 as answered and summarizes the chosen approach. |
| docs/observability.md | Updates alerting counts and documents deploy rules/test coverage. |
| docs/adr/0019-converge-on-a-timer-instead-of-deploying-over-ssh.md | Adds ADR capturing rationale, risks, and operational consequences. |
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Report-only is the chosen rollout: the timer goes on, and applying is switched on once it has been watched deciding correctly for a while. That made a gap in the previous commit visible. HOMELAB_CONVERGE_APPLY=0 lives in /etc/default/homelab-timers, a file outside this repository that nothing tracks, and DeployBehind could only say "it is refusing, OR report-only is set" — leaving an operator to go and read a file over ssh to tell a deliberate mode apart from a genuine refusal. A mode that exists only in /etc on one host is unrecorded state, which is the exact thing #99 is about. So the mode joins the record. homelab_deploy_apply_enabled is written with the other five gauges, tracked separately from DRY_RUN because --dry-run is a human asking a question while this is a property of how the host is configured — a manual dry run still reports apply=1, since the timer would have applied. DeployApplyDisabled (info) reports it, and is deliberately an alert rather than a roadmap entry for "remember to turn this on": it fires six hours in and resolves by itself on the first run after the line is removed. There is nothing to close and no way to leave it stale. DeployBehind now points at it instead of guessing, so the two read as one sentence when both fire. Verified against a real clone: HOMELAB_CONVERGE_APPLY=0 records apply=0 and applies nothing; --dry-run alone records apply=1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main gained an ADR-0019 of its own while this was open — #98's "read device joins from the DHCP server" — so the converge ADR renumbers to 0020, along with every reference to it. Same collision, same fix as b71de98. Rule counts merge rather than pick a side: main took Loki from 13 to 16 (#98's lease rules), this branch takes Prometheus from 48 to 53, so the totals become 53 metric-based + 16 log-based = 69. docs/security.md keeps main's new UnknownDeviceOnTrustedSegment row. The roadmap keeps this branch's answer to #99 and accepts main's deletion of the #100 bullet, which shipped in 209e8fa. The JOBS table merged cleanly: `converge` and main's new `dashboards-drift` are independent rows, and `make validate` passes with both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main is currently failing its own numbering guard: #98's "read device joins from the DHCP server" and #101's "run the lab stack in a guest" both merged as ADR-0019, twenty-three minutes apart, and check_adr_numbers reports the pair on any tree containing both. That blocks every open pull request, not this one. check_docs.py states the rule in the failure text — "renumber the one that landed second" — so this is arithmetic rather than a judgement call. Landing order on main's first-parent line, which is when a document became referenceable rather than when it was written: da0a071 06:33 #271 device joins stays ADR-0019 c7071c0 06:40 #270 lab stack becomes ADR-0020 (this branch, unmerged) becomes ADR-0021 The converge ADR yields to both, because merged work should not renumber to accommodate an open branch. Its number has now moved twice in one day, which is the cost of three ADRs being written against the same tip. Also fixes two link-text/target mismatches this branch introduced earlier: the 0019->0020 pass used a blanket `ADR-0019]` -> `ADR-0020]` substitution scoped to files mentioning converge, and roadmap.md and observability.md mention both, so two device-joins links were relabelled while still pointing at the right file. check_docs.py compares a filename to its own H1 and cannot see that, so both are now checked directly: every [ADR-NNNN](...NNNN-*.md) in the tree agrees with its target, and every ADR link resolves. The README conflict was main reflowing the runbook list against this branch adding `converge` to it; kept both. The adr/ count goes to 21. 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 #99.
Deployment was
make uptyped into an SSH session. Nothing recorded what got deployed, somake upon an uncommitted tree andmake uponmainwere indistinguishable; and nothing compared the host to the repository, so a config edited in place stayed edited until a deploy silently overwrote it.scripts/converge.shruns hourly on #77's existing machinery — the same timer pattern, the samerun-scheduled.shwrapper, the same textfile directory. It fetchesmain, refuses to move unless the tip verifies, fast-forwards, and runsmake up.It does not reimplement deploying.
make upstays the one deployment path, so every runbook that names it stays true and the blast radius is the decision to deploy.Ships in report-only mode
HOMELAB_CONVERGE_APPLY=0in/etc/default/homelab-timers. The timer fetches, verifies and records; applying is switched on once it has been watched deciding correctly for a while.The mode is a gauge, not just a file. A mode that lives only in
/etcon one host is unrecorded state — the exact thing this PR is about — and without itDeployBehindcould only say "it is refusing, OR report-only is set", leaving an operator to go read a file over ssh to tell a deliberate mode from a genuine refusal. Sohomelab_deploy_apply_enabledjoins the record, andDeployApplyDisabled(info) reports it.That alert is deliberately not a roadmap entry for "remember to turn this on": it fires six hours in and resolves by itself on the first run after the line is removed. Nothing to close, no way to leave it stale.
The secrets constraint dissolved
The issue's stated blocker was an age key on a host that pulls from a public repository. Checked, and it turns out not to be one: the key was already on that host, at
~/.config/sops/age/keys.txt, and has decrypted everymake upthere since the stack was deployed. Public means readable, and a key that decrypts local files is not endangered by strangers being able to read the repository.The real question is unattended execution, which is about write access. So the gate is a pinned signing fingerprint — measured, not assumed:
main, checked 2026-09-04968479A1AFF927E37D1A566BB5690EEEBB952194It also fetches an anonymous
https://URL rather thanorigin, so the agent holds no credential and cannot push. ADR-0021 states plainly what this does not buy: it cannot stop a compromised GitHub account, and the compensating control is the record, not the gate.A dirty tree is a hard stop
No
--force. Refusing is the point — overwriting is what the old model did, and losing the edit while never reporting it is half of what #99 is about.What gets recorded
Six gauges, written on every exit path including the refusals, so a run that declined to move still reports what the host is on: the deployed revision, its commit time, how far behind
main, whether the tree is dirty, whether the revision verified, and whether the host is applying at all.convergeis an ordinaryJOBSrow, soScheduledJobStale/Failed/NeverRancover it with no rule edit — which testsbackup.rules.yaml's claim that adding a timer is a row in a table rather than an edit to a rule file.deploy.rules.yamladds only what those cannot say: which refusal, and how far behind. All five new rules havepromtoolunit tests.Verification
make validatepasses (2 skips, both worktree-only). Every path exercised against a real clone: converged no-op, behind +--dry-run, dirty tree, missing key, unsigned tip, non-fast-forward, wrong branch, wrong checkout, and both apply modes.Two manual steps on merge
Both on the monitoring host, before
make install-timers(install primes each job once):curl -fsSL https://github.com/web-flow.gpg | gpg --importUntil the key is imported, convergence refuses every run and
DeployUnverifiedsays so — deliberately, since treating an unverifiable commit as fine would make the gate decorative.docs/runbooks/converge-the-host.md§Set it up.🤖 Generated with Claude Code
Note: this PR also unblocks
mainmainis currently failing its own ADR numbering guard — #98 (device joins) and #101 (lab stack) both merged as ADR-0019, 23 minutes apart.check_adr_numbersfails on any tree containing both, so this blocks every open PR, not just this one.check_docs.pystates the rule in its failure text — renumber the one that landed second — so the resolution is arithmetic, by landing order on main:The converge ADR yields to both, since merged work should not renumber around an open branch. If you would rather renumber differently, say so and I will redo it — nothing here depends on the number.