hub v0.70.1: the ghost customer's Delete button must exist (Danger-zone render gate split)
This commit is contained in:
@@ -51,6 +51,11 @@ UI. Package map, helpers, seams, extension points: **`REUSE.md`** (e.g. new even
|
||||
- Add debug capabilities (logging, verbose output).
|
||||
- If you need more input or troubleshooting output, **ask first — don't guess**.
|
||||
- Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill.
|
||||
- **Seam-wiring rule — and it covers TEMPLATE GATES (fourth inert seam, hub v0.70.1):** a feature
|
||||
is not shipped until its entry point is reachable. For UI, any conditional affordance
|
||||
(`{{if .Flag}}` around a button/form/script) ships with a render test per branch of the gate —
|
||||
handler tests that POST directly prove nothing about reachability. The v0.70.0 ghost-delete was
|
||||
fully implemented server-side and fully dead UI because the button sat inside the wrong gate.
|
||||
- UI/design work (tokens, gates, copy rules): use the `felhom-ui-design` skill.
|
||||
- **Logging**: levels/English/no-secrets rules per `documentation/runbooks/logging-conventions.md`
|
||||
(DEBUG = flow detail, INFO = state change + duration; logs are operator-tier English; keys never
|
||||
|
||||
+12
@@ -3,6 +3,18 @@
|
||||
> Created with the REUSE.md rollout (2026-07-03). Authoritative history: `hub/CHANGELOG.md` (hub),
|
||||
> `website/CHANGELOG.md`, `scripts/CHANGELOG.md`; end-of-task detail in `REPORT.md`.
|
||||
|
||||
- **2026-07-22 — hub v0.70.1: the ghost customer's Delete button exists now.** The v0.70.0
|
||||
ghost-delete path was fully implemented and fully unreachable — the **fourth inert-seam defect**,
|
||||
this time a TEMPLATE GATE: the Danger-zone card (and the `customerDeleteOpen` script) sat inside
|
||||
`{{if .HasConfig}}`, so a ghost rendered no Delete button (found live on `demo-vm-felhom`).
|
||||
Fix: `Deletable` page flag = the exact negation of the delete preview's 404 predicate; RESET
|
||||
stays HasConfig-gated, Block/Unblock config-only. Render tests per branch + 2 red-proofs.
|
||||
**The seam-wiring rule now covers template gates** (see CLAUDE.md) — handler tests that POST
|
||||
directly prove nothing about reachability. **Operator live leg PENDING: the demo-vm-felhom ghost
|
||||
delete click** (doubles as v0.70.0+v0.70.1 live validation). Same day: **agent 0.93.0 published
|
||||
+ deployed fleet-wide** (`pilot/RUNBOOK-publish-agent-0.93-2026-07-22.md`), **R-17 CLOSED**
|
||||
(u629193-sub1 deleted at the console).
|
||||
|
||||
- **2026-07-21 — THE FLEET IS TWO HOSTS.** A second Proxmox node exists: **`demo-hp-bb76ea`**, an
|
||||
**HP t740 Thin Client** (Ryzen V1756B, 30 GiB, PVE node `felhom-host`, customer `demo-hp`), installed
|
||||
from the armed universal ISO the same day. Both hosts run **agent 0.92.1**; the N100 is
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
| R-11 | Tester-facing one-pager: what the box does, known limitations, how to report | S | **RULED 2026-07-21 (channel); doc is the architect's** | Pairs with R-3. ****operator ruling 2026-07-21**: the reporting channel is DIRECT MESSENGER to the operator** — no group, no tracker, no form. Rationale: the expected tester count at first invite is ~0–few, and a channel nobody is on is worse than a channel everyone already uses. **The one-pager and the tester agreement are the ARCHITECT's deliverable, not CC's** — CC does not write them; it may be asked to fact-check claims in them against the code. Revisit the channel only if tester count makes direct messages unmanageable |
|
||||
| R-16 | Operator hygiene: campaign6 autofs orphan (clears on host reboot) + tied-CreatedAt flash duplicates (audiobookshelf/komga/romm) | XS | open (doc-drift bit CLOSED) | Viktor's own action items from 6D/6E. **Doc-drift leftover CLOSED (host-install v1.17.0, 2026-07-17):** the R-20-noted stale "EMPTY by default" operator-key comment corrected (keys are PINNED). Remaining = the two operator items above |
|
||||
| R-22 | **PBS-DR pre-check self-grant (F4).** On a non-default storage id the token-auth `GET /storage/<id>` pre-check 403s (no ACL yet) and used to abort before the root-run `grant` that creates it. | S | **SHIPPED + PROVEN-LIVE agent v0.89.0** (2026-07-17) | On a 403 the reconcile self-grants via the root wrapper + re-reads, then converges. Red-proof `TestSelfGrant_PreCheck403DoesNotAbortBeforeGrant`; live-reproduced on the demo (marker aside + ACLs revoked → self-grant → `converged state=adopted` in ~3 s, ACLs restored, offsite active). Origin `tests/VALIDATION-n100-baremetal-2026-07-16.md` F4. |
|
||||
| R-17 | Old-box archive (u629193-sub1) retirement — 9/9 byte-identical restores verified | XS | **RULED 2026-07-21 — DELETE; execution is an operator console action** | **operator ruling 2026-07-21**: delete the archive now; the 9/9 byte-identical restores are the evidence it is no longer needed. **CC spike-lite (2026-07-21) — it CANNOT be deleted through existing tooling, and CC did not improvise credentialed API calls:** (a) `felhom-tenantsync.sh` does not touch Hetzner at all — it manages PBS namespaces/tokens only; (b) the hub CAN delete sub-accounts (`hetznerapi.DeleteSubaccount`), but its ONLY caller is `offsite.Deprovision`, which selects by the `felhom-customer=<id>` label and runs as part of a customer RESET — a legacy archive sub-account from the 2026-07-09/10 spikes carries no such label, so no tooling verb targets it; (c) the only in-repo references are TEST FIXTURES (`hub/internal/hetznerapi/fake.go` generates the `u629193-sub%d` pattern; `felhom-controller/.../dr_recipe_test.go` uses it as a literal) — nothing live points at it. **Operator console steps:** Hetzner Storage Box console → box `u629193` → Sub-accounts → `u629193-sub1` → confirm the directory holds only the old-box archive → Delete. Deleting the sub-account destroys its data; the 9/9 verification is the sign-off |
|
||||
| R-17 | Old-box archive (u629193-sub1) retirement — 9/9 byte-identical restores verified | XS | **CLOSED 2026-07-22 — archive deleted (operator console)** | **operator ruling 2026-07-21**: delete the archive now; the 9/9 byte-identical restores are the evidence it is no longer needed. **CC spike-lite (2026-07-21) — it CANNOT be deleted through existing tooling, and CC did not improvise credentialed API calls:** (a) `felhom-tenantsync.sh` does not touch Hetzner at all — it manages PBS namespaces/tokens only; (b) the hub CAN delete sub-accounts (`hetznerapi.DeleteSubaccount`), but its ONLY caller is `offsite.Deprovision`, which selects by the `felhom-customer=<id>` label and runs as part of a customer RESET — a legacy archive sub-account from the 2026-07-09/10 spikes carries no such label, so no tooling verb targets it; (c) the only in-repo references are TEST FIXTURES (`hub/internal/hetznerapi/fake.go` generates the `u629193-sub%d` pattern; `felhom-controller/.../dr_recipe_test.go` uses it as a literal) — nothing live points at it. **Operator console steps:** Hetzner Storage Box console → box `u629193` → Sub-accounts → `u629193-sub1` → confirm the directory holds only the old-box archive → Delete. Deleting the sub-account destroys its data; the 9/9 verification is the sign-off. **CLOSED 2026-07-22: the operator executed the console action — `u629193-sub1` deleted, confirmed no sub-accounts remain on PBS-storage-1** |
|
||||
| R-19 | Internet-outage customer-experience drill: pull WAN on demo, verify lan_resolver path, document what the customer actually sees/does | S | idea | Flips map row E "LAN access" IMPLEMENTED→PROVEN-LIVE |
|
||||
| R-20 | ~~Verify operator-key pinning is fully in the day-0 install flow~~ | XS | **closed** (2026-07-16) | Confirmed against `scripts/felhom-host-install.sh` source (not changelog): keys resolve at L1181–1219 (script constants `OPERATOR_KEY_*`, populated, `--operator-pubkey-file` override), pinned automatically by `step_agent_config()` "STEP 6/8" (L2044; python builds `authz.signers` L2146–2156, reinstall preserves existing), verified at L2332–2337 ("authz signers: N … operator-signed self-update armed"). No interactive prompt or post-install hand-edit — fully automatic. Doc-drift note: the L193–197 "EMPTY by default" comment is stale vs the now-populated constants (→ R-16 hygiene) |
|
||||
| R-23 | **Immediate-sync Direction-2 follow-ups** (hub v0.58 / controller v0.140, 2026-07-16): **(a) — BANKED 2026-07-21 (both legs).** The operator-UI save->apply round trip is PROVEN: the STOP-2 global-floor save (hub `18:56:27 CEST`) released the controller's held wait in the **same second** (`16:56:27Z wait woke: generation=1 - firing out-of-cycle report`), with the report built 2 s later; the ring also shows `wait baseline generation=0` at startup (baseline recorded WITHOUT firing, as designed) then `generation=1`, so the generation advanced past 0. **RESTART LEG BANKED 2026-07-21 — the floor was moved to a version the box did NOT run, and the swap fired EXACTLY ONCE.** Operator saved global floor 0.153.0 → **v0.154.0** (a real version boundary, unlike the 2026-07-20 attempt which targeted an already-running version and therefore proved nothing). Timeline (guest UTC): `06:57:13` UpdateState `pending` written `initiated_by=auto-floor` → `06:57:17` agent `controller swap requested 0.153.0 -> 0.154.0` → `06:57:19` `image file written, restarting bootstrap` → `06:57:21` container StartedAt + UpdateState `completed_at` → `06:57:29` agent `new controller healthy`. **16 s end to end.** Assertions over the whole window (06:50 → 07:29, 39 min): `controller swap requested` = **1**, agent-driven bootstrap restarts = **1**, `new controller healthy` = **1**, rollback/swap-failed/unhealthy = **0**, container `RestartCount` = **0**. `VerifyStartup` banked it on the next boot (`Post-update startup: update successful (0.153.0 → 0.154.0)`) and the `06:57:52` periodic check logged `Current version 0.154.0 is up to date` — the at/above-floor branch correctly doing nothing. **No storm, no rollback, no second attempt.** *Caveat, disclosed: a hand-deploy of v0.155.0 at `07:17:10` sits inside the observation window and is what StartedAt shows after that point; it never touches `SwapController`, so the swap-count assertions above are uncontaminated across the full window. A second, unplanned confirmation of the at/above-floor branch came with it — after the hand-deploy the box ran 0.155.0 against a 0.154.0 floor and the updater logged `Current version 0.155.0 is up to date` and did nothing.* Evidence: `felhom-controller/REPORT.md` §6 (2026-07-21). *(Superseded note:)* **the self-restart single-fire leg was** - the floor was set to a version the box ALREADY ran, so there was no work and no restart. Finish by bumping the floor to a version the box does NOT run, debug ring open, asserting EXACTLY ONE restart. **Trap found while banking this: the wake is `logx.Debugf`, so it is INVISIBLE in `docker logs` at INFO** and lives only in the debug ring (`GET /api/debug/logs?level=DEBUG`) - a hunter looking at stdout wrongly concludes the box never woke; arguably (b) generalised. (b) cosmetic: the Waiter's "recovered" INFO logs on the next hold completion (`pollOnce` blocks ~240 s), not at reconnect | S | **(a) BANKED in full; only (b) cosmetic remains** | Map row "config/state change round-trips in seconds" flipped PARTIAL->PROVEN-LIVE 2026-07-21 on this evidence. Evidence: `felhom-controller/REPORT.md` 4f |
|
||||
@@ -84,7 +84,7 @@
|
||||
| R-26 | **Guided old-history recovery via a retained superseded escrow + the recovery code.** Enabled by hub v0.60.0 (Part B) which now RETAINS superseded escrow blobs (`host_escrow_superseded`, `ListSupersededEscrow`). Build the flow that, given the customer's recovery code, unwraps a retained old blob → recovers the old repo passphrase → mounts/reads the moved-aside `.orphaned-<date>` repo for restore. | M | idea (enabled by v0.60.0) | Turns "history recoverable in principle" into a real customer-drivable path; pairs with the controller v0.142.0 orphaned-repo move-aside. Origin `DIAGNOSE-offbox-repo-orphaned-2026-07-17` |
|
||||
| R-27 | **Customer-facing self-bind page (R-21 slice C follow-on).** Today an unclaimed appliance is bound by the OPERATOR on the Hosts page (hub v0.62.0). Build the customer-facing flow so a customer can claim/bind their own freshly-installed box. | M | **SHIPPED (slice 1, hub v0.66.0, 2026-07-17)** | **Slice 1 = the FIRST-box flow, DONE:** operator "Send self-bind link" → 7-day tokenized capability link over Hungarian email → public two-factor `/bind/<token>` (console pairing code shown on the box screen + retrieval passphrase) → hub stages the bind via the same `BindAppliance`, no operator. Viktor's 3 rulings verbatim (console code not a list; operator-sent link; 5-attempt lockout→call support). No oracle; expiry falls back to operator-bind; THE TRAP `/bind/` exemption tight (single predicate, 4 red-proofs). GC verdict §3 (no appliance GC → the 7-day TTL stands alone). **Slice 1 PROVEN-LIVE 2026-07-18:** in the rehearsal the operator minted + emailed the link at 16:28:55 and **the customer bound their own box at 16:29:55 with zero failed attempts**, `appliance_bound` carrying source `customer_selfbind`, credential delivered 26 s later with no operator action (`tests/VALIDATION-n100-rehearsal-2026-07-18.md`). The new-ISO console banner rendered on the real box. **Slice 2 = console-passphrase bind → R-27c** (below). Reused the appliance_registrations + one-shot delivery machinery; the capability link is the whole customer-auth surface (no login system built). Controller/agent untouched. See hub v0.66.0 REPORT. **Multi-box per link is out of scope by design** (single-active, one-shot → binding several boxes = repeated operator sends) |
|
||||
| R-27b | **Customer self-bind, second-box flow (controller side).** For a customer who ALREADY has a bound box and installs another, the controller shows a dismissable "bind another box" prompt (and a bind-later entry under settings) that walks to the hub `/bind/` page — so a returning customer isn't emailed a fresh operator-sent link for every box. Mechanism sketched in the hub v0.66.0 REPORT; NOT built (R-27 slice 1 deliberately did not touch the controller). | M | idea (minted by hub v0.66.0) | Origin: hub v0.66.0 slice-1 ship (first-box only). Reuses the same `/bind/` public page + tokenized-link machinery; adds a controller-side entry point + the operator "mint a link for an existing customer" affordance |
|
||||
| R-25b | **RULED: customer DELETE becomes a guided full-teardown cascade.** The middle-tier Customer RESET (hub v0.61.0) runs the full external teardown (Hetzner sub-account/box + PBS namespace/groups/token) and refuses while any host row exists. The Danger-zone DELETE still (a) leaves host rows and (b) does NOT run that teardown. | M (was S) | **SHIPPED hub v0.69.0 (2026-07-21)** | **operator ruling 2026-07-21**: **DELETE subsumes the whole cascade, behind explicit consent.** Three separate acknowledgements, each its own checkbox — (1) the host(s) will be deleted, (2) the customer will be RESET including external teardown and offsite data destruction, (3) the customer record and escrow will be purged — plus a **typed customer-name confirmation** before the button arms. Internal order is **host-delete → RESET → delete**, which preserves every existing invariant rather than relaxing any: RESET keeps its no-hosts precondition (hosts are already gone by then), and escrow keeps its demote-then-purge custody rule (host delete DEMOTES to retained custody, the final delete PURGES — the one true purge point). **Re-sized S → M: this is a multi-step destructive wizard with three acks and a typed confirmation, not a checkbox.** Implementation is explicitly NOT part of TASK-E; the row carries the ruling and awaits its own spec. **It no longer blocks R-3** — the model is decided, so the friend-alpha runbook can be written against it. **IMPLEMENTED per the ruling (TASK-I, hub v0.69.0):** `POST /configs/{id}/delete` now runs `hosts → RESET → purge`; three acks + typed customer-id + a stale-preview check + the ONLINE-host refusal, all gates before any write (zero side effects on refusal); custody purged exactly ONCE in leg 3 (leg 2 runs with `purgeEscrow=false`); ruling-3 preserved BY CONSTRUCTION and asserted from inside leg 2; failed legs retain the journal and the dialog offers Resume. Standalone RESET byte-identical. 5 red-proofs. Offboarding guidance: `runbooks/RUNBOOK-onboarding-draft-v4.md` §G. **v0.70.0 follow-up (same day, found validating against the live hub):** a completed delete still left the customer on the Customers list and still ALERTING, because `GetCustomers()` is report-derived and no tier ever deleted a report — new **residue** leg (reports/telemetry/log-tails/notif-prefs + the credential-bearing `appliance_registrations`/`selfbind_tokens`), and **ghost customers are now deletable** (404 = nothing here, not no-config-row) |
|
||||
| R-25b | **RULED: customer DELETE becomes a guided full-teardown cascade.** The middle-tier Customer RESET (hub v0.61.0) runs the full external teardown (Hetzner sub-account/box + PBS namespace/groups/token) and refuses while any host row exists. The Danger-zone DELETE still (a) leaves host rows and (b) does NOT run that teardown. | M (was S) | **SHIPPED hub v0.69.0 (2026-07-21)** | **operator ruling 2026-07-21**: **DELETE subsumes the whole cascade, behind explicit consent.** Three separate acknowledgements, each its own checkbox — (1) the host(s) will be deleted, (2) the customer will be RESET including external teardown and offsite data destruction, (3) the customer record and escrow will be purged — plus a **typed customer-name confirmation** before the button arms. Internal order is **host-delete → RESET → delete**, which preserves every existing invariant rather than relaxing any: RESET keeps its no-hosts precondition (hosts are already gone by then), and escrow keeps its demote-then-purge custody rule (host delete DEMOTES to retained custody, the final delete PURGES — the one true purge point). **Re-sized S → M: this is a multi-step destructive wizard with three acks and a typed confirmation, not a checkbox.** Implementation is explicitly NOT part of TASK-E; the row carries the ruling and awaits its own spec. **It no longer blocks R-3** — the model is decided, so the friend-alpha runbook can be written against it. **IMPLEMENTED per the ruling (TASK-I, hub v0.69.0):** `POST /configs/{id}/delete` now runs `hosts → RESET → purge`; three acks + typed customer-id + a stale-preview check + the ONLINE-host refusal, all gates before any write (zero side effects on refusal); custody purged exactly ONCE in leg 3 (leg 2 runs with `purgeEscrow=false`); ruling-3 preserved BY CONSTRUCTION and asserted from inside leg 2; failed legs retain the journal and the dialog offers Resume. Standalone RESET byte-identical. 5 red-proofs. Offboarding guidance: `runbooks/RUNBOOK-onboarding-draft-v4.md` §G. **v0.70.0 follow-up (same day, found validating against the live hub):** a completed delete still left the customer on the Customers list and still ALERTING, because `GetCustomers()` is report-derived and no tier ever deleted a report — new **residue** leg (reports/telemetry/log-tails/notif-prefs + the credential-bearing `appliance_registrations`/`selfbind_tokens`), and **ghost customers are now deletable** (404 = nothing here, not no-config-row). **v0.70.1 (2026-07-22): the ghost delete was implemented but UNREACHABLE** — the Danger-zone card (and the `customerDeleteOpen` script) sat inside `{{if .HasConfig}}`, so a ghost rendered no Delete button at all (the fourth inert-seam defect; handler tests POST directly and proved nothing about reachability). Render gate split: RESET stays HasConfig-gated, Danger zone gates on `Deletable` (the exact negation of the preview's 404 predicate), Block/Unblock stay config-only; render tests per branch + 2 red-proofs. **Operator live leg: the demo-vm-felhom ghost delete click — PENDING** (doubles as the v0.70.0+v0.70.1 live validation; expect `residue=ok customer_delete=ok` with `skipped_no_config` Hetzner/descriptor legs, staleness emails stop) |
|
||||
| R-25 | **Device-node TOCTOU hardening (drive init).** Graduate the controller v0.141.0 Observation: the `format → resolveEnrollUUID(path) → AssignDisk(uuid)` sequence has a narrow /dev-re-enumeration window (agent-guarded on the destructive format via anti-retarget durable-id; benign fs-UUID mount). Bind resolve+assign to the format's durable-id so the mount can't target a moved node. | S | idea | From the v0.141.0 F6 commit's security-review finding (`felhom-controller` REPORT). Low real risk (single-operator, agent-guarded), but cheap to close |
|
||||
| R-24 | **Guest RAM resize (live) — SHIPPED (agent v0.90.0 + controller v0.143.0, 2026-07-17).** The customer right-sizes the guest's memory from the controller's Rendszer page; the agent enforces every bound fresh + applies via PVE `SetConfig` (live cgroup, no reboot, Phase-0 proven). **Framing note:** the original hub-desired-state framing is SUPERSEDED by Viktor's controller-direct ruling (2026-07-17) — the resize is controller→agent local-API, never through the hub. Memory only (cores stay observation). **COMPLETE — the apply is proven in BOTH directions on metal (2026-07-18):** customer zero shrank the guest **11675 → 8192 MB (16:50:22)** and grew it **8192 → 12288 MB (17:02:17)**, each a live cgroup apply with no reboot, the new total rippling into the deploy page's memory math. The nested demo had proven the refusals; the N100 proved the applies. `tests/VALIDATION-n100-rehearsal-2026-07-18.md` ledger 9. **Cores/live-resize as hub desired-state is NOT built** (deferred, low demand). | M | **SHIPPED + PROVEN-LIVE** | See felhom-agent + felhom-controller REPORTs; capability-map row "customer right-sizes guest RAM". |
|
||||
| R-12 | Cluster mode: agent-follows-guest, bind-mount reconciliation on HA migration | XL | idea | Scoped 07-15; interim = HA-group pin to one node. Driven by Peti's two-node cluster |
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.70.1 — the ghost customer's Delete button must exist (2026-07-22)
|
||||
|
||||
**The fourth inert-seam defect: v0.70.0's ghost-delete path was fully implemented and fully
|
||||
unreachable.** `handleCustomerDelete`/`handleCustomerDeletePreview` accepted `cfg == nil`, the
|
||||
ghost dialog branch existed in the template JS (`customer_unified.html` `if (d.has_config ===
|
||||
false)`) — but the Danger-zone card containing the Delete button sat inside the `{{if .HasConfig}}`
|
||||
block (old ~L736) that also wraps the RESET card. A ghost rendered no Danger zone → no button →
|
||||
dead UI. Handler tests passed because they POST directly; nothing asserted the rendered page.
|
||||
Observed live on `demo-vm-felhom` (2026-07-22): Edit tab showed Controller Update + Geo cards only.
|
||||
|
||||
- **Handler** (`configs.go` `handleCustomerUnified`): new `Deletable` page flag — the **exact
|
||||
negation of the delete preview's 404 predicate** (`customer_delete.go`: `cfg == nil && no hosts
|
||||
&& residue empty`), one truth, not a lookalike. Hosts were already fetched for the Host tab;
|
||||
only the residue count is an extra read, and it runs solely on the ghost shape. A lookup error
|
||||
logs and leaves `Deletable=false` — fail toward hiding a destructive control.
|
||||
- **Template**: the old gate split. RESET card stays `{{if .HasConfig}}` (identity-preserving
|
||||
re-onboarding — a ghost has no identity to preserve). Danger zone gates on `{{if .Deletable}}`;
|
||||
inside it the Block/Unblock forms gain their own `{{if .HasConfig}}` (blocking gates dashboard
|
||||
visibility of a configured customer — meaningless for a ghost). The `customerDeleteOpen/Submit`
|
||||
script moved out with the card (it was inside the old gate). Ghost shape gets a one-sentence
|
||||
intro prefix; the dialog already explains the rest.
|
||||
- **Render tests** (`customer_ghost_delete_render_test.go`), asserting the delete form's `action`
|
||||
and the `customerDeleteOpen(` call site: ghost-with-residue renders Delete only (no RESET, no
|
||||
Block); configured customer keeps every affordance byte-for-byte (incl. the blocked→Unblock
|
||||
branch); nothing-left 404s (there is no renderable `Deletable=false` state — `customer != nil`
|
||||
implies a report row implies residue > 0). Two red-proofs run and recorded in `REPORT.md`.
|
||||
- **The generalized seam-wiring rule now covers template gates:** any conditional UI affordance
|
||||
ships with a render test per branch — handler tests that POST directly prove nothing about
|
||||
reachability.
|
||||
|
||||
## v0.70.0 — a deleted customer actually disappears (the ghost + its alerts) (2026-07-21)
|
||||
|
||||
Found while validating v0.69.0 against the live hub, on the operator's report that `demo-vm-felhom`
|
||||
|
||||
@@ -283,6 +283,12 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
Overrides map[string]interface{}
|
||||
IsBlocked bool
|
||||
|
||||
// Deletable (v0.70.1) gates the Danger-zone card. It is the exact negation of the delete
|
||||
// preview's 404 predicate (customer_delete.go: cfg == nil && no hosts && residue empty) —
|
||||
// one truth, not a lookalike. Before v0.70.1 the card sat inside {{if .HasConfig}}, so the
|
||||
// entire v0.70.0 ghost-delete path was implemented but unreachable (dead UI).
|
||||
Deletable bool
|
||||
|
||||
HasReports bool
|
||||
Customer *store.CustomerSummary
|
||||
Report map[string]interface{}
|
||||
@@ -397,6 +403,19 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
staleSinceReset = customer == nil || !customer.ReceivedAt.After(*cr.CompletedAt)
|
||||
}
|
||||
|
||||
// v0.70.1: the Danger-zone render gate. Hosts are already fetched above for the Host tab —
|
||||
// only the residue count is an extra read, and it runs ONLY on the ghost shape (config-less,
|
||||
// hostless), never on the hot normal path. A lookup error logs and leaves Deletable=false:
|
||||
// fail toward HIDING a destructive control, never toward showing one on unknown state.
|
||||
deletable := cfg != nil || len(hostViews) > 0
|
||||
if !deletable {
|
||||
if residue, err := s.store.CustomerResidue(customerID); err != nil {
|
||||
s.logger.Printf("[ERROR] CustomerResidue %s: %v", customerID, err)
|
||||
} else {
|
||||
deletable = residue.Total() > 0
|
||||
}
|
||||
}
|
||||
|
||||
// R-36 interim (v0.67.0): enabled-but-unprovisioned is a real, stable state — the same predicate
|
||||
// the offsite re-issue handler already uses to refuse ("No provisioned offsite tier").
|
||||
var offsiteView struct {
|
||||
@@ -420,6 +439,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
Config: cfg,
|
||||
Overrides: overrides,
|
||||
IsBlocked: cfg != nil && cfg.Status == "blocked",
|
||||
Deletable: deletable,
|
||||
|
||||
HasReports: customer != nil,
|
||||
Customer: customer,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package web
|
||||
|
||||
// v0.70.1 — the ghost customer's Delete button must EXIST. v0.70.0 made ghost customers
|
||||
// deletable server-side, but the Danger-zone card sat inside {{if .HasConfig}}, so the whole
|
||||
// ghost path was implemented and unreachable (dead UI — the fourth inert-seam defect, found
|
||||
// live on demo-vm-felhom). Handler tests that POST directly prove nothing about reachability:
|
||||
// these tests assert the RENDERED page, per branch of the new gate.
|
||||
//
|
||||
// Assertions anchor on the delete form's action attribute and the customerDeleteOpen( call
|
||||
// site — not free-text strings a copy edit would break.
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
deleteFormMarker = `action="/configs/ghost/delete"`
|
||||
deleteOpenMarker = `customerDeleteOpen('ghost')`
|
||||
deleteFuncMarker = `function customerDeleteOpen(`
|
||||
resetFormMarker = `action="/configs/ghost/reset"`
|
||||
blockFormMarker = `action="/customers/ghost/block"`
|
||||
unblockFormMkr = `action="/customers/ghost/unblock"`
|
||||
)
|
||||
|
||||
// Scenario A — ghost with residue (the demo-vm-felhom shape): no config row, 0 hosts,
|
||||
// residue.Total() > 0 (report rows). The Danger zone must render — delete form, opener button,
|
||||
// and its script — while RESET and Block (config-coupled affordances) must NOT.
|
||||
func TestCustomerPage_GhostWithResidue_RendersDeleteOnly(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
// Reports only — NO SaveCustomerConfig. This is the ghost: config gone, residue keeps the
|
||||
// customer on the list and raising staleness alerts.
|
||||
if err := st.SaveReport("ghost", []byte(tabsTestReportJSON)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if residue, err := st.CustomerResidue("ghost"); err != nil || residue.Total() == 0 {
|
||||
t.Fatalf("fixture must have residue (got total=%v err=%v)", residue, err)
|
||||
}
|
||||
|
||||
html := renderCustomerPage(t, s, "ghost")
|
||||
|
||||
if !strings.Contains(html, deleteFormMarker) {
|
||||
t.Errorf("ghost page must render the delete form (%s) — without it the entire v0.70.0 ghost-delete path is dead UI", deleteFormMarker)
|
||||
}
|
||||
if !strings.Contains(html, deleteOpenMarker) {
|
||||
t.Errorf("ghost page must render the Delete button's %s opener", deleteOpenMarker)
|
||||
}
|
||||
if !strings.Contains(html, deleteFuncMarker) {
|
||||
t.Errorf("the customerDeleteOpen script must exist whenever the button does (was inside the old HasConfig gate)")
|
||||
}
|
||||
// The WRONG outcome this split must not introduce: RESET rendering for a ghost. RESET is
|
||||
// identity-preserving re-onboarding; a ghost has no identity to preserve.
|
||||
if strings.Contains(html, resetFormMarker) {
|
||||
t.Errorf("RESET card must NOT render for a ghost (found %s)", resetFormMarker)
|
||||
}
|
||||
// Blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost.
|
||||
if strings.Contains(html, blockFormMarker) || strings.Contains(html, unblockFormMkr) {
|
||||
t.Error("Block/Unblock forms must NOT render for a ghost")
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B — normal configured customer: byte-for-byte the affordances of today — RESET card
|
||||
// + full Danger zone incl. Block, and the delete machinery.
|
||||
func TestCustomerPage_ConfiguredCustomer_KeepsAllAffordances(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "ghost", CustomerName: "Ghost", Domain: "ghost.hu",
|
||||
RetrievalPassword: "pw", APIKey: "k", Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveReport("ghost", []byte(tabsTestReportJSON)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
html := renderCustomerPage(t, s, "ghost")
|
||||
|
||||
for _, marker := range []string{resetFormMarker, blockFormMarker, deleteFormMarker, deleteOpenMarker, deleteFuncMarker} {
|
||||
if !strings.Contains(html, marker) {
|
||||
t.Errorf("configured customer lost an affordance: %s absent — the gate split must not change the normal shape", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B variant — a BLOCKED configured customer renders Unblock (the IsBlocked branch
|
||||
// survived the extra HasConfig wrapper around the block forms).
|
||||
func TestCustomerPage_BlockedCustomer_RendersUnblock(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "ghost", CustomerName: "Ghost", Domain: "ghost.hu",
|
||||
RetrievalPassword: "pw", APIKey: "k", Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// SaveCustomerConfig never writes status — blocking goes through SetCustomerConfigStatus
|
||||
// (the same call the /block handler makes).
|
||||
if err := st.SetCustomerConfigStatus("ghost", "blocked"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := renderCustomerPage(t, s, "ghost")
|
||||
if !strings.Contains(html, unblockFormMkr) {
|
||||
t.Errorf("blocked configured customer must render the Unblock form (%s)", unblockFormMkr)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C — nothing left: no config, 0 hosts, residue.Total() == 0. There is no renderable
|
||||
// Deletable=false state with real store reads (customer != nil implies a report row implies
|
||||
// residue > 0), so "no Danger zone" manifests as the page-level 404 — the same semantics as the
|
||||
// delete preview's 404 ("there is nothing here").
|
||||
func TestCustomerPage_NothingLeft_404(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/customers/ghost", nil), "ghost")
|
||||
if rr.Code != 404 {
|
||||
t.Fatalf("a customer with no config, no hosts and no residue must 404 (mirrors the preview) — got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -811,13 +811,20 @@
|
||||
document.getElementById('cust-reset-form-' + cid).submit();
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
<!-- Danger zone (v0.48.0 edit-a): the Block/Delete forms relocated verbatim from the
|
||||
Customer Info header — endpoints and confirm() handlers unchanged. -->
|
||||
Customer Info header — endpoints and confirm() handlers unchanged.
|
||||
v0.70.1: gated on .Deletable, NOT .HasConfig — a GHOST customer (config gone, hosts or
|
||||
report/telemetry residue remain) must render the Delete button, or the whole v0.70.0
|
||||
ghost-delete path is dead UI (found live on demo-vm-felhom). RESET stays HasConfig-gated
|
||||
above (identity-preserving re-onboarding — a ghost has no identity to preserve). -->
|
||||
{{if .Deletable}}
|
||||
<section class="card">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="text-muted">Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the <strong>retained recovery-key custody</strong> for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
|
||||
<p class="text-muted">{{if not .HasConfig}}<strong style="color: var(--warn);">Ghost customer</strong> — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the <strong>retained recovery-key custody</strong> for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
|
||||
{{if .HasConfig}}{{/* v0.70.1: blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost */}}
|
||||
{{if .IsBlocked}}
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
|
||||
{{.CSRFField}}
|
||||
@@ -829,6 +836,7 @@
|
||||
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Block this customer? They will be hidden from the Dashboard.">Block</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="customerDeleteOpen('{{.CustomerID}}')">Delete customer…</button>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user