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
### 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)
- **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`),
+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
**Commit:** `1705d71` · **Paired catalog commit:** `app-catalog-felhom.eu` `8fd9c00`
**Deployed:** guest 9201 (`demo-felhom`) on `felhom-pve`, golden/bootstrap mechanism.
**Repo:** `felhom-controller` · **Version:** `v0.84.0`**`v0.85.0`** (+ `v0.85.1` version-only validation
target) · **Date:** 2026-06-26
## Problem
Some apps generate a random first-login password into a file at first boot instead of taking it from a
deploy field — Crafty writes one to `/crafty/app/config/default-creds.txt` (its built-in default is
rejected as "too short"). Customers had to read the container logs to find it; the static
`app_info.default_creds` hint can't carry a per-install secret.
## 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.
## Fix — a general, catalog-driven mechanism (Crafty is the first consumer)
- **`.felhom.yml`** gains an optional `initial_credentials` block:
`{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.
## Baseline
felhom-controller `e0cf78b` v0.84.0 → v0.85.0. Cross-repo: felhom-agent v0.42.0 (`POST /controller/swap`).
## Catalog (`8fd9c00`)
crafty-controller `.felhom.yml` gains the `initial_credentials` block (json, username/password keys, a
note) and `first_steps` now points at the app page instead of "find it in the logs".
## 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.
## Security posture
This surfaces a **live working credential** on the app page — same exposure class as the existing
post-deploy password reveal and the `default_creds` card, and rendered with the same mask/reveal UX. It
relies on the dashboard being **auth-gated in production**; the demo's public-unauth dashboard is a
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.
## 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`.
## Verification (live, guest 9201, controller v0.84.0)
| Check | Result |
|---|---|
| Spec synced + parsed | on-disk `.felhom.yml` carries `initial_credentials`; `/api/stacks/crafty-controller` meta shows it |
| 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 |
## 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.
**Method:** fetched the exact HTML the browser receives via the in-guest API (Host: felhom.demo-felhom.eu).
The reveal/copy is client-side JS — wiring confirmed in markup, not browser-clicked this run.
## 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`.
## Observation (carry, not acted on)
The whole-dashboard public-unauth exposure on the demo remains the right place to fix credential
visibility broadly — tracked separately; this feature should not be read as endorsing an unauthenticated
dashboard.
## 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.
+18 -6
View File
@@ -1065,18 +1065,30 @@ Notification preferences (email, enabled events, cooldown hours) are:
#### 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
```
1. Check Gitea Docker Registry V2 API for new image tags
2. Compare highest semver tag with current Version (set at build time via ldflags)
3. If newer version exists → pull image → update compose file → docker compose up -d
4. Current container is replaced by Docker → new container starts with new version
5. On startup, new container reads update-state.json → marks update success/failure
1. Check Gitea Docker Registry V2 API for the latest semver tag (queryRegistry, BasicAuth).
2. If newer than current (ldflags Version): docker login --password-stdin → docker pull <image>
→ docker logout — IN-GUEST over the shared docker socket (token via stdin, never argv).
3. Delegate to the host agent: agentapi.SwapController → POST /controller/swap {image} (202). The agent
(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
- **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.