Files
felhom-controller/REPORT.md
T
2026-07-19 10:03:17 +02:00

171 lines
9.4 KiB
Markdown

# REPORT — controller v0.147.0 → v0.147.3: feedback slice 1 (2026-07-19)
**Baseline v0.146.0 → LIVE v0.147.3 on demo guest 9201.** Green at every version:
`go build ./... && go vet ./... && go test ./...` (23 packages) plus `template_id_gate`,
`emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`.
> `docker_run_volume_path_gate` fails on `internal/appexport/estimate.go:179`. **Pre-existing on
> HEAD and untouched by this release** — verified by stashing this work and re-running the gate.
> Not fixed here (out of scope, and it is someone's call whether that `-v :ro` du probe gets an
> allowlist entry or a rewrite).
## The problem
The systemic complaint, twice in one evening: **you press a button and nothing happens.** No
progress, no ETA, no named result. Three worst offenders fixed on the two patterns already in the
codebase — the deploy 3-step panel and the storage-init status poll. **No new framework**; that is
now ROADMAP **R-45**, because three targeted cards were worth shipping tonight and a framework was
not.
Three of the four versions below exist because the cards were watched against **real runs on the
demo box**. Each follow-up is a thing no unit test would have surfaced.
---
## 4a — a verification restore names its result
The completion flash said the app had been restored „ellenőrző mappába a meghajtón". *Which* folder,
on *which* drive, was invisible — so the customer could not go and look at the thing they had just
asked for. And nothing anywhere listed what these restores had accumulated, so copies piled up and
the only way to find them was SSH.
- The flash now carries the **full path**.
- The restore page gained **„Meglévő ellenőrző másolatok"** — app · size · date · path — each with a
double-confirmed **„Másolat törlése"** (the house inline `felhomConfirm` idiom, never native
`confirm()`).
- `backups/offsite-restore` was open-coded in three places; it now has one home
(`offsiteRestoreRootFor`). A test pins the path in the flash to the path in the listing, so the
customer can never be told about a directory the page cannot show or remove.
**The delete is the only one this release adds**, so it names a **stack, never a path**: the Manager
resolves that name inside a root it computed itself and refuses anything landing outside.
Red-proofed — neutralise the name guard and `stack: ""` resolves to the offsite-restore **root** and
`os.RemoveAll` takes every copy with it. Every refusal is asserted as a **non-effect** (the
neighbouring copy and the live data are still on disk afterwards).
Honest note: the `HasPrefix` containment check behind that name guard could **not** be red-proofed
independently — with `isSafeStackName` in front of it, no input the API accepts can reach it with an
escaping path. It is kept as defence-in-depth and labelled in the test file as unproven-by-test
rather than dressed up as a red-proof it does not have.
### Live (endpoint-exact, through the real handlers)
Real verification restore of `calibre-web`, then the guard matrix:
```
message: "A(z) calibre-web visszaállítva ellenőrző mappába:
/mnt/felhom-drives/hdd_1/backups/offsite-restore/calibre-web
(a meglévő adatok változatlanok)."
listing: calibre-web · 430.4 KB · …/offsite-restore/calibre-web · [Másolat törlése]
stack=../primary -> refused; copy AND backups/primary both still present
missing confirm -> refused; nothing changed
real delete -> copy gone; backups/primary SURVIVES; page -> „Nincs ellenőrző másolat"
```
---
## 4b — Megosztás enable shows what it is waiting for
Enabling sharing ran `ReconcileSamba()` **synchronously inside the POST handler**. On a box whose
golden had not baked `felhom-samba` that is `compose up -d` pulling ~100MB from a private registry:
minutes of an apparently-hung form post, then „Beállítás mentve." whether or not anything came up.
Detached + polled now, with a card that distinguishes **„képfájl letöltése"** (image genuinely
absent — the multi-minute case) from **„indítás"** (already baked — seconds). The distinction is
decided *before* the work starts, because afterwards the image is always present and the card could
never truthfully say „letöltés".
- Success is **probed, not inferred**`compose up -d` exits 0 on a crash-loop, so the terminal
state is container liveness.
- `nil` from reconcile also covers "deliberately deployed nothing because there is no household
password yet"; that now gets its own message instead of a card spinning forever.
- The **password** form starts the same job — with `UserSet` false reconcile deploys nothing, so on a
fresh box *that*, not the enable toggle, is where the pull actually happens.
**Test scope, stated honestly:** the unit tests cover the single-flight slot, snapshot isolation and
the phase vocabulary. They do **not** drive `runSambaEnsureJob` end-to-end — the Server field is the
concrete `*stacks.Manager`, not an interface, and introducing one purely for this card was more churn
than the slice warranted. Pulling-vs-starting and the probed terminal state are covered by live
validation instead. That seam belongs to R-45.
Live: `/sharing/status` returns `{"ok":true,"data":{"phase":"running","running":true,"error":""}}`;
the page carries the card and the poll.
---
## 4c — „Távoli mentés most" streams real progress
restic was already reporting bytes and percentages; the runner seam used `CombinedOutput()` and threw
them away. The manual run now passes `--json`, scans stdout line-by-line, and the page shows total
bytes, percent and the app being pushed. **Manual only** — the nightly stays silent, pinned by a test
that fails if the scheduled path ever passes `--json` or publishes progress.
The poll also now **arms unconditionally**. It used to start only if the page already rendered
„Fut…", which loses a race the manual trigger always runs: the POST redirects and the page renders
before the detached goroutine writes `LastStatus=running`, so the poll never armed and the customer
watched a static page during the very run they had just started.
Red-proofed twice: break the parser -> the percent assertion fails; drop the wiring -> the `--json`
assertion fails.
### What three live runs actually taught (v0.147.1 -> .3)
**Run 1 — immich reported `0%` for 40+ seconds.** The parser was not broken. On an incremental run
where nothing changed restic transfers nothing: `bytes_done` is `omitempty`, so it is not even in the
JSON, and `percent_done` stays 0. Confirmed by capturing the real schema from
`backup --dry-run --json` on restic 0.14.0 in the controller image rather than guessing; those lines
are now quoted verbatim in the type's doc comment. **A byte-only bar is indistinguishable from a hang
in the common case** — shipping it would have traded "no feedback" for "feedback that says 0% and
looks stuck". -> **v0.147.1** parses and publishes `files_done`/`total_files`.
**Run 2 — bookstack showed clean byte progress (100%, 154.0 MB, 7/7), immich sat at `1 of 46 files,
0 bytes` for 42 seconds.** restic 0.14 only counts a file when it **completes**, so an app dominated
by one large archive (immich's ~430MB volume tar) freezes *both* counters. No percentage can move.
-> **v0.147.2** shows what restic *does* keep reporting: the file being processed and elapsed
seconds. „1 / 46 fájl (430.2 MB) · feldolgozás alatt: immich_upload.tar · 42 mp" is a different
message from „0%".
**Run 3 — the per-app legs finished in ~15s; the remaining 40 of a 57-second run was the shares leg
and `forget --prune`,** during which the card sat frozen on „calibre-web — 8 / 8 fájl". The same
silence, relocated to the end of the run. -> **v0.147.3** adds a run **phase**; the post-app stages
announce themselves and app-scoped counters clear when a phase starts.
Final live trace — the whole 57-second run is narrated, no frozen state:
```
t+6s app=immich
t+9s app=bookstack
t+12s app=calibre-web
t+15s phase=shares
t+18s ... t+54s phase=retention
t+57s done
```
---
## Also: golden/controller infra-image drift closed at the source
`infra.Images()` derives the list from the existing pins; `--print-infra-images` prints it; the golden
bake now asks the controller binary it is about to bake instead of carrying its own copy. That copy
had already drifted — `felhom-samba` was never added to it, so the golden baked 3 of 4, **which is
why enabling Megosztás pulled at runtime in the first place**. A test parses the const block out of
the source with `go/ast` and fails if a pin is added without reaching `Images()`; red-proofed.
Verified against the real published image on the live box — all four returned.
## Validation method
Live validation was **endpoint-exact** (the exact URLs the UI posts to, through the real handlers and
the real restic/samba paths), not browser click-through: the controller UI is password-gated and I do
not enter credentials into web forms. No server logic was skipped — only rendering. The rendered
markup was checked by fetching the pages themselves and asserting on the emitted HTML.
## Not done
- **Floor not raised** — Viktor decides floor timing.
- No golden rebuild, no manifest saves, nothing on Peti's arc.
- No SMB server-side changes — the `\\FELHOM` access issue stays client-side diagnosis first.
- ROADMAP: **R-45** (unified async-job feedback) and **R-46** (verification-copy browse + expiry)
registered in `felhom.eu/documentation/backlog/ROADMAP.md`.