The controller honors an operator-enforced minimum version (FLOOR) on the hub report ACK and auto-updates to the floor when below it (managed default, no click), reusing the Phase 1 in-guest-pull + agent-swap + rollback. Latest stays the opt-in button; the floor is the auto-target, never latest. - pusher.go: PushResponse += min_controller_version, latest_version (existing ACK seam) - main.go: OnPushResponse → updater.SetFloor + MaybeAutoUpdate (rides report cycle) - updater.go: SetFloor/GetFloor + MaybeAutoUpdate reusing performUpdate (auto-floor); no-op at/above floor, floor>latest, dev/no-agent/backup; no flap (in-mem+persisted) - settings UI (HU): floor display + auto restart-poll during an auto-update - tests: below/at/floor>latest/no-flap/raised-floor; below-floor red-proof verified - no agent change (reuses Phase 1 POST /controller/swap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FSZmmSFVzGwEzhYmxbkgBK
3.2 KiB
REPORT — v0.86.0: Phase 2 managed updates — floor-driven auto-update
Repo: felhom-controller · Version: v0.85.1 → v0.86.0 · Date: 2026-06-27
Why
Phase 1 shipped an opt-in "update to latest" button. Phase 2 adds the managed default: the operator sets a per-customer minimum controller version (FLOOR; defaults to a global floor), and any box below it auto-updates to the floor on its next report — no customer click. Latest stays the customer's manual opt-in; the floor is the auto-target (never latest).
Files changed
internal/report/pusher.go—PushResponse+=min_controller_version,latest_version. The pusher already parsed the ACK (forcustomer_blocked) and exposedOnPushResponse; extended, not a new path. (The task spec pointed atnotify/notifier.go'sio.Discardresponse-drops, but the real periodic report sender ispusher.go— it already had the seam, so the floor is read there.)cmd/controller/main.go— the existingOnPushResponsenow alsoupdater.SetFloor(...)+updater.MaybeAutoUpdate(). Rides the existing report cycle; no new timer/endpoint.internal/selfupdate/updater.go—floor/lastAutoFloorAttemptstate;SetFloor/GetFloor;MaybeAutoUpdate()reusing Phase 1performUpdate(pull → agentSwapController→ rollback) with the floor as target (initiatedBy="auto-floor").internal/web/handlers.go+templates/settings.html— Hungarian floor display + auto restart-poll.
Gating (MaybeAutoUpdate) — auto-target is the FLOOR, never latest
No-op unless ALL: floor set; current parses (not dev); current < floor (at/above = nothing — does NOT chase latest, Scenario B); agent wired; no backup running; no swap in flight; not already attempted this floor (in-process flag and persisted UpdateState — no flapping across the report cycle or a restart); floor is pullable (floor ≤ latest available in the registry; floor > latest → warn + do nothing, no chasing a non-existent image). Rollback on failure is Phase 1's (the agent) — not duplicated.
Tests — go build ./... && go vet ./... && go test ./... green
internal/selfupdate/floor_test.go:
- below-floor → updates to the floor ref (fake agent asserts
…:0.87.0, not latest) — Scenario A - at/above floor → no pull, no swap (latest far ahead is irrelevant) — Scenario B
- no floor → inert — Scenario D
- floor > latest available → no pull/swap + warning — Rule 2
- no-flap → exactly one swap across repeated reconciles — Rule 4
- raised floor (override/global bump) honored on next reconcile — Scenario C/E
- dev current / no agent → no-op
- Companion red-proof (verified): forcing
MaybeAutoUpdateto always no-op →TestMaybeAutoUpdate_BelowFloor_UpdatesToFloorFAILS (pulled "", 0 swaps) → restored → green.
Live test — floor auto-update 0.86.0 → 0.87.0 on demo 9201
(Filled after the live run — see the "Build / deploy / LIVE test" section below.)
What was NOT done
- No agent binary change (reuses Phase 1 swap). The customer latest-only button is untouched. No new floor-polling endpoint/timer. No auto-update loop / while a swap is in flight.