REPORT: hub v0.14.0 host-enroll — shipped + live-verified

Per-test results + companion red-proof, deployed v0.14.0 pod verification,
live throwaway enroll/reuse/401/404/400 + Bearer-auth check, cleanup confirmed.

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 15:39:28 +02:00
parent 8098237ce1
commit 15397468df
+74 -35
View File
@@ -4,44 +4,83 @@
---
## REPORT — hub v0.13.1: DR recipe v1 drive-shape sync (test-data + regression guard only)
## hub v0.14.0 — Passphrase-authed host enrollment (Day-0 option C) — 2026-06-26
**TASK — DR-recipe completion (hub side of a cross-repo change).** The agent's **v0.39.0** dropped two
fields from the host-half `drives[]` (`role`, `restic_repo_coord`) and now resolves the `pbs` coord live.
The hub change is **test-data + a regression guard only — NO behavior change; redeploy optional**, because
the hub already reads drive sections as `json.RawMessage` (verbatim passthrough): dropping fields needs no
store/handler/struct change.
**Task:** Implement option C from `documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md`:
a passphrase-authed, mint-once-reuse host-enrollment endpoint so Day-0 carries **one** secret (the
retrieval passphrase) and the global operator key never enters the field deploy path. Single repo
(`felhom.eu`, `hub/`).
**Baseline:** v0.13.0 (`5f5e3c5`) → **v0.13.1** (`149a3b0`, pushed to `main`).
### Confirmed baseline
- felhom.eu (hub) `main` @ `230980f` (= origin/main, verified by `git fetch`), version `v0.13.1`
(ldflags-injected; no source constant) → target `v0.14.0`.
- Reconfirmed every relied-on symbol live: `handleConfigRetrieve` (auth pattern), `handleAdminCreateHost`
(mint block — left untouched), `UpsertHost` (`ON CONFLICT(host_id)`), `GetHostByAPIKey`/`GetHost`/
`scanHost`/`hostSelectCols`, `idx_hosts_customer`, `configgen.RandomHex`.
### Changes
- `internal/store/testdata/dr-recipe.golden.json` + the `drHostHalf` test fixture — dropped the `role`
key from `drives[0]` to match the v1 shape the agent emits.
- `internal/api/testdata/host-report.golden.json` — re-synced **byte-identical** with the agent's
`internal/hub/testdata/host-report.golden.json` (sha256 `57f2a5e7…18b2f2b5`). The hub copy previously
lacked the `dr_recipe` section entirely; it is now a verbatim copy, so the cross-repo golden truly
matches and POSTing it through `/host-report` exercises the `SaveDRRecipeHostHalf` ingest path.
- New `internal/store/dr_recipe_test.go::TestAssembleDRRecipe_V1DriveShape` — the regression guard: a
stored host half whose `drives[]` carry NEITHER dropped field but WHICH HAS a `pbs` block assembles
cleanly (pbs carried through, drives passed through verbatim, neither `role` nor `restic_repo_coord`
present). Demonstrated to FAIL when the fixture re-adds `role`, then reverted.
### Files changed
| File | Change |
|------|--------|
| `hub/internal/store/store.go` | **+** `Store.GetHostByCustomer` (SELECT … WHERE customer_id=? ORDER BY updated_at DESC LIMIT 1; nil-on-not-found; uses `idx_hosts_customer`) |
| `hub/internal/store/host_test.go` | **+** `TestGetHostByCustomer` (none→nil / one / two→most-recent / other-customer) |
| `hub/internal/api/handler.go` | **+** route `POST /host-enroll` (exact-match, beside `/host-report`); **+** `handleHostEnroll` (passphrase auth → mint-once-reuse). `handleConfigRetrieve` + `handleAdminCreateHost` **unchanged** |
| `hub/internal/api/host_enroll_test.go` | **+** 5 scenario tests (mint/reuse/401-no-mint/404/400) |
| `hub/CHANGELOG.md` | **+** v0.14.0 entry (newest on top) |
| `hub/README.md` | **+** "Host Enrollment (Day-0, option C)" section |
| `documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md` | **+** "c-pref SHIPPED in v0.14.0" pointer in §5 |
### Verification
- `go build ./... && go vet ./... && go test ./...`**green**.
- **Live (the real acceptance):** after deploying the daemon (agent v0.39.0) and its startup host-report
landing in the hub DB, the **real `AssembleDRRecipe`** over a copy of the live `/data/hub.db` produced
the `demo-felhom` recipe — `recipe_version 1`, 3 guests w/ sizing, **`pbs` present** (`felhom-pbs/root/9201`),
**2 drives clean** (no `role`/`restic_repo_coord`), 5 pve_storage, **14 apps**. **Secret scan over the
full assembled recipe AND both raw stored halves = 0 hits** (nothing to redact — the point of the arc).
(Operator HTML panel not browser-rendered: no dashboard password in this env; the server pipeline +
exact stored/assembled bytes were verified.)
### Commits
- `8098237``hub v0.14.0: passphrase-authed host enrollment (Day-0 option C)` (code + tests + CHANGELOG + README + spike pointer).
- `<this REPORT commit>` — REPORT.md overwrite.
### Cross-repo golden checksum
`hub/internal/api/testdata/host-report.golden.json``felhom-agent/internal/hub/testdata/host-report.golden.json`:
`57f2a5e7154ece1be94a79fd327f2924762e70c3ee388d6e375df7c918b2f2b5` (both).
### Tests (temp-sqlite stores; no network)
- **Per-scenario (all PASS):**
- A — first enroll mints → `201`, `host_id=c1-<6hex>`, `api_key=64hex`, exactly 1 host row, `GetHostByAPIKey(minted)` resolves.
- B — second enroll **reuses**`200`, creds **byte-for-byte identical**, still exactly 1 host row.
- C — wrong passphrase → `401`, host-row count **unchanged at 0** (auth-before-mint proven).
- D — unknown customer → `404`, no mint.
- E — missing header → `401`; missing `customer_id``400`.
- store `TestGetHostByCustomer` — none→nil, one→hit, two→most-recently-updated.
- **Companion red-proof (§10):** temporarily disabled the reuse branch (always-mint) → Scenario B
**FAILED** as required (`second status = 201, want 200` — second call returned a *new* `host_id`
`c1-0d44c6`, not the first). Reverted to mint-once-reuse → green. This proves the idempotency test is
load-bearing, not hollow.
- **Test count:** api package 29 → **34** (+5); store package 11 → **12** (+1).
- **Green gate:** `go build ./... && go vet ./... && go test ./...` — all `ok` (api, configgen, monitor,
store), no failures.
### BACKLOG flagged (not acted on)
External-drive bulk data has **no offsite / second-failure-domain backup** — cross-drive (Tier-2) rsync
lands on the same internal SSD in the same guest. This is why `restic_repo_coord` named nothing real and
was dropped from v1. Design a genuine off-host bulk-data tier before re-introducing a drive backup coord.
Full detail in `felhom-agent/REPORT.md` §8.
### Deploy + pod verification
- `./build.sh 0.14.0 --push` on the build box (auto-pulls `8098237`) → image
`gitea.dooplex.hu/admin/felhom-hub:0.14.0` pushed (digest `sha256:541cdb54…`, 24M).
- `kubectl set image deploy/hub` → rollout succeeded; pod `hub-6c7d8bcc9-454n9` `1/1 Running`.
- Startup log: `[INFO] felhom-hub 0.14.0 starting`; deployed image confirmed `…:0.14.0`.
### Live verification against the real endpoint (throwaway `enroll-spike-2026-06-26`)
Curl-against-real-endpoint (the exact call the future host-bootstrap script makes). Secrets stayed
resident on the build box; only statuses / non-secret host_id / identical-bytes booleans surfaced.
| Check | Result |
|-------|--------|
| 1. first enroll (correct passphrase) | **201**, `host_id=enroll-spike-2026-06-26-c641d7`, api_key 64-hex |
| 2. second enroll (idempotent) | **200**, host_id identical YES, api_key identical YES, **exactly 1 host row** |
| 3. wrong passphrase | **401** |
| 4. unknown customer | **404** |
| 5. missing customer_id | **400** |
| 6. **Bearer-auth proof**`GET /hosts/{host_id}/desired-state` with `Authorization: Bearer <enrolled key>` | **200** (the enrolled credential is a real, working host key end-to-end) |
### Cleanup (throwaway discipline, mirroring the spike)
- Deleted throwaway `customer_configs` / `hosts` / `host_reports` rows → counts **0/0/0** (verified).
- Real `demo-felhom` customer confirmed **still present / untouched**.
- Uninstalled temporary `sqlite` from the hub pod (`apk del`); removed all `/tmp/enroll-spike*` scratch.
- The global key was read out-of-band into a shell var only; **no secret** written to disk, any
committed file, or this report.
### Observations / out-of-scope (not acted on)
- `POST /admin/hosts` is still **PROVISIONAL** (`handler.go:595`) and is left **as the operator/global
escape hatch** — its removal/lock-down is a separate cutover decision, deliberately not done here.
- `GET /config/{id}` was **not** modified (no host fields, no JSON, raw-YAML body intact) — the in-guest
controller pull is unaffected.
- The host-bootstrap script (Day-0 wiring) is the **next** task — not started here. `host-enroll`'s
mint-once-reuse is the idempotency primitive that step will depend on.
- Under option A, `admin/hosts` re-mint still orphans a prior host (no idempotency there); option C
fixes this structurally for the enroll path.