136 lines
7.6 KiB
Markdown
136 lines
7.6 KiB
Markdown
# REPORT — app lifecycle states + R-55 live leg (TASK-F Parts 1 & 3), 2026-07-21
|
|
|
|
**Versions: v0.158.0 (lifecycle) + v0.158.1 (a defect I shipped in 0.158.0 and caught live — read §4).
|
|
Baseline `a96226a` (v0.157.0), clean tree. Deployed to guest 9201 and live-verified.**
|
|
|
|
## 1. What shipped — app lifecycle
|
|
|
|
`.felhom.yml` gains an optional top-level `lifecycle:`. Absent/empty ≡ `available`, so all existing
|
|
templates are untouched.
|
|
|
|
| state | offered for new installs | shown to someone already running it |
|
|
|---|---|---|
|
|
| `available` | yes | nothing |
|
|
| `hidden` | **no** | nothing — "we stopped offering this" is not their problem |
|
|
| `abandoned` | **no** | „Nem karbantartott" badge + a permanent notice on the app page |
|
|
|
|
**A deployed instance keeps full function in every state.** That is the whole design: the earlier
|
|
`retired/` directory move un-offered the app but would ALSO have made the orphan detector see the
|
|
template as gone for anyone running it — flagging a working install `Elavult` and offering a Törlés
|
|
button. Withdrawing an app must never take a working app away from a customer.
|
|
|
|
Three design points worth carrying past this feature:
|
|
|
|
- **The deploy gate is server-side and fail-CLOSED**, before any mutation, with the ruled message
|
|
„Ez az alkalmazás jelenleg nem telepíthető." Hiding a button is not a gate — a stale link, a
|
|
bookmarked deploy form and a direct POST must all be refused. A second check in
|
|
`stacks.DeployStack` covers callers that do not route through the API.
|
|
- **Unknown values fail OPEN** (→ `available` + one WARN) — deliberately the opposite posture. A
|
|
typo, or a state from a catalog newer than this controller, must never silently pull a working app
|
|
out of every customer's list. Both paths read the same `EffectiveLifecycle`, so they cannot disagree.
|
|
- **Lifecycle never reaches orphan detection.** `getCatalogTemplateSlugs` keys on directory + compose
|
|
presence only. Red-proofed.
|
|
|
|
Badge plumbing is generic (`MetaBadge` + the `meta_badge` partial + a `lifecycleBadge` funcmap
|
|
entry) so R-56's difficulty labels need only a sibling funcmap function — no new markup or CSS.
|
|
|
|
## 2. Live validation — endpoint-level, on 9201
|
|
|
|
The catalog change arrived through the **normal git-sync**, not a hand-copy:
|
|
|
|
```
|
|
$ grep -m1 '^lifecycle:' /opt/docker/stacks/plant-it/.felhom.yml
|
|
lifecycle: abandoned
|
|
```
|
|
|
|
| check | result |
|
|
|---|---|
|
|
| plant-it on the Alkalmazások page | **0 occurrences** (control app `bookstack`: 10) |
|
|
| direct `POST /api/stacks/plant-it/deploy` | **HTTP 409** `{"ok":false,"error":"Ez az alkalmazás jelenleg nem telepíthető."}` |
|
|
| `/apps/plant-it` | 200, carries the notice, **no Telepítés button** |
|
|
| `/apps/bookstack` (control) | 200, no badge, unaffected |
|
|
|
|
Method: the exact endpoints the UI calls, authenticated as the customer (no browser on DooPlex).
|
|
|
|
## 3. Red-proofs — all five run
|
|
|
|
| # | pre-fix shape restored | observed failure |
|
|
|---|---|---|
|
|
| 1 | remove the API deploy gate | wiring test: *"deployStack never calls Meta.CanInstall() — the lifecycle gate is INERT"* |
|
|
| 2 | drop `st.Deployed \|\|` from the catalog filter | `"plant-it-run" must be listed`, `"oldapp-run" must be listed` — a customer's running app vanishes from their own page |
|
|
| 3 | remove the badge line from stacks.html | *"a deployed abandoned app must carry the Nem karbantartott badge"* |
|
|
| 4 | make orphan detection lifecycle-aware | `catalog set = map[bookstack:true]` — both withdrawn apps read as orphans |
|
|
| 5 | restore the pointer receivers (§4) | `can't evaluate field IsAbandoned in type interface {}` on every lifecycle value |
|
|
|
|
The wiring test walks the **AST**, not `strings.Contains`, because a commented-out call still
|
|
contains the string; it also asserts the gate precedes `DeployStack`, since a gate that fires after
|
|
the mutation is not fail-closed.
|
|
|
|
## 4. A defect I shipped in v0.158.0 and caught within the hour
|
|
|
|
**`/apps/<slug>` returned HTTP 500 for EVERY app**, not only withdrawn ones.
|
|
|
|
`EffectiveLifecycle` / `CanInstall` / `IsAbandoned` were declared with **pointer receivers**.
|
|
`appDetailHandler` passes `data["Meta"] = found.Meta` — a `stacks.Metadata` VALUE inside a
|
|
`map[string]interface{}` — and html/template cannot call a pointer-receiver method on a
|
|
non-addressable value:
|
|
|
|
```
|
|
executing "app_info" at <.Meta.IsAbandoned>: can't evaluate field IsAbandoned in type interface {}
|
|
```
|
|
|
|
Fixed in v0.158.1 with value receivers and the reason recorded at the declaration so it is not
|
|
"tidied" back.
|
|
|
|
**Why every gate missed it, which is the transferable part.** It compiles. `go vet` is silent. All
|
|
of v0.158.0's tests passed — because none of them rendered `app_info`. The catalog-page tests
|
|
exercised the *funcmap* route (`lifecycleBadge .Meta`), which takes a value and works with either
|
|
receiver. **A template method call is only ever checked when that template actually runs**, so a
|
|
render test is not optional coverage for template-facing methods — it is the only coverage.
|
|
|
|
`TestAppInfoRendersForEveryLifecycle` now renders the real template through the production tree with
|
|
the handler's exact data shape — `"Meta"` as a value in a `map[string]interface{}`, deliberately not
|
|
a pointer, because a pointer would hide the bug the test exists to catch.
|
|
|
|
I found this while live-validating, not from the suite. That is the honest sequence.
|
|
|
|
## 5. Part 3 — R-55 live leg, PROVEN
|
|
|
|
Operator-present, 2026-07-21. Fixture on 9201 (guest clock = UTC, host = CEST):
|
|
|
|
| time (UTC) | event |
|
|
|---|---|
|
|
| 14:28:52 | **immich STOPPED through the real UI endpoint** `POST /api/stacks/immich/stop` → `compose down` |
|
|
| — | pre-state verified: immich **0 containers**, calibre-web 1 running (drive-backed), bookstack 2 running (not drive-backed) |
|
|
| 14:29:14 | `pct reboot 9201` |
|
|
| 14:29:28 | `[gate] boot 1784525102-11906045: waiting (≤2m0s) for live drive bind(s) [/mnt/felhom-drives/hdd_1]` |
|
|
| 14:29:28 | `[gate] … live bind confirmed — recreating drive-backed app calibre-web (state=stopped) onto /mnt/felhom-drives/hdd_1` |
|
|
| 14:29:29 | `[gate] … 1 drive-backed app(s) left stopped — zero containers means the customer stopped them on purpose` |
|
|
| 14:29:32 | `[bootrecon] Boot reconciliation: no boot-orphaned apps (nothing to start)` |
|
|
|
|
End state: **immich 0 containers / stopped**, calibre-web 1 running, bookstack 2 running. **Zero
|
|
alerts** (the only `app_start_failed|deadapp` grep hit was the scheduler registering its job).
|
|
Reboot → steady state in **~15 seconds**.
|
|
|
|
**immich is absent from the recreate list.** Hours earlier, the identical fixture brought it back
|
|
RUNNING — that is the defect R-55 fixed, and this is the same experiment with the opposite result.
|
|
The `left stopped` INFO line fired in production for the first time, so the honoured path is
|
|
observable rather than silent.
|
|
|
|
## 6. Gates
|
|
|
|
`go build ./... && go vet ./... && go test ./...` — **green** (both versions). All five template
|
|
gates pass (`template_id`, `emoji`, `mojibake`, `app_row_dedup`, `native_confirm`).
|
|
|
|
## 7. Observations
|
|
|
|
1. **I pushed a commit that does not build.** A `git stash`/`stash pop` used to compare
|
|
`reuse_refs_check.py` output silently discarded the staged index, so the first lifecycle commit
|
|
landed only the new test files. Fixed by a follow-up commit rather than a force-push (rewriting
|
|
pushed history is worse than one bad commit in the log). **Lesson: never `git stash` mid-staging.**
|
|
2. `REUSE.md` cross-repo citation drift is pre-existing and improved: 8 broken paths → **5**. I fixed
|
|
the three that were plain prefix errors; the rest cite abbreviated paths the checker resolves
|
|
against the repo root.
|
|
3. The `hidden` state currently has no user in the catalog. It is implemented and tested, but only
|
|
`abandoned` is exercised end to end.
|