docs: v0.85.0 self-update rework — README/CHANGELOG/REPORT (live-validated)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtXesNa2LGbMmE4DNL6SE7
This commit is contained in:
2026-06-26 21:43:23 +02:00
parent 3c1e91b5f0
commit 3aa9777f1c
3 changed files with 78 additions and 55 deletions
+4
View File
@@ -1,5 +1,9 @@
## Changelog ## Changelog
### 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).
### v0.85.0 — Self-update reworked: in-guest pull + agent swap (Phase 1) (2026-06-26) ### v0.85.0 — Self-update reworked: in-guest pull + agent swap (Phase 1) (2026-06-26)
- **Problem:** the self-update button was dead in the LXC architecture — `selfupdate/updater.go` drove - **Problem:** the self-update button was dead in the LXC architecture — `selfupdate/updater.go` drove
the old bare-metal flow (`docker compose -f /opt/docker/felhom-controller/docker-compose.yml up -d`), the old bare-metal flow (`docker compose -f /opt/docker/felhom-controller/docker-compose.yml up -d`),
+56 -49
View File
@@ -1,57 +1,64 @@
# REPORT — Catalog-driven initial-login extraction (show an app's auto-generated password) # REPORT — v0.85.0: self-update reworked — in-guest pull + agent swap (Phase 1)
**Repo:** `felhom-controller` · **Version:** `v0.83.0`**`v0.84.0`** · **Date:** 2026-06-26 **Repo:** `felhom-controller` · **Version:** `v0.84.0`**`v0.85.0`** (+ `v0.85.1` version-only validation
**Commit:** `1705d71` · **Paired catalog commit:** `app-catalog-felhom.eu` `8fd9c00` target) · **Date:** 2026-06-26
**Deployed:** guest 9201 (`demo-felhom`) on `felhom-pve`, golden/bootstrap mechanism.
## Problem ## What & why
Some apps generate a random first-login password into a file at first boot instead of taking it from a The self-update button was dead in the LXC architecture: `selfupdate/updater.go` drove the old bare-metal
deploy field — Crafty writes one to `/crafty/app/config/default-creds.txt` (its built-in default is `docker compose -f /opt/docker/felhom-controller/docker-compose.yml up -d` — a path that doesn't exist in
rejected as "too short"). Customers had to read the container logs to find it; the static the guest ("docker-compose.yml nem elérhető"). The stranded 0.77.0 demo could detect 0.84 but not install
`app_info.default_creds` hint can't carry a per-install secret. 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.
## Fix — a general, catalog-driven mechanism (Crafty is the first consumer) ## Baseline
- **`.felhom.yml`** gains an optional `initial_credentials` block: felhom-controller `e0cf78b` v0.84.0 → v0.85.0. Cross-repo: felhom-agent v0.42.0 (`POST /controller/swap`).
`{file, format: json|regex|plain, container?, username_key/password_key (json),
username_pattern/password_pattern (regex), note}`.
- **`internal/stacks/metadata.go`** — new `InitialCredentials` struct + `Metadata.InitialCreds`
(deep-copied in `deepCopyStack`).
- **`internal/stacks/initialcreds.go`** — `ReadInitialCredentials(stack)` reads the file **live** from
the running container (`docker exec <c> cat <file>`; path passed as a single arg, no shell) and parses
it via the pure, unit-tested `parseInitialCreds` (json/regex/plain). Container defaults to the stack's
main container (`findProbeContainer`). Returns a non-Available result (card hidden) when the container
is down / file missing / parse fails. **Never persists the secret** to `app.yaml` — read on demand.
- **`internal/web/handlers.go`** — `appDetailHandler` populates `InitialCreds` for deployed apps with a
spec; **`app_info.html`** renders a "Kezdeti belépési adatok" card: username + masked password with
Megjelenítés/Másolás (value read from a hidden element — never inlined into a JS literal), clearly
labelled as the **initial** password (valid only until the customer changes it in-app).
- **Tests:** `parseInitialCreds` json (+ default key) / regex (+ missing-pattern) / plain / unknown-format.
`go build`, `go vet`, `go test ./internal/stacks/...` all green.
## Catalog (`8fd9c00`) ## Changes
crafty-controller `.felhom.yml` gains the `initial_credentials` block (json, username/password keys, a - **`internal/selfupdate/updater.go`** — rewrote the install path. Kept `CheckForUpdate`/`queryRegistry`/
note) and `first_steps` now points at the app page instead of "find it in the logs". `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.
## Security posture ## Tests (`internal/selfupdate/updater_test.go`) — all green (was 0 → **4**)
This surfaces a **live working credential** on the app page — same exposure class as the existing up-to-date → no pull / no agent (Scenario C); pull-fails → agent never called, state failed (Scenario D);
post-deploy password reveal and the `default_creds` card, and rendered with the same mask/reveal UX. It happy → pull then exactly one `SwapController` with the right ref; no-agent → unavailable.
relies on the dashboard being **auth-gated in production**; the demo's public-unauth dashboard is a `go build ./... && go vet ./... && go test ./...` — all `ok`.
separate, pre-existing tracked issue (not introduced here). The secret is never written to controller
state — only read live from the volume where it already lives.
## Verification (live, guest 9201, controller v0.84.0) ## Build / deploy
| Check | Result | 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
| Spec synced + parsed | on-disk `.felhom.yml` carries `initial_credentials`; `/api/stacks/crafty-controller` meta shows it | to latest.
| Card renders | `/apps/crafty-controller` HTML contains the "Kezdeti belépési adatok" card |
| Username | `initcred-user` cell renders `admin` |
| Password (live-extracted) | hidden value element carries the exact password from the container's `default-creds.txt` (HTML-escaped `&``&amp;`, decoded back by JS `textContent`) |
| Controls | `Felhasználónév` label + `icRevealPw`/`icCopyPw` reveal/copy wired |
**Method:** fetched the exact HTML the browser receives via the in-guest API (Host: felhom.demo-felhom.eu). ## Live test on demo 9201 (operator-authorized; method per check)
The reveal/copy is client-side JS — wiring confirmed in markup, not browser-clicked this run. - 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`.
## Observation (carry, not acted on) ## Observations (out of scope — not acted on)
The whole-dashboard public-unauth exposure on the demo remains the right place to fix credential - **Phase 2** (next task): hub per-customer version floor (default global) + operator desired-version
visibility broadly — tracked separately; this feature should not be read as endorsing an unauthenticated fleet-push. An existing 0.84.0 customer still has the OLD dead button — the first hop onto new-code is
dashboard. 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.
+18 -6
View File
@@ -1065,18 +1065,30 @@ Notification preferences (email, enabled events, cooldown hours) are:
#### Controller Self-Update (`internal/selfupdate/`) #### Controller Self-Update (`internal/selfupdate/`)
The controller can update itself — a Watchtower-style pull-and-restart mechanism for a single container. Replaces manual SSH-based `docker pull + sed + docker compose up -d` with a one-click Settings page button or scheduled auto-update. The controller can update itself to the latest registry version with a one-click Settings button. In the
LXC architecture there is **no in-container compose** to drive (the old `docker compose -f
/opt/docker/felhom-controller/docker-compose.yml up -d` path does not exist in the guest — it produced
"docker-compose.yml nem elérhető"). Instead (Phase 1, v0.85.0) the controller **pulls** the target image
in-guest and delegates the container **swap to the host agent**, which owns the restart + health-verify +
**rollback**.
##### How It Works ##### How It Works
``` ```
1. Check Gitea Docker Registry V2 API for new image tags 1. Check Gitea Docker Registry V2 API for the latest semver tag (queryRegistry, BasicAuth).
2. Compare highest semver tag with current Version (set at build time via ldflags) 2. If newer than current (ldflags Version): docker login --password-stdin → docker pull <image>
3. If newer version exists → pull image → update compose file → docker compose up -d → docker logout — IN-GUEST over the shared docker socket (token via stdin, never argv).
4. Current container is replaced by Docker → new container starts with new version 3. Delegate to the host agent: agentapi.SwapController → POST /controller/swap {image} (202). The agent
5. On startup, new container reads update-state.json → marks update success/failure (external to this container) rewrites /etc/felhom-controller-image, restarts
felhom-controller-bootstrap.service, polls the new controller to healthy, and ROLLS BACK to the
previous image if it doesn't come up. The controller never docker-rm/recreates itself.
4. On startup the new container reads update-state.json → VerifyStartup marks success (current==target)
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.
##### Design Philosophy ##### Design Philosophy
- **No automatic rollback** — follows the Watchtower pattern (24k+ GitHub stars, no rollback). Docker's `restart: unless-stopped` policy is the crash safety net. The Hub's dead man's switch detects when the controller goes down. - **No automatic rollback** — follows the Watchtower pattern (24k+ GitHub stars, no rollback). Docker's `restart: unless-stopped` policy is the crash safety net. The Hub's dead man's switch detects when the controller goes down.