v0.86.0: Phase 2 managed updates — floor-driven auto-update

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
This commit is contained in:
2026-06-27 11:59:47 +02:00
parent 3aa9777f1c
commit 1310a0ebd7
10 changed files with 445 additions and 60 deletions
+133
View File
@@ -57,6 +57,12 @@ type Updater struct {
updateRunning bool
backupRunning func() bool
// Phase 2 managed updates: the operator-enforced minimum version (FLOOR), learned from the hub
// report ACK, and the last floor we already auto-attempted (no flapping within this process; the
// persisted UpdateState guards across restarts). Auto-target is ALWAYS the floor, never latest.
floor string
lastAutoFloorAttempt string
// Seams (default to the real implementations; overridden in tests to avoid network/docker).
queryFn func() (string, error) // resolve latest registry tag (default u.queryRegistry)
pullFn func(targetImage) error // pull the image in-guest (default u.pullImage)
@@ -348,6 +354,133 @@ func (u *Updater) TriggerUpdate(initiatedBy string) error {
return nil
}
// SetFloor records the operator-enforced minimum controller version (the managed-update FLOOR),
// learned from the hub's report ACK. Empty clears it (Phase 2 inert for this box). Cheap + safe to
// call every report; pair it with MaybeAutoUpdate to reconcile.
func (u *Updater) SetFloor(version string) {
u.mu.Lock()
defer u.mu.Unlock()
if version != u.floor {
u.dbg("SetFloor: floor %q → %q", u.floor, version)
}
u.floor = version
}
// GetFloor returns the current floor (for the UI / status).
func (u *Updater) GetFloor() string {
u.mu.Lock()
defer u.mu.Unlock()
return u.floor
}
// MaybeAutoUpdate auto-updates the controller to the FLOOR when the box is below it — the managed
// default (no customer click). It REUSES the Phase 1 performUpdate flow (pull in-guest → agent swap
// → rollback on failure); it never adds a second swap path and never touches the agent binary.
//
// It is a strict no-op unless ALL hold:
// - a floor is set AND the current version parses (a dev build can't compare),
// - current < floor (Scenario B: at/above floor does NOTHING — we must NOT chase latest here),
// - an agent is wired (it performs the swap) and no backup is running,
// - no swap is in flight, we haven't already auto-attempted this exact floor in-process, and the
// persisted state doesn't already record this floor as attempted (no flapping/storm across the
// report cycle or a restart),
// - the floor is a real, pullable tag: floor <= latest available in the registry. If the floor
// EXCEEDS the latest available (operator misconfig) we log a warning and do nothing.
//
// Auto-target is ALWAYS the floor, never latest. Runs right after the floor is set (post-report) —
// no new timer/endpoint.
func (u *Updater) MaybeAutoUpdate() {
u.mu.Lock()
floor := u.floor
if floor == "" {
u.mu.Unlock()
return
}
if u.agent == nil {
u.mu.Unlock()
u.dbg("maybeAutoUpdate: no agent — auto-update unavailable")
return
}
if u.updateRunning {
u.mu.Unlock()
u.dbg("maybeAutoUpdate: an update is already running — skip")
return
}
curVer, err := ParseVersion(u.currentVer)
if err != nil {
u.mu.Unlock()
u.dbg("maybeAutoUpdate: current %q not parseable (dev?) — skip", u.currentVer)
return
}
floorVer, err := ParseVersion(floor)
if err != nil {
u.mu.Unlock()
u.logger.Printf("[WARN] [selfupdate] Floor %q is not a valid version — ignoring", floor)
return
}
// Scenario B — at/above floor: NOTHING. Must NOT update just because latest > current (that's the
// customer's opt-in button, not the floor's job).
if curVer.Compare(floorVer) >= 0 {
u.mu.Unlock()
u.dbg("maybeAutoUpdate: current %s >= floor %s — no action", u.currentVer, floor)
return
}
// No flapping (in-process): one auto-update per below-floor condition.
if u.lastAutoFloorAttempt == floor {
u.mu.Unlock()
u.dbg("maybeAutoUpdate: already auto-attempted floor %s this process — skip", floor)
return
}
u.mu.Unlock()
// No flapping (across restart): if the persisted state already records an attempt at THIS floor,
// don't re-trigger. A failed+rolled-back auto-update restarts this process (losing the in-memory
// flag), so without this a persistent failure would retry every report. A 'success' would already
// be caught by the at/above check; we still guard it for completeness.
if st, _ := LoadState(u.dataDir); st != nil && st.TargetVersion == floor &&
(st.Status == "failed" || st.Status == "success") {
u.dbg("maybeAutoUpdate: persisted state already records floor %s (status=%s) — skip", floor, st.Status)
return
}
// Validate the floor is PULLABLE: it must not exceed the latest available registry tag. We do not
// chase a non-existent image. queryFn is the same registry lookup Phase 1 uses.
latestStr, err := u.queryFn()
if err != nil {
u.logger.Printf("[WARN] [selfupdate] Auto-update: registry check failed (%v) — deferring floor %s", err, floor)
return
}
latestVer, err := ParseVersion(latestStr)
if err != nil {
u.logger.Printf("[WARN] [selfupdate] Auto-update: registry returned invalid latest %q — deferring floor %s", latestStr, floor)
return
}
if floorVer.Compare(latestVer) > 0 {
u.logger.Printf("[WARN] [selfupdate] Auto-update: floor %s exceeds latest available %s (operator misconfig?) — doing nothing", floor, latestStr)
return
}
// Commit: re-check under lock (a concurrent report may have started one) and claim the run.
u.mu.Lock()
if u.updateRunning || u.lastAutoFloorAttempt == floor || u.floor != floor {
u.mu.Unlock()
return
}
if u.backupRunning != nil && u.backupRunning() {
u.mu.Unlock()
u.dbg("maybeAutoUpdate: backup running — defer floor %s (will retry next report)", floor)
return
}
u.updateRunning = true
u.lastAutoFloorAttempt = floor
u.mu.Unlock()
targetImage := fmt.Sprintf("%s:%s", u.cfg.Image, floor)
previousImage := fmt.Sprintf("%s:%s", u.cfg.Image, u.currentVer)
u.logger.Printf("[INFO] [selfupdate] Auto-update to FLOOR: %s → %s (managed, no customer action)", u.currentVer, floor)
go u.performUpdate(floor, targetImage, previousImage, "auto-floor")
}
// performUpdate runs the actual update in a goroutine: pull the target image IN-GUEST (shared docker
// socket, our registry token), then delegate the container SWAP to the host agent (which owns the
// restart + verify + rollback). This controller process is expected to be killed when the agent swaps;