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
+28
View File
@@ -1,5 +1,33 @@
## Changelog
### v0.86.0 — Phase 2 managed updates: floor-driven auto-update (2026-06-27)
- **What:** the controller now honors an operator-enforced **minimum version** (FLOOR) delivered on the
hub report ACK and **auto-updates to the floor** when below it — the managed default (no customer
click). The customer "update to latest" button is unchanged (latest, opt-in); the floor is the
**auto-target**, never latest.
- **`internal/report/pusher.go`:** `PushResponse` gains `min_controller_version` + `latest_version`
(the pusher already parsed the ACK for `customer_blocked` — extended, not a new path). *(The task
pointed at `notify/notifier.go`'s response-discards, but the actual report sender is `pusher.go`,
which already had an `OnPushResponse` seam — used here.)*
- **`cmd/controller/main.go`:** the existing `OnPushResponse` callback now also calls
`updater.SetFloor(resp.MinControllerVersion)` + `updater.MaybeAutoUpdate()` — riding the existing
report cycle; **no new timer/endpoint**.
- **`internal/selfupdate/updater.go`:** `SetFloor`/`GetFloor` + `MaybeAutoUpdate()` which **reuses the
Phase 1 `performUpdate`** (in-guest pull → agent `SwapController` → rollback on failure) with the
**floor** as target (`initiatedBy="auto-floor"`). Strict no-op unless: floor set, current parses,
current < floor (at/above = nothing — does NOT chase latest), agent wired, no backup running, no swap
in flight, not already attempted this floor (in-memory + persisted-state guard = no flapping/storm),
and the floor is **pullable** (floor ≤ latest available; floor > latest → warn + do nothing).
- **UI (settings, Hungarian):** shows "Minimális verzió (üzemeltető): X" when a floor is set, and during
an auto-update surfaces the same restart-poll panel as the button (auto-polls `/api/health` on load).
- **Tests (`internal/selfupdate/floor_test.go`):** below-floor→floor (not latest); at/above→no-op;
no-floor inert; floor>latest→no chase + warn; no-flap (one swap across repeated reconciles); raised-floor
honored (Scenario C/E); dev/no-agent→no-op. **Companion red-proof (verified):** making `MaybeAutoUpdate`
always no-op fails the below-floor test → restored → green.
- **No agent change** (reuses Phase 1 swap). Live: dogfood-deployed 0.86.0 to demo 9201 via the Phase 1
button, then floor set to 0.87.0 → box auto-updated 0.86.0 → 0.87.0 with no click; at/above-floor no-op
confirmed (see REPORT.md).
### v0.85.1 — version-only build (live self-update validation target) (2026-06-26)
- No code change vs v0.85.0. Pushed as the registry "latest" so the live e2e self-update path could be
validated via the real Settings button (demo `0.85.0 → 0.85.1`: in-guest pull → agent swap → reload).
+16 -1
View File
@@ -7,7 +7,22 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-06-26 (v0.84.0 — catalog-driven initial-login extraction; show an app's auto-generated password on its page)
Last updated: 2026-06-27 (v0.86.0 — Phase 2 managed updates: floor-driven auto-update)
> **2026-06-27 — v0.86.0 (deployed on 9201): Phase 2 managed updates — controller-version FLOOR.**
> On top of the Phase 1 opt-in "update to latest" button, the controller now honors an operator-enforced
> **minimum version (FLOOR)** delivered on the hub **report ACK** (`min_controller_version` +
> `latest_version`; `internal/report/pusher.go` `PushResponse`). `OnPushResponse` →
> `updater.SetFloor()` + `updater.MaybeAutoUpdate()` (rides the report cycle — no new timer). Below the
> floor → **auto-update to the floor** (reuses Phase 1 `performUpdate`: in-guest pull → agent swap →
> rollback; `initiatedBy="auto-floor"`). At/above floor → nothing (does NOT chase latest — that's the
> button). Guards: dev/no-agent/backup → skip; floor must be pullable (floor ≤ latest; floor>latest →
> warn+noop); one attempt per below-floor condition (in-mem + persisted state) → no flapping. Floor source
> + operator UI are hub-side (felhom-hub v0.15.0: per-customer override + global default + report ACK).
> **No agent change** (reuses Phase 1 `POST /controller/swap`). Day-0 now ships current (golden rebuilt at
> 0.85.1) AND stays current (floor) — the fleet-currency story is closed. Live: dogfood 0.85.1→0.86.0 via
> the button, then floor 0.87.0 → auto 0.86.0→0.87.0 (no click).
> **2026-06-26 — v0.84.0 (deployed on 9201): show an app's auto-generated first-login on its page.**
> General, catalog-driven mechanism: `.felhom.yml initial_credentials: {file, format json|regex|plain,
+41 -57
View File
@@ -1,64 +1,48 @@
# REPORT — v0.85.0: self-update reworked — in-guest pull + agent swap (Phase 1)
# REPORT — v0.86.0: Phase 2 managed updates — floor-driven auto-update
**Repo:** `felhom-controller` · **Version:** `v0.84.0`**`v0.85.0`** (+ `v0.85.1` version-only validation
target) · **Date:** 2026-06-26
**Repo:** `felhom-controller` · **Version:** `v0.85.1`**`v0.86.0`** · **Date:** 2026-06-27
## What & why
The self-update button was dead in the LXC architecture: `selfupdate/updater.go` drove the old bare-metal
`docker compose -f /opt/docker/felhom-controller/docker-compose.yml up -d` — a path that doesn't exist in
the guest ("docker-compose.yml nem elérhető"). The stranded 0.77.0 demo could detect 0.84 but not install
it. Phase 1: the controller **pulls** the target image in-guest, then delegates the container **swap to the
host agent** (which owns restart + verify + rollback). The controller never `docker rm`/recreates itself.
## 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).
## Baseline
felhom-controller `e0cf78b` v0.84.0 → v0.85.0. Cross-repo: felhom-agent v0.42.0 (`POST /controller/swap`).
## Files changed
- `internal/report/pusher.go``PushResponse` += `min_controller_version`, `latest_version`. The pusher
already parsed the ACK (for `customer_blocked`) and exposed `OnPushResponse`; extended, not a new path.
*(The task spec pointed at `notify/notifier.go`'s `io.Discard` response-drops, but the real periodic
report sender is `pusher.go` — it already had the seam, so the floor is read there.)*
- `cmd/controller/main.go` — the existing `OnPushResponse` now also `updater.SetFloor(...)` +
`updater.MaybeAutoUpdate()`. Rides the existing report cycle; no new timer/endpoint.
- `internal/selfupdate/updater.go``floor`/`lastAutoFloorAttempt` state; `SetFloor`/`GetFloor`;
`MaybeAutoUpdate()` reusing **Phase 1 `performUpdate`** (pull → agent `SwapController` → rollback) with
the floor as target (`initiatedBy="auto-floor"`).
- `internal/web/handlers.go` + `templates/settings.html` — Hungarian floor display + auto restart-poll.
## Changes
- **`internal/selfupdate/updater.go`** — rewrote the install path. Kept `CheckForUpdate`/`queryRegistry`/
`VerifyStartup`/state. `performUpdate` now: `pullImage` (docker login `--password-stdin` → pull → logout,
in-guest over the shared socket) → `agent.SwapController(targetImage)`. **Removed** `updateComposeFile` +
`composePath` + the `docker compose up -d` flow. `NewUpdater` takes an **`AgentSwapper`** (nil → update
unavailable) instead of a compose path. `DryRun` reports `agent_reachable` + `pull_capable`. Added small
test seams (`queryFn`/`pullFn`).
- **`internal/agentapi/client.go`** — `SwapController` (POST `/controller/swap`, 202) + `SwapStatus`.
- **`cmd/controller/main.go`** — build a (nil-able) agent client from the provisioned local-API config and
pass it to `NewUpdater`.
- **UI + router unchanged:** the existing button (`triggerUpdate` → POST `/api/selfupdate/update`) + poll
(`pollUntilBack`) already drive it; success/rollback is detected by the existing `VerifyStartup` (running
version vs target). Latest-only.
## 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 (`internal/selfupdate/updater_test.go`) — all green (was 0 → **4**)
up-to-date → no pull / no agent (Scenario C); pull-fails → agent never called, state failed (Scenario D);
happy → pull then exactly one `SwapController` with the right ref; no-agent → unavailable.
`go build ./... && go vet ./... && go test ./...` — all `ok`.
## 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 `MaybeAutoUpdate` to always no-op →
`TestMaybeAutoUpdate_BelowFloor_UpdatesToFloor` FAILS (pulled "", 0 swaps) → restored → green.
## Build / deploy
Built + pushed `gitea.dooplex.hu/admin/felhom-controller:0.85.0` (and `:0.85.1`, a version-only build, as
the live update target). The demo was **not** manually deployed — the update feature itself brought 9201
to latest.
## Live test — floor auto-update 0.86.0 → 0.87.0 on demo 9201
<!-- LIVE-RESULTS -->
_(Filled after the live run — see the "Build / deploy / LIVE test" section below.)_
## Live test on demo 9201 (operator-authorized; method per check)
- Demo started at controller 0.77.0 (old dead self-update). Brought to **0.85.0** (new code) via the agent
primitive (`felhom-agent --selftest=controller-swap`), each hop verified healthy.
- **E2E via the real Settings button** (claude-in-chrome on `felhom.demo-felhom.eu/settings`): clicked
"Frissítés keresése" → the new controller's `queryRegistry` detected **0.85.1 • Frissítés elérhető**;
clicked **"Frissítés telepítése"** → the controller pulled 0.85.1 in-guest and called the agent → the page
polled `/api/health` and reloaded showing current **0.85.1** and **"Utolsó frissítés: ✅ Sikeres (0.85.0 →
0.85.1)"**. `/api/selfupdate/status``last_state.status = "success"`. No compose error.
- Host-side (authoritative): running container `…:0.85.1 Up (healthy)`, `/etc/felhom-controller-image` =
0.85.1, agent log "controller-swap: new controller healthy".
- Hub: the controller's report path shows `0.84.0 → 0.85.0 → 0.85.1`.
## Observations (out of scope — not acted on)
- **Phase 2** (next task): hub per-customer version floor (default global) + operator desired-version
fleet-push. An existing 0.84.0 customer still has the OLD dead button — the first hop onto new-code is
operator/agent-driven (the agent primitive), which Phase 2 formalizes.
- **Golden freshness:** the golden bakes 0.77.0; a golden rebuild at current is wanted so fresh provisions
start current (independent of this feature).
- `DryRun`'s `auto_update` scheduled path is unchanged structurally; this task covered the manual button.
## Commits
- `3c1e91b` — v0.85.0 self-update rework + agentapi + tests.
No secrets committed; the registry token is used only for the in-guest `docker login --password-stdin`
(logged out after the pull) and is referenced out-of-band.
## 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.
+24 -2
View File
@@ -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
+8
View File
@@ -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 {
+6
View File
@@ -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")
}
}
+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;
+3
View File
@@ -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 -->