controller v0.94.0: pull-based config-refresh (re-pull + self-restart on config_version change)

PushResponse.ConfigVersion from the report ACK; ConfigRefresher reconciles vs.
the last-applied version (settings.applied_config_version) and on a change calls
bootstrap.RefreshConfig (re-pull controller.yaml + re-merge local_api) then
GracefulSelfRestart. First-run records baseline (no restart); unchanged = no-op
(no storm); failed pull keeps config + retries. Companion to hub v0.26.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
2026-06-30 21:49:47 +02:00
parent 464b14f029
commit 419d3d0b4e
9 changed files with 432 additions and 0 deletions
+31
View File
@@ -1,5 +1,36 @@
## Changelog
### v0.94.0 — pull-based config-refresh (re-pull controller.yaml + self-restart on a config change) (2026-06-30)
Config delivery is now pull-based, riding the report ACK exactly like the Phase 2 version floor — the hub
never connects into the box. This replaces the hub's retired "Push Config" (companion hub change v0.26.0)
and is the mechanism by which an operator config edit reaches a running box.
- **`internal/report/pusher.go`:** `PushResponse` gains `ConfigVersion int` (`json:"config_version"`).
0 = the hub didn't advertise it (old hub / report-only customer) → no action.
- **`internal/report/config_refresh.go` (NEW) — `ConfigRefresher.Reconcile`.** The testable reconcile
(all side effects injected): on a config_version change vs. the last-applied version, **Refresh** (re-pull
`controller.yaml`) → **Record****Restart**. First-ever ACK (nothing recorded) records the baseline
WITHOUT restarting (the first-boot pull already has the current config); an unchanged version is a no-op
(no restart storm); a failed pull keeps the current config and does NOT record/restart (retried next
cycle); record-before-restart so the restarted process sees it applied and doesn't loop.
- **`internal/bootstrap/bootstrap.go``RefreshConfig`.** Re-pulls `controller.yaml` from the hub and
rewrites it, re-merging `local_api` from the same read-only `bootstrap.json` mount (no secret stashed
elsewhere). Reuses the existing `pullWithRetry`/`mergeLocalAPI`/`writeFileAtomic`. Overwrites
`controller.yaml` (hub = source of truth); NEVER touches `settings.json`; fail-safe (any failure leaves
the current config unchanged + returns an error). NOT first-boot-gated (unlike `MaybeIngest`).
- **`internal/settings/settings.go`:** `applied_config_version` + `GetAppliedConfigVersion` /
`SetAppliedConfigVersion` (persisted so the version survives the restart).
- **`internal/api/selfrestart.go`:** exported `GracefulSelfRestart` (the unexported one now calls it) so
the main.go reconcile reuses the one graceful-restart mechanism instead of reinventing an `os.Exit`.
- **`cmd/controller/main.go`:** wires the reconcile into `OnPushResponse` beside the floor reconcile —
same report cycle, no new timer, no agent involvement. The first-boot `MaybeIngest` never-clobber is
untouched (the refresh is a separate explicit re-pull).
- Tests: `Reconcile` (change→refresh+record+restart; **same-version no-op RED-PROOF**; baseline-no-restart;
failed-pull no-record/no-restart; zero-version no-op; record-fail skips restart) + `RefreshConfig`
(re-pull overwrites + re-merges local_api; failed pull leaves config unchanged; absent bootstrap errors
without writing). `go build/vet/test ./...` green.
### v0.93.0 — NAS Part B: off-box backup target (restic-over-SFTP) (2026-06-30)
Closes the NAS arc: back the app-data tier (each off-box app's recovery unit + DB dumps + volume tars) up
to the customer's NAS as an **encrypted restic repo over SFTP** — the "1 off-site" leg of 3-2-1, distinct