# REPORT — pull-based config-refresh (controller v0.94.0) **Date:** 2026-06-30 **Task:** retire the hub's inbound controls + add pull-based config delivery. Controller half (companion: hub v0.26.0). The controller re-pulls `controller.yaml` + self-restarts when the report ACK's `config_version` changes — the box-pulls-config replacement for the retired "Push Config". ## Baselines - felhom-controller `464b14f` (v0.93.0) → `419d3d0` (**v0.94.0**, live on guest 9201 / felhom-pve). - Companion hub v0.26.0 live on k3s (advertises `config_version` in the report ACK). ## What changed - `internal/report/pusher.go`: `PushResponse.ConfigVersion` (`json:"config_version"`). - `internal/report/config_refresh.go` (NEW): `ConfigRefresher.Reconcile` — testable reconcile with injected side effects. change → Refresh→Record→Restart; first-run records baseline (no restart); unchanged = no-op; failed pull keeps config (no record/restart, retries); record-before-restart. - `internal/bootstrap/bootstrap.go`: `RefreshConfig` — re-pull + re-write controller.yaml, re-merging local_api from bootstrap.json (reuses pullWithRetry/mergeLocalAPI/writeFileAtomic). Overwrites controller.yaml (hub = source of truth); never touches settings.json; fail-safe. - `internal/settings/settings.go`: `applied_config_version` + Get/Set. - `internal/api/selfrestart.go`: exported `GracefulSelfRestart` (reuse; the unexported one calls it). - `cmd/controller/main.go`: wired `ConfigRefresher` into `OnPushResponse` beside the floor reconcile. ## Tests & green gate - `go build ./... && go vet ./... && go test ./...` — **all green**. - `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. - **Red-proof (no-restart-storm):** removing the `ackVersion == applied` guard makes `TestReconcile_SameVersion_NoOp` FAIL (`refreshes=1/restarts=1, want 0`) — proving the guard prevents a restart-every-report loop. Restored → green. ## Live validation (guest 9201 on felhom-pve, hub.felhom.eu) Deployed 0.94.0 via the golden/bootstrap mechanism; push_interval temporarily 1m for fast cycles (restored to 15m after). 1. **First-run baseline (no restart):** first report after deploy recorded `applied_config_version=1` with `RestartCount=0` — baseline recorded, no restart. 2. **Config edit converges:** bumped demo-felhom `config_version` 1→2 in the hub DB (equivalent to a config save). Next report ACK carried 2; controller logged: `config-refresh: hub config_version=2 != applied=1 — re-pulling controller.yaml` → `re-pulled … merged local_api (192.168.0.162:8443)` → `applied config_version=2 — self-restarting` → `Graceful self-restart: exiting (0)`. Container restarted (`RestartCount 0→1`, healthy); `applied_config_version=2`; the re-pull is proven by `web.session_secret` changing (`e1ed2dd3…` → `f456197e…`, regenerated by the hub each pull); `local_api` block preserved. 3. **No restart storm:** 4 further report cycles at config_version=2 → zero config-refresh activity, container stayed up (no further restart). 4. **Apps unaffected:** during the config-apply restart only `felhom-controller` restarted — every customer app (nextcloud, gitea, calcom, rallly, sparkyfitness, …) stayed `Up (healthy)`. 5. **No inbound:** the whole flow ran with the hub never connecting into the box (it only answers the box's report); hub `grep client.Do internal/web/` is clean of ControllerURL targets. ## Notes - A config change rotates `web.session_secret` (configgen regenerates it per pull) → controller dashboard sessions are invalidated on a config apply. This is identical to the retired Push Config behaviour (same configgen path) — no regression; only on actual config changes, not every report. - The first-boot `bootstrap.MaybeIngest` never-clobber is untouched; the refresh is a separate explicit re-pull keyed on a version change.