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:
+24
-2
@@ -1086,8 +1086,30 @@ in-guest and delegates the container **swap to the host agent**, which owns the
|
||||
or failure (rollback → version mismatch). The Settings button polls /api/health and reloads.
|
||||
```
|
||||
|
||||
Latest-only (no version picker). Per-customer version floor + operator desired-version fleet-push are
|
||||
Phase 2 (hub-side). No host agent wired (un-provisioned guest) → self-update unavailable.
|
||||
The button is latest-only (no version picker) and opt-in. No host agent wired (un-provisioned guest) →
|
||||
self-update unavailable.
|
||||
|
||||
##### Phase 2 — managed updates: the version FLOOR (v0.86.0)
|
||||
|
||||
On top of the opt-in button, the controller now honors an operator-enforced **minimum version (FLOOR)**.
|
||||
The hub returns the customer's *effective* floor (per-customer override else a global default) on the
|
||||
**report ACK** (`min_controller_version`, alongside `latest_version`). The controller's report pusher
|
||||
(`internal/report/pusher.go`, `OnPushResponse`) hands the floor to the updater (`SetFloor`) and calls
|
||||
`MaybeAutoUpdate()` — **on the existing report cycle, no new timer/endpoint**:
|
||||
|
||||
- If the box is **below** the floor it **auto-updates to the floor** (not latest) by reusing the Phase 1
|
||||
flow above (`performUpdate`, `initiatedBy="auto-floor"`) — same pull → agent swap → rollback. No
|
||||
customer click.
|
||||
- **At/above** the floor: nothing (it does **not** chase latest — that's the button's job).
|
||||
- Guards: dev build / no agent / backup running → skip; floor must be **pullable** (floor ≤ latest
|
||||
available; floor > latest → warn + do nothing); one attempt per below-floor condition (in-memory flag
|
||||
+ persisted `update-state.json`) → **no flapping/storm**.
|
||||
- Settings UI shows "Minimális verzió (üzemeltető): X" and, during an auto-update, the same restart-poll
|
||||
panel as the button.
|
||||
|
||||
The floor is the **auto-target** (the operator raises it for a controlled fleet rollout); latest stays
|
||||
the customer's manual opt-in. Floor source + operator UI are hub-side (felhom-hub v0.15.0). **No agent
|
||||
change — Phase 2 reuses the Phase 1 `POST /controller/swap`.**
|
||||
|
||||
##### Design Philosophy
|
||||
|
||||
|
||||
@@ -329,6 +329,14 @@ func main() {
|
||||
} else {
|
||||
sett.SetHubVerified(true, time.Now())
|
||||
}
|
||||
// Phase 2 managed updates: the ACK carries the operator-enforced minimum version (FLOOR).
|
||||
// Hand it to the updater and reconcile — if the box is below the floor it auto-updates to
|
||||
// the floor (reusing the Phase 1 swap). This rides the existing report cycle; no new timer.
|
||||
// latest_version stays informational (the customer's opt-in button), NOT the auto-target.
|
||||
if updater != nil {
|
||||
updater.SetFloor(resp.MinControllerVersion)
|
||||
updater.MaybeAutoUpdate()
|
||||
}
|
||||
}
|
||||
// Wire hub push status into alert manager for dashboard alerts
|
||||
alertMgr.SetHubPushStatus(func() web.HubPushStatusData {
|
||||
|
||||
@@ -26,6 +26,12 @@ type PushStatus struct {
|
||||
type PushResponse struct {
|
||||
Status string `json:"status"`
|
||||
CustomerBlocked bool `json:"customer_blocked"`
|
||||
// Phase 2 managed updates: the effective controller-version FLOOR (the operator's enforced
|
||||
// minimum) and the latest available version. Empty when the hub has none configured / is old.
|
||||
// The controller auto-updates to the floor when below it (latest stays the customer's opt-in
|
||||
// "update to latest" button — never the auto-target).
|
||||
MinControllerVersion string `json:"min_controller_version"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
}
|
||||
|
||||
// Pusher sends reports to the central hub.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Scenario A — below floor auto-updates to the FLOOR (not latest), reusing the Phase 1 swap.
|
||||
// This is the companion RED-PROOF: it must FAIL if MaybeAutoUpdate stops honoring the floor.
|
||||
func TestMaybeAutoUpdate_BelowFloor_UpdatesToFloor(t *testing.T) {
|
||||
agent := &fakeAgent{}
|
||||
u := newTestUpdater(t, "0.86.0", agent)
|
||||
// Registry latest is 0.87.0 (so the floor is pullable). Auto-target must be the FLOOR, not latest.
|
||||
u.queryFn = func() (string, error) { return "0.87.0", nil }
|
||||
var pulled string
|
||||
u.pullFn = func(img string) error { pulled = img; return nil }
|
||||
|
||||
u.SetFloor("0.87.0")
|
||||
u.MaybeAutoUpdate()
|
||||
waitDone(t, u)
|
||||
|
||||
want := imageBase + ":0.87.0"
|
||||
if pulled != want {
|
||||
t.Errorf("pulled %q, want %q (the floor)", pulled, want)
|
||||
}
|
||||
calls := agent.swapCalls()
|
||||
if len(calls) != 1 || calls[0] != want {
|
||||
t.Errorf("agent swap calls = %v, want exactly [%q]", calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B — at/above floor: no pull, no swap. Must NOT update even though latest > current.
|
||||
func TestMaybeAutoUpdate_AtFloor_NoAction(t *testing.T) {
|
||||
agent := &fakeAgent{}
|
||||
u := newTestUpdater(t, "0.87.0", agent)
|
||||
u.queryFn = func() (string, error) { return "0.99.0", nil } // latest far ahead — irrelevant
|
||||
pulled := false
|
||||
u.pullFn = func(string) error { pulled = true; return nil }
|
||||
|
||||
u.SetFloor("0.87.0") // current == floor
|
||||
u.MaybeAutoUpdate()
|
||||
|
||||
if pulled {
|
||||
t.Error("must NOT pull when at/above floor (latest>current is the customer's button, not the floor)")
|
||||
}
|
||||
if n := len(agent.swapCalls()); n != 0 {
|
||||
t.Errorf("agent called %d times, want 0 (at floor)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario D — no floor set: inert.
|
||||
func TestMaybeAutoUpdate_NoFloor_Inert(t *testing.T) {
|
||||
agent := &fakeAgent{}
|
||||
u := newTestUpdater(t, "0.86.0", agent)
|
||||
u.queryFn = func() (string, error) { return "0.99.0", nil }
|
||||
pulled := false
|
||||
u.pullFn = func(string) error { pulled = true; return nil }
|
||||
|
||||
u.SetFloor("") // explicitly none
|
||||
u.MaybeAutoUpdate()
|
||||
|
||||
if pulled || len(agent.swapCalls()) != 0 {
|
||||
t.Errorf("no floor must be inert; pulled=%v swaps=%d", pulled, len(agent.swapCalls()))
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2 — floor exceeds the latest available tag (operator misconfig): do nothing (don't chase a
|
||||
// non-existent image).
|
||||
func TestMaybeAutoUpdate_FloorAboveLatest_NoChase(t *testing.T) {
|
||||
agent := &fakeAgent{}
|
||||
u := newTestUpdater(t, "0.86.0", agent)
|
||||
u.queryFn = func() (string, error) { return "0.87.0", nil } // latest available
|
||||
pulled := false
|
||||
u.pullFn = func(string) error { pulled = true; return nil }
|
||||
|
||||
u.SetFloor("0.88.0") // floor > latest available
|
||||
u.MaybeAutoUpdate()
|
||||
|
||||
if pulled {
|
||||
t.Error("must NOT pull when the floor exceeds the latest available tag")
|
||||
}
|
||||
if n := len(agent.swapCalls()); n != 0 {
|
||||
t.Errorf("agent called %d times, want 0 (floor>latest)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4 — no flapping/storm: repeated reconciles for the same below-floor condition trigger exactly
|
||||
// one auto-update.
|
||||
func TestMaybeAutoUpdate_NoFlap(t *testing.T) {
|
||||
agent := &fakeAgent{}
|
||||
u := newTestUpdater(t, "0.86.0", agent)
|
||||
u.queryFn = func() (string, error) { return "0.87.0", nil }
|
||||
u.pullFn = func(string) error { return nil }
|
||||
|
||||
u.SetFloor("0.87.0")
|
||||
u.MaybeAutoUpdate()
|
||||
waitDone(t, u)
|
||||
// Simulate further report cycles with the same floor + still-below current.
|
||||
u.SetFloor("0.87.0")
|
||||
u.MaybeAutoUpdate()
|
||||
u.SetFloor("0.87.0")
|
||||
u.MaybeAutoUpdate()
|
||||
waitDone(t, u)
|
||||
|
||||
if n := len(agent.swapCalls()); n != 1 {
|
||||
t.Errorf("agent swap calls = %d, want exactly 1 (no flapping)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C / E — precedence is reflected by whatever floor the notifier sets: a raised floor (e.g.
|
||||
// an operator override or a global bump) is honored on the next reconcile.
|
||||
func TestMaybeAutoUpdate_HonorsRaisedFloor(t *testing.T) {
|
||||
agent := &fakeAgent{}
|
||||
u := newTestUpdater(t, "0.86.0", agent)
|
||||
u.queryFn = func() (string, error) { return "0.87.0", nil }
|
||||
var pulled []string
|
||||
u.pullFn = func(img string) error { pulled = append(pulled, img); return nil }
|
||||
|
||||
// First the global floor equals current → no action.
|
||||
u.SetFloor("0.86.0")
|
||||
u.MaybeAutoUpdate()
|
||||
if len(agent.swapCalls()) != 0 {
|
||||
t.Fatalf("floor==current should not update")
|
||||
}
|
||||
// Operator raises the floor (override or global bump) → now auto-updates to the new floor.
|
||||
u.SetFloor("0.87.0")
|
||||
u.MaybeAutoUpdate()
|
||||
waitDone(t, u)
|
||||
|
||||
want := imageBase + ":0.87.0"
|
||||
calls := agent.swapCalls()
|
||||
if len(calls) != 1 || calls[0] != want {
|
||||
t.Errorf("agent swap calls = %v, want [%q] after raised floor", calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A dev build never auto-updates (can't compare versions).
|
||||
func TestMaybeAutoUpdate_DevCurrent_NoAction(t *testing.T) {
|
||||
agent := &fakeAgent{}
|
||||
u := newTestUpdater(t, "dev", agent)
|
||||
u.queryFn = func() (string, error) { return "0.87.0", nil }
|
||||
pulled := false
|
||||
u.pullFn = func(string) error { pulled = true; return nil }
|
||||
|
||||
u.SetFloor("0.87.0")
|
||||
u.MaybeAutoUpdate()
|
||||
|
||||
if pulled || len(agent.swapCalls()) != 0 {
|
||||
t.Errorf("dev build must not auto-update; pulled=%v swaps=%d", pulled, len(agent.swapCalls()))
|
||||
}
|
||||
}
|
||||
|
||||
// No agent (un-provisioned guest) → no auto-update.
|
||||
func TestMaybeAutoUpdate_NoAgent_NoAction(t *testing.T) {
|
||||
u := newTestUpdater(t, "0.86.0", nil)
|
||||
u.queryFn = func() (string, error) { return "0.87.0", nil }
|
||||
pulled := false
|
||||
u.pullFn = func(string) error { pulled = true; return nil }
|
||||
|
||||
u.SetFloor("0.87.0")
|
||||
u.MaybeAutoUpdate()
|
||||
|
||||
if pulled {
|
||||
t.Error("must not pull when no agent is wired")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -874,6 +874,9 @@ func (s *Server) settingsData() map[string]interface{} {
|
||||
}
|
||||
data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate
|
||||
data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime
|
||||
// Phase 2 managed updates: the operator-enforced minimum version (FLOOR) the box auto-updates
|
||||
// to. Empty = none set by the operator.
|
||||
data["ControllerFloor"] = s.updater.GetFloor()
|
||||
}
|
||||
|
||||
data["NotificationPrefs"] = s.settings.GetNotificationPrefs()
|
||||
|
||||
@@ -99,6 +99,21 @@
|
||||
{{if .AutoUpdateEnabled}}<span class="state-text-green">✅ Aktív</span> <span class="mono">({{.AutoUpdateTime}})</span>{{else}}–{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{if .ControllerFloor}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Minimális verzió (üzemeltető)</span>
|
||||
<span class="settings-value mono">
|
||||
{{.ControllerFloor}}
|
||||
<span style="margin-left:0.5em; color:#888;">— a rendszer automatikusan erre a verzióra frissít, ha régebbi</span>
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .UpdateRunning}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Állapot</span>
|
||||
<span class="settings-value state-text-yellow" id="auto-update-status">⏳ Frissítés folyamatban — a vezérlő hamarosan újraindul…</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{with .LastUpdateState}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Utolsó frissítés</span>
|
||||
@@ -192,6 +207,12 @@ function pollUntilBack() {
|
||||
.catch(function() {});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// If an update is already in flight when the page loads (e.g. a floor-driven AUTO-update kicked off by
|
||||
// the hub, not a button click), surface the same restart-poll panel so the page recovers itself.
|
||||
{{if .UpdateRunning}}
|
||||
pollUntilBack();
|
||||
{{end}}
|
||||
</script>
|
||||
|
||||
<!-- Section: Storage Paths -->
|
||||
|
||||
Reference in New Issue
Block a user