§7.2 answer: YES. A real day-0 from the existing v1.25.0 ISO reached a claimable,
app-serving box in ~10 minutes unattended, and an app's data came back from the
drive with the guest's app.yaml gone — proven readable by the application over
its own TCP path, with a discriminator (PRE-BACKUP row = 1, POST-BACKUP row = 0).
Part 0: NO ISO rebuild needed, verified against the ISO on disk rather than from
source. It bakes only felhom-bootstrap.sh, its unit and the secret-free pairing
env (full-base64 match, 1 hit each) and 0 hits for any installer, controller or
golden marker. The installer is fetched at run time; the live URL is byte-identical
to repo HEAD (v1.22.0, six days newer than the ISO) and the fresh box ran it.
Part 1: baked 0.188.0 rather than the brief's 0.187.0 — 0.187.0 lacks D5, which
is the very claim Part 2 step 6 tests. Published (404 pre-gate with a 200 control;
anonymous download, 649310288 bytes, sha match), vouched, and consumed by a real
box. R-120's gate exercised BOTH ways: 0.185.1 refused with no write, 0.188.0
allowed — evaluated, not silently skipped.
Part 3: RUNBOOK-manual-build.md cited a "RECORDED" qemu line that is itself
labelled reconstructed and whose source says it was never saved. The real
invocation is now captured from this bake as §4.0, with the bake/publish/teardown
steps; the old entry is marked SUPERSEDED.
Teardown all three layers, hub disposition stated: VM destroyed, scratch storage
removed with space returned exactly, customer sess-g DELETED via full cascade.
sess-f deliberately left (R-131) with its command recorded.
Filed, none fixed: R-128 (false ISO_VERSION invariant comment), R-129 (demo-hp's
"no baked SSH key" is stale — key auth works), R-130 (HARD_MIN_LVM_GIB warns and
proceeds), R-131 (fourth orphaned scratch customer), R-132 (curl's %{redirect_url}
printed the hub operator password into a transcript — HUB_PW needs rotating).
23 KiB
Tester gate — re-bake the golden, and prove a fresh box is safe to hand to a tester (2026-07-31)
The sentence that matters (§7.2): YES — a fresh install from the existing v1.25.0 ISO is safe to
hand to an external tester. Installed clean and unattended, registered, claimed, deployed two apps,
and restored an app's data with the guest's app.yaml gone — with the data proven readable by the
application over the path the application actually uses. Caveats in §8; none of them blocks a tester.
Golden re-baked 0.186.0 → 0.188.0 (D5), published, and vouched. No ISO was rebuilt — Part 0 showed none was needed. Nothing already installed changed.
1. Baselines — established separately, at the start of this run
| What | Value | How |
|---|---|---|
controller main |
v0.188.0 (D5) | HEAD=origin/main=4115e88, tree clean |
controller on demo-felhom (9201) |
0.187.0 | pct exec 9201 -- cat /etc/felhom-controller-image |
controller on demo-hp |
0.187.0 | hub /configs row (the box's own report) |
| golden's baked controller, before this run | 0.186.0 (sha b760ac6a…) |
hub manifest; drill/bake-0.186.0.log |
| agent published + vouched | 0.118.1 (sha 9a74c87d…) |
hub manifest; matches felhom-agent --version on felhom-pve |
| hub | 0.83.0 | deploy/hub image tag |
| ISO | v1.25.0, built 2026-07-23 | build-felhom-iso.sh:44; on-disk manifest |
felhom.eu HEAD |
0a9bd38, clean |
git rev-parse |
| managed floor | v0.156.0 (DB override) | hub /configuration |
The brief expected golden 0.186.0 and fleet 0.187.0 — both correct. It also assumed main was
0.187.0; it is 0.188.0. See §3.
2. Part 0 — does the ISO need rebuilding? NO
1. What the ISO contains. Three Felhom payloads, base64-injected into the first-boot stub
(scripts/iso/stub-first-boot.sh:22-38, filled at scripts/iso/build-felhom-iso.sh:324-329):
felhom-bootstrap.sh, felhom-bootstrap.service, and the bootstrap env. Plus answer.toml (fqdn,
disk-setup, a per-build throwaway root hash) and the GRUB branding. No controller image, no golden
archive, no installer copy.
Verified against the ISO on disk, not inferred from source — full-base64 match, one hit each:
| Probe | Hits |
|---|---|
base64 of repo-HEAD felhom-bootstrap.sh (36 692 chars) |
1 |
base64 of repo-HEAD felhom-bootstrap.service |
1 |
| base64 of the reconstructed secret-free pairing env | 1 |
SCRIPT_VERSION="1.2 (host-install marker) |
0 |
felhom-controller |
0 |
vzdump-lxc-9100 (golden marker) |
0 |
2. What the bootstrap fetches at run time. felhom-bootstrap.sh:96 —
INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}", fetched on
every attempt at :322-326. Served from the repo's /scripts by git-sync
(manifests/webpage.yaml:71-75). Live check: 200, and byte-identical to repo HEAD (both sha256
ab8b283e…), SCRIPT_VERSION 1.22.0 — committed f6aed82 on 2026-07-29, six days after the ISO was
built. Proven live in §5: the fresh box ran v1.22.0.
3. How the golden reaches a fresh box. The hub-vouched artifact manifest.
scripts/felhom-host-install.sh:423-433 resolve_artifacts() GETs
$HUB_URL/api/v1/artifacts/$CUSTOMER_ID authed with the retrieval passphrase, reads
golden.version + golden.sha256, then fetches from Gitea and verifies the sha. Nothing baked.
4. Version pins inside the ISO. Four candidates; none pins the box's controller or golden:
ISO_VERSION="1.25.0"(build-felhom-iso.sh:44) — filename/manifest tag, read by nothing at run time.- The baked
felhom-bootstrap.sh— this one IS frozen; a bootstrap change would require a new ISO. Currently identical to repo HEAD, so no drift today. This is the single thing that could ever force an ISO rebuild, and it is the answer to "what is baked". answer.toml's disk-setup + throwaway root hash — per-build, not versions.- The env's two URLs — endpoints, not versions.
Verdict: no ISO work. Re-baking + vouching the golden is sufficient, and that is what was done.
3. Part 1 — bake, publish, vouch
3.1 The version baked, and why not 0.187.0
Part 1 said re-baking to 0.187.0 would level R-120's gate. It would — but it would ship a golden
without D5, and Part 2 step 6 is the D5 claim, so the proof this task exists for could not have
passed. The operator page also says "rebuilt on the current controller", and current is 0.188.0.
The brief's arithmetic predates D5 landing (2026-07-30). Baked felhom-controller:0.188.0, which
also satisfies R-120 (configs.go:1166 refuses only compareVersions(golden, newest) < 0; 0.188.0 vs
the fleet's 0.187.0 is not < 0). Recorded here rather than absorbed silently, per standing rule 4.
3.2 The bake
Venue: the DooPlex golden-bake nested VM, the accepted exception for bakes
(target-selection.md:59-61). Reverted to virgin first, restored to virgin after.
build-golden.sh v2.1.0, template debian-13-standard_13.6-1_amd64.tar.zst.
Pass markers, as returned: docker OK (overlay2; data-root /var/lib/docker); including mount point
for rootfs, mp0 and mp1 with no excluding and no FATAL; archive 619 MB;
upload OK (HTTP 201). Infra images baked = 4, resolved from the controller itself
(--print-infra-images), not the drift-prone fallback list: traefik:v3.6.7,
cloudflare/cloudflared:2026.6.0, gtstef/filebrowser:1.3.3-stable,
gitea.dooplex.hu/admin/felhom-samba:1.1.0. Literal-token leak grep on the saved log: 0.
GOLDEN_VERSION=0.188.0
GOLDEN_SHA256=7353d8beb63641f87a848e45f8aa12e465647e1190ad164a65b32ad01fc3d299
3.3 The three observables, as returned
1 — published. 404 pre-gate first, with a discriminating control so the check is not vacuous:
golden 0.188.0 pre-gate: HTTP 404
golden 0.186.0 (control): HTTP 200
Then an anonymous full download — what a fresh box does, no auth header:
http=200 bytes=649310288
7353d8beb63641f87a848e45f8aa12e465647e1190ad164a65b32ad01fc3d299 golden.tar.zst
Byte count and sha both identical to what the bake printed.
2 — the manifest vouches it, read back:
<option value="0.188.0" data-sha="7353d8be…" selected
name="golden_sha256" value="7353d8beb63641f87a848e45f8aa12e465647e1190ad164a65b32ad01fc3d299"
3 — a consumer resolves it. The exact call felhom-host-install.sh:425 makes, against the
public hub, passphrase-authed:
GET https://hub.felhom.eu/api/v1/artifacts/sess-g → HTTP 200
{"agent":{"version":"0.118.1","sha256":"9a74c87d…"},
"golden":{"version":"0.188.0","sha256":"7353d8be…"}}
4 (stronger, from §5) — a real fresh box consumed it:
[INFO] manifest: agent v0.118.1 (sha 9a74c87dc97f259b…), golden v0.188.0
[INFO] fetching golden v0.188.0 from Gitea → …/vzdump-lxc-9100-2026_07_31-07_40_20.tar.zst
[OK] verified sha256 7353d8beb63641f8… matches the hub manifest
3.4 R-120's gate: it evaluated, and it allowed
An absent refusal is not evidence (standing rule 3), so the gate was exercised both ways against the live hub before the real vouch. Vouching golden 0.185.1 (below the fleet's 0.187.0) first:
HTTP/1.1 303 → /configuration?flash=golden_behind_fleet
[WARN] artifact vouch REFUSED: golden 0.185.1 is older than the newest controller the fleet
reports (0.187.0) — a fresh install would land on stale application code (R-120)
and the manifest still read 0.186.0 afterwards — the refusal wrote nothing. Then the real vouch:
[INFO] Artifact manifest set: agent=0.118.1 golden=0.188.0 min_agent="0.113.0" wrapper_sha=true
Evaluated and allowed. For the record and not for action: the gate lives in the vouch form
(hub/internal/web/configs.go:1165) and cannot fire on an image deploy, so it does not cover every
drift path. Not widened here.
4. Part 2 — venue
demo-hp (t740), Tier 0, the designated drill+build host. The brief's venue caution is accurate
about the current storage.cfg — local carries no images content type and local-lvm is fenced
— but target-selection.md:38-40 names the fix: "Put VM disks on a dir storage at /mnt/nvme-1tb,
at its root", and "remove scratch storages when done". A temporary cc-scratch dir storage was
added at that path and removed at teardown (§7). felhom-pve was the alternative and was not chosen:
11 GB free RAM against demo-hp's 25, and it is the Tier 0 box that carries the PBS-DR/offsite tier.
Nested virt confirmed on (kvm_amd/parameters/nested = 1). drill-r50 (VM 300), guest 9201 on both
boxes, local-lvm, and both backup targets were untouched.
VM 310: q35/OVMF with pre-enrolled-keys=0 (the ISO is a -mkimage build — unsigned loader,
Secure Boot must be off), 8 GB, 4 cores, 150 G qcow2, boot order=scsi0;ide2 so the empty disk
falls through to the ISO and the installed disk wins afterwards. The ISO already present on demo-hp
was verified byte-identical to the DooPlex build (99b38420…).
5. Part 2 — the proof, step by step
| # | Step | Result |
|---|---|---|
| 1 | Install from the ISO, real day-0 | PASS |
| 2 | Register and claim | PASS |
| 3 | Controller is the new one, from the box | PASS |
| 4 | Two apps deployed, one with a database | PASS |
| 5 | Discriminating data seeded | PASS |
| 6 | Back up, restore with app.yaml moved aside |
PASS |
| 7 | Data readable by the app over its real path | PASS |
Step 1 — install. Unattended from boot to provisioned in ~10 minutes. PVE 9.2.2 installed,
first-boot stub laid down the retry unit, the box registered itself as an unclaimed appliance
(pairing code HTG-N8D), was bound to customer sess-g on the hub, and the delivery landed on the
next poll. The installer that actually ran: felhom-host-install v1.22.0 — fetched at run time,
six days newer than the ISO. That is Part 0's claim, proven on a real box.
[OK] Day-0 provision SUCCESS — vmid=9201 host_id=sess-g-e687c3 customer=sess-g
golden=local:backup/vzdump-lxc-9100-2026_07_31-07_40_20.tar.zst
Secrets scrubbed on success: /etc/felhom/ retained only .bootstrap-done and the non-secret
pairing code; bootstrap.env and the appliance token were shredded.
Step 2 — register and claim. Host enrolled, wg peer registered, host-report accepted, DR-recipe
stored. The claim gate was armed from first boot and the claim was a real one — the code exists
only in the customer's email (R-119; the operator relayed it). The positive discriminator flipped:
before: {"ok":false,"error":"dashboard not yet claimed"}
after: {"ok":false,"error":"authentication required"}
hub: [INFO] [claim] customer sess-g CLAIMED its dashboard (password set by the customer)
Step 3 — the controller, from the box, not from the manifest:
/etc/felhom-controller-image : gitea.dooplex.hu/admin/felhom-controller:0.188.0
docker ps : gitea.dooplex.hu/admin/felhom-controller:0.188.0 | Up (healthy)
felhom-agent --version : felhom-agent 0.118.1
Step 4 — two apps, through the real endpoints (GET /stacks/{app}/deploy for the pre-generated
values, then POST /api/stacks/{app}/deploy, the endpoint the UI invokes):
- rallly —
lukevella/rallly:4.11.1+postgres:16-alpine, the DB app. Chosen because itsDB_PASSWORDistype: secret, i.e. exactly D5's travelling class, and itsDATABASE_URLis a TCP URL to a different container:postgresql://rallly@rallly-postgres:5432/rallly. - homebox — sqlite, no DB container.
Both healthy; rallly served 200 through traefik on its own route.
Step 5 — the discriminator. cc_proof table, one row before the backup
(PRE-BACKUP-sess-g-2026-07-31), one after (POST-BACKUP-sess-g-MUST-NOT-RETURN). Without this
pair a stale restore is indistinguishable from a good one.
The read path, established and red-proofed before it was trusted. All DB access ran from
rallly's own network namespace (docker run --network container:rallly … psql -h rallly-postgres)
— the path DATABASE_URL names, never 127.0.0.1 inside the postgres container, which postgres
trusts and which produced D5's §1.2 false pass. Proven to be doing real work:
correct password : rallly|t|t (user=rallly, over_tcp=t, PostgreSQL 16)
wrong password : FATAL: password authentication failed for user "rallly"
Step 6 — backup, then restore with the guest gone.
[backup] Recovery unit captured for rallly → /mnt/sys_drive/felhom-data/backups/primary/rallly
(images=2, secrets-referenced=2, data_keys=0, portable-carried=2/2, withheld=0)
[backup] Volume dump: rallly/rallly_rallly_postgres_data → 65.0 MB
Unit compose/app.yaml: mode 0600, header naming the carried class, both secrets present.
Manifest schema_version: 2, controller_version: 0.188.0. The carried DB_PASSWORD was verified
by fingerprint, never printed, against the live one:
unit DB_PASSWORD sha256=14c8f5152b205829
live DB_PASSWORD sha256=14c8f5152b205829
and the guest's own copy was ENC: at rest. The guest's app.yaml was then moved aside —
confirmed absent — and the restore run through POST /backup/restore:
[backup] Restoring rallly from recovery unit: images=2, secrets recovered=2/2, data_keys=0
[backup] Restored 1 Docker volume(s) for rallly
[backup] Restore rallly: replaying DB dump into rallly-postgres (postgres)
[backup] Restore-from-unit completed: rallly (30.97 s)
2/2 with the guest's app.yaml gone.
Step 7 — the application reading its own data. The live app process was asked for its own credential, and it matches the pre-backup one — so the secret genuinely survived the guest:
host=rallly-postgres port=5432 user=rallly pw_sha256=14c8f5152b205829
Then the read, over that path:
current_user | server_ip | over_tcp
rallly | 172.19.0.2/32 | t
id | marker
1 | PRE-BACKUP-sess-g-2026-07-31
PRE-BACKUP rows = 1
POST-BACKUP rows = 0
The discriminator held. 32 public tables intact; the guest's app.yaml was regenerated from the
unit with both secrets re-encrypted at rest; rallly's own healthcheck returned 200, and traefik
served poll.sess-g.test 200. Checked the data, not the exit code.
One difference from D5's adventurelog run worth recording: this unit carried both a .sql dump
and the volume tar, and the restore did both (volume restored, then dump replayed). D5's case had
db_dumps: None. The password mattered either way — the replay authenticates with it.
6. Part 3 — the runbook integrity fix
RUNBOOK-manual-build.md:104-106 told the reader to "use its RECORDED qemu launch line; do not
reconstruct from memory", citing RUNBOOK-publish-0.79-0.110-2026-07-10.md:29 — a line labelled
"PASS (reconstructed — DEVIATION)" whose own §Deviations says "the canonical qemu-system
one-liner was never saved". The document forbade improvising and pointed at an improvisation.
Captured verbatim from this bake and recorded as canonical in RUNBOOK-manual-build.md §4.0:
qemu-img snapshot -a virgin /mnt/5_hdd/felhom.eu/drill/drill.qcow2
qemu-system-x86_64 -enable-kvm -cpu host -smp 4 -m 8192 \
-drive file=/mnt/5_hdd/felhom.eu/drill/drill.qcow2,format=qcow2,if=virtio,cache=writeback \
-netdev user,id=n0,dhcpstart=10.0.2.30,hostfwd=tcp::2222-10.0.2.15:22 \
-device virtio-net-pci,netdev=n0 -display none -daemonize \
-pidfile /mnt/5_hdd/felhom.eu/drill/qemu.pid
SSH on :2222 in ~40 s; pve-manager/9.2.2. §4 also now carries the bake/publish/teardown steps and
the template-rot warning, and the old runbook's deviation entry is marked SUPERSEDED with a forward
pointer so the reconstruction is never used as an authority again.
7. Teardown — all three layers
- The machine. VM 310 stopped and
qm destroy --purge --destroy-unreferenced-disks 1.qm listafterwards shows onlydrill-r50(300), untouched;/mnt/nvme-1tb/images/is empty. - The host.
cc-scratchremoved;storage.cfgback to its original four entries.felhom-backupavailable 926 492 284 KiB before the run and 926 492 284 KiB after — the space returned exactly. - The hub.
sess-gand its host record are DELETED — the full cascade ran. See §7.1.
7.1 Layer 3, stated explicitly
The delete gate refused twice before it allowed, which is the gate working, and both refusals are recorded because "I deleted it" without them would hide that this needs a wait:
POST /hosts/sess-g-e687c3/delete → 409 Conflict (host ONLINE)
POST /configs/sess-g/delete (bare confirm) → 400 (all three acknowledgements required)
customer_delete.go:220-227 refuses while hostStatus(...) == "ok", so the record cannot be deleted
until the destroyed box ages out of ONLINE (~30 min after its last report). It flipped ok → stale at
08:25, and the cascade then ran:
POST /configs/sess-g/delete
ack_hosts=1 ack_reset=1 ack_purge=1 confirm_id=sess-g expect_hosts=1
→ 303 /configs?flash=deleted
[INFO] customer DELETE cascade started for sess-g (journal #8, 1 host(s))
[INFO] delete sess-g: host sess-g-e687c3 deleted (escrow DEMOTED to retained custody)
[INFO] tenantsync: deprovision ok for sess-g (ns=sess-g, existed=false)
[INFO] delete sess-g: residue purged (reports=5 app_telemetry=7 notif_prefs=1
selfbind_tokens=1 appliance_registrations=1)
[INFO] customer DELETE cascade COMPLETE for sess-g (journal #8) — full teardown
Verified positively, not by absence — both pages loaded with real content and returned zero hits, and the unclaimed-appliance row is gone by its own UUID:
/configs loaded 10160 bytes, occurrences of 'sess-g' = 0
/hosts loaded 9880 bytes, occurrences of 'sess-g' = 0
/hosts occurrences of appliance uuid f9f5d7e0-… = 0
A polling trap, recorded because it nearly produced a false result here. The first delete-gate
poll loop broke on grep -q '"status":"ok"' || break — and an empty response satisfies that just
as well as a status change. It fired on one transient empty reply and reported "GATE OPEN" while the
host was still ONLINE. Re-run parsing the JSON and treating unparseable as retry, the host was
ok for another four minutes. Standing rule 3, in miniature: the absent signal is not the positive one.
sess-f is NOT deleted — it belongs to the 2026-07-30 session, not this run (R-131). Command for
whoever picks it up, once its host is not ONLINE:
POST /configs/sess-f/delete
ack_hosts=1 ack_reset=1 ack_purge=1 confirm_id=sess-f expect_hosts=<live host count>
Secrets. Shredded in the guest (rallly-dbpw, the moved-aside app.yaml, deploy bodies, session
and CSRF files, claim artefacts) and on the box; helper scripts removed from both. The scratch
customer's retrieval passphrase and the ISO's throwaway root password were handled file→file and
never printed; both die with the session scratchpad and the destroyed VM.
8. Findings — filed, none fixed
- R-128 —
build-felhom-iso.sh:44claimsISO_VERSION"aligns with felhom-host-install SCRIPT_VERSION". ISO 1.25.0, host-install 1.22.0. A comment asserting an invariant nothing maintains. Cosmetic; it misleads a reader about which installer an ISO implies. - R-129 — the docs say demo-hp has "no baked SSH key" and needs the G1 break-glass password
(
CLAUDE.md,target-selection.md:41-42,felhom-agent/CLAUDE.md).ssh -o BatchMode=yes demo-hpauthenticated by key, first try. The prohibition is stale in a direction that costs a future session a needless vault round-trip. - R-130 — a fresh box's
local-lvmwas ~75 GiB againstHARD_MIN_LVM_GIB=120, and the installer logged[WARN] local-lvm free ~75 GiB < hard min 120 GiBand continued to a fully successful install. Either the hard minimum is not hard, or it is wrong. A "hard min" that warns is the R-29 shape. - R-131 (housekeeping) — hub customer
sess-f("R-120 golden 0.186.0 proof", DOWN) is a scratch customer left behind by the 2026-07-30 session — the fourth instance of the accumulationtarget-selection.md:86-87andPROMPT-TEMPLATE.md§13 warn about (drill-r50,sess-c,sess-d, nowsess-f). Not deleted here: it is not this run's record. Command in §7.1. - R-132 —
curl -w '%{redirect_url}'reconstructs the request URL WITH its basic-auth credential. While red-proofing the R-120 gate, a call made correctly with-u ":$HUB_PW"(no credential in any URL) still printed the hub operator password into this session's transcript, because the write-out format rebuilds the URL from the credential curl was given. The request was safe; the reporting was not.HUB_PWshould be rotated. Rule going forward: read a redirect from-D -andgrep '^Location:', never%{redirect_url}, on any authenticated call. - R-119 confirmed live, not re-filed: the claim code is emailed-only with no operator-side route, and it is the single human step in an otherwise unattended day-0. Budget for it before a run.
9. §7.2 — the sentence that matters
Yes — a fresh install is safe to hand to an external tester. From ISO boot to a claimable,
app-serving box was ~10 minutes unattended; the box fetched the current installer and the newly
vouched golden and verified both by sha; two apps deployed through the real endpoints; and an app's
data came back from the drive with the guest's app.yaml gone, readable by the application over its
own TCP path, with a discriminator proving it was a restore and not a no-op.
One thing the tester's host must do that the operator must plan for: the claim code arrives only by email (R-119), so the tester needs a working mailbox on the customer record before the box boots. Nothing else found here blocks a tester; R-128/R-129/R-130 are documentation and gate-hygiene issues, and R-131 is housekeeping.
What "safe" does not mean: bug-free. Two things this run did not cover — the offsite/PBS-DR
legs (deliberately: the task forbids pointing anything at production PBS or the real restic offsite,
so sess-g was created with DR tier off and offsite disabled), and any bare-metal ISO. Only a
nested-vm-profile v1.25.0 ISO exists on disk; a tester on real hardware needs a generic- or
hp-profile build from the same tooling, which is an ISO build and therefore its own task.
10. What did not happen, and why
- No ISO built — Part 0 said none was needed, and the brief says to stop and report if one were.
- No defect fixed — R-128/129/130/131 filed only; a fix mid-run would describe a version that never existed.
- No existing box changed — no golden deployed to 9201 on either demo host, no floor moved (still v0.156.0), no MinAgent change (still 0.113.0).
- Campaign 10, the demo-hp repartition, subdomain onboarding — out of scope, untouched.
sess-fnot deleted — see R-131.