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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user