Move the local whole-guest backup off the guest's own device (demo-hp + demo-felhom)

Supervised operational run. No code, no version bump, nothing deleted.

Primary backup tier on both demo boxes moved from `local` (a dir storage on
/var/lib/vz -- the SAME physical device as the guest) to `felhom-backup`, a dir
storage on each box's secondary drive:

  demo-hp      /mnt/nvme-1tb  uuid:91d2dc2d-...  archive 2,256,044,492 B
  demo-felhom  /mnt/hdd_1     uuid:47a3361a-...  archive 5,957,878,962 B

Both proven end to end via the real UI path: archive lands on the secondary
drive (df delta matches the archive byte-for-byte), restore-test auto-selects it
and passes with mount_parity: ok, and freshness survives an agent restart with
an empty in-memory store -- so the age can only have come from the new storage.

Phase 0: the target is CONFIGURATION, not converged (the sole writer of
agent.json touches only escrow.pbs_storage_id and preserves unknown keys), so
the runbook's STOP did not fire. No consumer hardcodes "local" on the backup path.

Findings:
- F-1  the storage path must BE the mountpoint; a subdirectory fails exactMount
       and the target reports disconnected permanently (observe.go:321)
- F-2  --is_mountpoint 1 is load-bearing; proven live, an unguarded storage on a
       non-mounted path reports active with the ROOT filesystem's free space and
       had already created dump/ on pve-root -- a silent retarget onto the very
       device this change escapes
- F-3  FelhomAgentStore is granted per storage path; without it every backup
       403s. felhom-host-install.sh must issue it for new installs
- R-109 (new) the DR recipe records no backup target, and each box now carries
       two content=backup dir storages, one live and one frozen
- R-105 narrowed and TRACED: dr_recipe drives was [] fleet-wide because the
       enrolled drives were never PVE storages, so isUserDataDrive never saw
       them. Both boxes now populate drives; SMART on the backup drives too

Absent-drive behaviour today is fail-loudly with no silent retarget (PVE half
live-proven; agent half source-traced). That is NOT the intended fall-back-and-
alarm design -- filed as E-2 with the honest single-drive label.

Reported in full in the record: the agent was restarted with a felhom-pbs backup
in flight, producing a spurious tier failure. The backup had in fact succeeded
(PVE task OK, 6,264,034,053 B snapshot) and the spurious failure reached no
channel -- R-84 ground truth superseded it.

Outstanding: full drive-loss recovery (needs physical access) and the agent half
of the absent-drive behaviour.
This commit is contained in:
2026-07-28 21:38:13 +02:00
parent 070b0ce2dc
commit b5a73e050b
4 changed files with 641 additions and 81 deletions
+73 -80
View File
@@ -1,103 +1,96 @@
# REPORT — C9-F1 + C9-F2: a restore that restored nothing, and a crash loop nobody saw (2026-07-28)
# REPORT — the local whole-guest backup moved off the guest's own device (2026-07-28)
**Overwritten** per the standing rule. **Shipped: controller v0.183.0**, live on **both** demo boxes.
Fleet: hub v0.80.0, agent v0.110.0, controller **0.183.0**. `peti-felhom` untouched.
**Overwritten** per the standing rule (the prior C9-F1/C9-F2 text stays in git history).
**Supervised operational run — no code, no version bump.** Fleet unchanged: hub v0.80.0,
agent v0.110.0, controller v0.183.0. `peti-felhom` untouched. **Nothing was deleted.**
Both defects are the same shape — the system reporting healthy while the customer is not — and both
live in the same status-derivation code.
Record: `documentation/runbooks/RUNBOOK-vzdump-target-move-2026-07-29.md`.
## Phase 0 — the asymmetry, sized before designing anything
---
Tier-2 writes **two** things on every run: the capture legs (`hdd/`, `userdata/`) and, always, a full
`recovery-unit/` (DB dumps + named-volume tarballs). `RestoreTier2Files` reads **only the two legs**
(`tier2_restore.go:101-104`) and has never opened `recovery-unit/`.
## What changed
All 53 catalog templates enumerated, cross-checked against both boxes' actual copies:
On both demo boxes the primary backup tier moved from `local` — a `dir` storage on `/var/lib/vz`,
i.e. **the same physical device as the guest itself** — to **`felhom-backup`**, a `dir` storage on
the secondary drive.
| class | count | what the restore can return |
| | demo-felhom | demo-hp |
|---|---|---|
| **A** | **9** | the file legs only — **never** their database or named volumes |
| **B** | **43** | **nothing at all** — a guaranteed no-op, forever |
| C | 1 | `bentopdf`, stateless |
| Target drive | `/dev/sdb` USB HDD → `/mnt/hdd_1` | `/dev/nvme0n1``/mnt/nvme-1tb` |
| Durable id | `uuid:47a3361a-91e0-4831-a69d-27f540ed3f48` | `uuid:91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae` |
| Archive proven | 5,957,878,962 B | 2,256,044,492 B |
| Restore-test | `pass`, **`mount_parity: ok`**, 1 m 24 s | `pass`, **`mount_parity: ok`**, 1 m 54 s |
Four apps (`plex`, `jellyfin`, `emby`, `navidrome`) are in B only because their single bind is a
`:ro` media mount, which `ClassifyBinds` correctly excludes. **81% of the catalog.**
Three steps per box: `pvesm add dir … --is_mountpoint 1`, two `pveum acl modify`, and a one-line
`local_backup_target` edit in `agent.json`.
**The asymmetry is Tier-2's alone.** Tier-1 (`RestoreFromRecoveryUnit`) and offsite both restore the
unit and replay volume dumps — so BookStack already had a working restore; only this button lied.
**Drive loss is now locally recoverable in principle on both boxes** — the whole-guest archive lives
on different hardware from the guest, and a restore from it boots and passes mount parity. The proof
that closes matrix row 4 (pull the system drive and restore for real) needs physical access and is
outstanding.
## Shipped — Part 1a (honesty)
## Phase 0 verdict
- **Refuses UP FRONT.** `Tier2RestoreCoverage` is consulted before any op begins; a class-B app is
refused **without being stopped**. Live: BookStack uptime stayed `Up About an hour` (Campaign 9
left it at `Up 25 seconds`).
- **Names the working action** rather than dead-ending 81% of the catalog:
> „Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem
> állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon."
- **Claims only what was examined** (the QUIET half — immich's 1.3 GB Postgres unit is not covered,
so the old blanket sentence was a clean bill of health over data never opened):
> „Minden vizsgált fájl megvan a helyén." + „Az alkalmazás adatbázisa és belső kötetei nem
> tartoznak ebbe a visszaállításba."
**The target is configuration, not converged — the runbook held and the §3 STOP did not fire.**
Exactly one writer of `agent.json` exists (`pbsdr.seedEscrowStorageID`); it touches only
`escrow.pbs_storage_id` and preserves unknown keys verbatim via a `map[string]json.RawMessage`
read-modify-write. Nothing on the hub pushes agent config. No consumer hardcodes `"local"` on the
backup path — runners are one-per-tier, `NewestArchiveTime` reads its own target, and
`restoreTierForArchive` classifies from the archive, not from config.
## Shipped — Part 2 (C9-F2)
## What the run found
`StateRestarting` is deliberately **NOT** added to `IsDownState` — that alarms on every deploy and
update fleet-wide. A **sustained** run becomes down after `crashLoopAfter = 5m`, set above the three
real numbers already in the codebase: the deploy flow's **120 s** health timeout, Mealie's **60 s**
`start_period`, and R-97b's **180 s** quiesce grace (so the windows compose into one bounded delay
instead of leaving a gap). Docker's backoff caps at 60 s, so a real loop registers ≥4 attempts inside
it. Carried by `Stack.RestartingSince` (not persisted) + `Stack.CrashLooping(now)`, used by **both**
the alarm and the dashboard counter — which previously counted `restarting` as running and so
contradicted the alarm on the same screen.
- **F-1 (before any command).** The storage `path` must **be** the drive's mountpoint. A
subdirectory fails `exactMount` (`internal/storage/observe.go:321`), so the target would report
`disconnected` **permanently** and its durable id would degrade off the filesystem UUID.
- **F-2 (before any command).** `--is_mountpoint 1` is load-bearing. **Proven live:** an unguarded
`dir` storage on a non-mounted path reports `active`, advertises the **root filesystem's** free
space, and had already created `dump/` on `/dev/mapper/pve-root` — a silent retarget onto the
exact device this change exists to escape. The guarded one refuses outright.
- **F-3 (found by the first real backup).** `FelhomAgentStore` is granted **per storage path**; a
new target without its own grant 403s every backup. **`felhom-host-install.sh` must issue it for
new installs**, or a new box ships with a tier that fails on its first run.
- **R-109 (new).** The DR recipe records **no backup target** — and each box now carries two
`content=backup` dir storages, one live and one holding frozen 2026-07-28 archives.
- **R-105 narrowed and traced.** `dr_recipe.host_half.drives` was `[]` fleet-wide with the cause
untraced. Cause: the enrolled drives were never PVE storages, so `isUserDataDrive` never saw them.
Both demo boxes now populate `drives`, and the backup drives gained SMART reporting. R-105's other
two fields are untouched.
## Live replay (demo-hp + demo-felhom)
## Absent-drive behaviour (Part 3)
| # | scenario | result |
|---|---|---|
| 1 | **crash loop alarms** | Ten consecutive 30 s samples **silent** through the threshold window, then `17:05:40 Event pushed: app_start_failed (warn) — Telepített alkalmazás nem fut: Uptime Kuma`**5m25s** after the loop began (5 min + one scan). |
| 1b | **heartbeat COUNTS it** | `17:06:10 [deadapp] check alive: 20 scans since boot, 2 deployed app(s) evaluated, **1 currently down**` — Campaign 9's evidence was `0 currently down` while an app looped. |
| 2 | **normal deploy is silent** | A real `uptime-kuma` deploy produced only `app_deployed (info)`; no alarm, with deadapp-check running every 30 s throughout. |
| 3 | **restore refuses without an outage** | The honest message rendered; `[WARN] Tier-2 file restore refused up front: stack=bookstack has no restorable subtree in its copy (unit_present=true) — app NOT stopped`; BookStack uptime unbroken. |
| 4 | **paperless still restores** (regression guard on Campaign 9's headline) | 3 files deleted → restored → **43/43 byte-identical to the pre-deletion sha256 set**, `documents_ok 16 of 16 problems []`. |
Today: **fail loudly, no silent retarget.** The PVE half is live-proven with throwaway storages (no
live drive was unmounted). The agent half is source-traced: `targetStoragePresent` checks name
presence only, never `Reachable`, so the tier stays DUE, the controller quiesces, vzdump is refused,
and the run fails and alarms.
## Filed, not fixed
**This is not §6's intended design** (fall back to the system drive and alarm). There is no fallback
at all, so an absent drive means no local backup until a human intervenes. Filed as **E-2**, together
with the honest single-drive label — a one-drive box protects against corruption only, and two
drives is effectively a hardware requirement for drive-loss protection.
- **C9-F1b** — route class-B apps to the Tier-1 unit restore from the card the customer already
opened. Its own task **deliberately**: it puts a DESTRUCTIVE operation (overwrites live data with
the backup state) behind a button reached via a NON-destructive one, so the confirm copy must carry
that difference.
- **C9-F4** — **nothing reads the Tier-2 copy's `recovery-unit/` mirror.** Written by every Tier-2 run
(`tier2.go:369`), read by no path: `RecoveryUnitPath` resolves to `backups/**primary**/`
(`appbackup/paths.go:46-48`), and the only reader of the secondary tree is `tier2_restore.go:79`.
Tier-2 exists for the case where the PRIMARY drive is lost — and in exactly that case the primary
unit is gone while this mirror survives on the second drive, unreachable by any customer action,
leaving offsite as the only route. **Potentially larger than C9-F1.**
## One operational error, reported in full
## Tests
**The agent was restarted on demo-hp with a `felhom-pbs` backup in flight**, against §4.3. The
in-flight check was done before the first restart and not repeated before the second. It produced a
**spurious tier failure** (`context canceled` while waiting) — the F-A1 class the project already
fixed once.
`go test ./...` **rc=0, 27 packages** — run and `rc` read *separately* from the commit. Six red-proofs
all observed, including the one that matters most: adding `StateRestarting` to `IsDownState` fails the
brief-restart test with *"every deploy and update would page the operator"*.
**The backup had not failed:** the PVE task returned OK and the PBS snapshot `2026-07-28T19:19:45Z`
is 6,264,034,053 B. The spurious failure **reached no channel** — the agent died with its in-memory
record and R-84 ground truth superseded it; the controller's event trail for the window shows only
the real 403 failure and its recovery. The design absorbed it; the mistake was still a mistake, and
on a slower tier the same slip could have aborted a multi-hour WAN upload.
## Observations
Also recorded: `pgrep -f vzdump` self-matches a polling script's own command line and is not a safe
in-flight check — use the PVE task list.
- **A seventh shipped-invariant-comment.** `controller/README.md` stated that faults "still surface as
`exited`/`degraded`/**`restarting`**/`unhealthy`" — but `restarting` was in no down set at all. The
sentence was a wish with no test pinning it. Corrected in place, with the threshold rule documented
beside it.
- **Pre-existing gate failure, not mine:** `scripts/docker_run_volume_path_gate.py` fails on
`internal/appexport/estimate.go:179` (an unreviewed `docker run -v`). Verified it fails identically
on clean HEAD; left alone as out of scope rather than silently "fixed".
- The other six gates pass (`template_id`, `emoji`, `mojibake`, `native_confirm`, `app_row_dedup`,
`offbox_rename`).
## State at close
## NOT yet live-validated — carried forward
Both boxes healthy. Primary and offsite tiers `due=false` with `age_state=known` on both; breakers
clear; agents `active`; no thrash and no spurious staleness. Target drives at 12 % used with SMART
`PASSED`. The old archives (18 GB demo-felhom, 5.2 GB demo-hp) are **left in place** as the rollback
and as the only evidence of what the previous configuration produced.
- **Tier-1 content recovery after real loss** — still the most valuable unproven item (Campaign 9's A2
ran against an intact app; A3 used Tier-2). Unchanged by this work.
- The C9-F2 threshold under a **quiesce** cycle (Scenario C) is unit-proven but was not replayed live;
it needs a backup window plus an app that fails to come back.
- C9-F1's refusal for the other 42 class-B apps is proven by enumeration and by BookStack live, not
app-by-app.
- Host reboot mid-backup, three-way concurrency with GC, Scenario C live, `offsite_stale` firing,
F-HUB — all still open from Campaign 9.
**Outstanding:** full drive-loss recovery (physical access), and the agent half of the absent-drive
behaviour (needs a drive unmount that would break the guest bind on a remote box).
+2
View File
@@ -39,6 +39,8 @@ State: `BLOCKED` · `READY` · `WAITING-ON-OPERATOR` · `WATCHING`. Every row ha
| **F-REBOOT** | ~~A guest rebooted during its backup does not come back — shutdown completes, start never happens, no self-heal; 9m47s total appliance outage with every alarm silent~~ | **SHIPPED + PROVEN-LIVE** (agent v0.107.0, 2026-07-28) | — | 60 s guest-power watchdog; `onboot` is the deliberate-stop discriminator (already the stale-lock path's, and what `pve-guests` consults), retry bounded 3x/1m-2m-4m then escalates once. Live on demo-hp: **120 s unattended** vs the incident's 587 s with a human; Scenario B proven (an `onboot:0` guest left stopped) | — |
| **F-LEAK** | ~~A failed restore-test cannot destroy its own scratch guest (403 `VM.Allocate`); the 10-slot VMID band shrinks silently~~ | **SHIPPED + PROVEN-LIVE** (agent v0.110.0 + host-install v1.21.0, 2026-07-28) | — | **Three attempts, two refuted live.** (1) Pool adoption: `PUT /pools/{pool}` also needs `VM.Allocate` on the VM — membership cannot bootstrap its own authority. (2) Per-path `/vms/990000..990009` ACLs: work, but PVE's destroy calls `remove_vm_access` (`LXC.pm:906`) which deletes every ACL at `/vms/<vmid>`**consumed by the op it authorises**, one use per slot. (3) SHIPPED: 4th root-fenced exception, band enforced in sudoers **literally** (`pct destroy 99000[0-9] --purge`) + in code + at the caller; API destroy still tried first. Live: band PERMITTED, `9201`/`9100`/`9999`/`990010`/`1` REFUSED, and `pct start 990000` REFUSED too | — |
| **F-OBS** | ~~`deadapp-check` leaves NO positive observable on a default (info-level) box — "no alarms" was indistinguishable from "never ran"~~ | **SHIPPED + PROVEN-LIVE** (controller v0.180.0 + agent v0.109.0, 2026-07-28) | — | INFO summary every 20th scan carrying scans/evaluated/down. **Agent v0.109.0 fixes the same shape in the guest-power watchdog shipped hours earlier in v0.107.0** — it logged only at startup and when it acted, so its health could be read only from absence | — |
| **E-2** | Drive-role machinery around the moved vzdump target: backup-target role on `StoragePath`, wizard assignment (never by transport/`removable`), no automatic roles, stickiness, `felhom-host-install.sh` creating the target **+ its `FelhomAgentStore` ACL**, absent-target policy, retention/space on a shared drive, honest single-drive label, remaining fleet migration | **READY (M)** | — | Architecture change PROVEN by hand on both demo boxes 2026-07-28 (`runbooks/RUNBOOK-vzdump-target-move-2026-07-29.md`); this is the machinery around a proven thing. Flips matrix row 4 | CC |
| **R-109** | **The DR recipe records no backup target.** It lists every storage's name/type/content but never which one holds the local archives — and each demo box now carries TWO `content=backup` dir storages, `felhom-backup` (live) and `local` (frozen 2026-07-28 archives) | READY (XS) | — | Add the resolved `BackupTarget()` to the host-half. Third recipe-completeness defect beside R-105/R-106 | CC |
| **R-89** | Retention as a per-customer **commercial** policy on the hub | READY (increment 2) | — | Policy object + reconciler → ep0 prune job; keep box tokens write-only | CC |
| **R-92** | Hub PBS-DR gauge is 0.1 GB-granular — small deltas unverifiable | READY (XS) | — | Widen precision when retention becomes customer-visible | CC |
| **R-93** | `drill-r50` is both a blocked customer and the only drift fixture | READY (XS) | — | Retire it for a synthetic fixture, or unblock + silence per-customer | CC |
+3 -1
View File
@@ -99,10 +99,12 @@
| R-102 | **Tier-2 writes a full `recovery-unit/` mirror on every run and no code path reads it.** Written at `internal/backup/tier2.go:368-369` ("Unit leg (always)"); `RecoveryUnitPath` resolves to `backups/**primary**/` (`internal/appbackup/paths.go:46-48`) and the only reader of the secondary tree is `internal/backup/tier2_restore.go`, which reads `hdd/`+`userdata/` only (`:101-104`) | M | READY — 2026-07-28 | **Was C9-F4** (`OPEN-ITEMS.md`). The sharp edge is *when* it bites: Tier-2 exists for primary-drive loss, and in exactly that failure the primary unit is gone while this mirror survives on the second drive, unreachable by any customer action — leaving offsite as the only route. LIVE: demo-felhom's `backups/secondary/{bookstack,docmost}/` hold `recovery-unit` and nothing else, at 156 MB and 86 MB. Flips: the Tier-2 row in map §C; `07` §6.3, §7.2 |
| R-103 | **The Tier-2 no-coverage refusal names the working action but does not route to it.** v0.183.0 refuses up front without stopping the app and tells the customer to use „Visszaállítás indítása" on the other page; it does not take them there | S | READY — 2026-07-28 | **Was C9-F1b.** Deliberately its own item: it puts a DESTRUCTIVE operation (overwrites live data with the backup state) behind a button reached via a NON-destructive one, so the confirm copy must carry that difference. Flips: nothing until shipped; `07` §10.2 |
| R-104 | **An interrupted offsite run leaves an exclusive restic lock the existing self-heal cannot reach.** `resticStep` has `unlock --remove-all` (`internal/backup/offbox.go:634-648`) but `ensureOffboxRepo`'s probe fails first, `classifyResticProbe` (`:77-93`) has no lock case → `"other"` → fail-fast; `ClassifyOffsiteFailure` likewise, so the operator is told *„A távoli mentés ismeretlen okból nem sikerült"* for a precisely-known, self-healable condition | S | READY — 2026-07-28 | **Was C9-F3.** Reachable by any interruption — container restart, OOM, network drop, host reboot mid-backup. The tier stays dead until a human runs `restic unlock --remove-all`. Flips: the offsite row in map §C; `07` §8 row 15 |
| R-105 | **Three hub-held DR records are empty on the entire live fleet.** `hosts.dr_record_json` = `{}` on all 3 hosts; `host_escrow.directive_json` = `{}` on both escrowed hosts; `dr_recipe.host_half.drives` = `[]` on every customer **including two with enrolled data drives** (916 GB USB on demo-felhom, 938 GB NVMe on demo-hp) | M | READY — 2026-07-28 | These are exactly the fields a host-loss recovery reads: `05-hub-architecture.md:175-176,186` names the slim DR record as one of four durable sources; `06-offsite-connectivity.md:148-150` says the escrow upload carried the DR directive; `felhom-agent/internal/dr/plan.go:34-35` makes `PlannedDrive` the re-attach-by-`durable_id` wrong-disk guard. **The three may have different causes**`isUserDataDrive` (`internal/hub/dr_recipe.go:129-136`) requires type `usb`/`local-dir` **and** a non-empty `DurableID` **and** `MountPath`, and which of the three fails was not traced. Evidence: `architecture/_recovery-inventory-2026-07-28.md` Part D2.3. Flips: `07` §4 |
| R-105 | **Three hub-held DR records are empty on the entire live fleet.** `hosts.dr_record_json` = `{}` on all 3 hosts; `host_escrow.directive_json` = `{}` on both escrowed hosts; `dr_recipe.host_half.drives` = `[]` on every customer **including two with enrolled data drives** (916 GB USB on demo-felhom, 938 GB NVMe on demo-hp) | M | READY — 2026-07-28 | These are exactly the fields a host-loss recovery reads: `05-hub-architecture.md:175-176,186` names the slim DR record as one of four durable sources; `06-offsite-connectivity.md:148-150` says the escrow upload carried the DR directive; `felhom-agent/internal/dr/plan.go:34-35` makes `PlannedDrive` the re-attach-by-`durable_id` wrong-disk guard. **The three may have different causes**`isUserDataDrive` (`internal/hub/dr_recipe.go:129-136`) requires type `usb`/`local-dir` **and** a non-empty `DurableID` **and** `MountPath`, and which of the three fails was not traced. Evidence: `architecture/_recovery-inventory-2026-07-28.md` Part D2.3. **UPDATE 2026-07-28 (vzdump-target move): the `drives` third is TRACED and now POPULATED on both demo boxes.** Cause: the enrolled data drives were never PVE storages at all — only agent-generated systemd mounts — so they never entered `report.StorageTargets` and `isUserDataDrive` never saw them. Giving each drive a `dir` storage at its own mountpoint supplied all three required fields at once (type `local-dir`, fs-UUID durable id, mount path), and the recipe now emits `uuid:91d2dc2d-…`/`/mnt/nvme-1tb` on demo-hp and `uuid:47a3361a-…`/`/mnt/hdd_1` on demo-felhom. **The other two fields (`hosts.dr_record_json`, `host_escrow.directive_json`) are untouched and still `{}`** — this narrows R-105, it does not close it. See `runbooks/RUNBOOK-vzdump-target-move-2026-07-29.md` §5.4. Flips: `07` §4 |
| R-106 | **The DR recipe records the PBS namespace as `"root"` on every box** while the real namespaces are `demo-felhom` / `demo-hp` | XS | READY — 2026-07-28 | Traced to source: `Snapshot.Namespace` decodes a `ns` field (`felhom-agent/internal/pbs/client.go:97`) that PBS does not echo per item when the request is already namespace-scoped via `?ns=` (`:117-120`), so it is empty and `ToHub` normalises empty → `"root"` (`internal/pbs/report.go:23-27`), which `latestPBSCoord` writes into the recipe (`internal/hub/dr_recipe.go:149`). `latest_snapshot_id: "9201"` is **not** a defect — it is `BackupID`, documented as a coordinate (`:56`). Verified against ep0's real namespaces and a live `pct restore` command line carrying `--ns demo-felhom`. Flips: `07` §4 |
| R-107 | **No offsite action unpacks the named-volume tars Tier-3 captures on every run.** `ReconstituteFromOffsite` skips the unit outright (`internal/backup/offbox_reconstitute.go:284-289`); `PlaceOffsiteRestore` places it only when the live unit is ABSENT (`internal/backup/offbox_restore.go:352-356`). The DB **is** replayed, from the scratch unit's `db-dumps/` | M | READY — 2026-07-28 | 52 of 53 catalog templates keep data in Docker named volumes, so for most apps the offsite tier carries the volume data and cannot itself put it back. A two-step route exists (full restore → place → Tier-1 restore, which does unpack tars) but **no single action does it and no UI routes it** — the R-103 shape one tier over. Sibling of R-102: both are "captured and never read". Flips: the offsite-restore row in map §C; `07` §6.3, §7.2 |
| R-108 | **Network storage can host an app's namespace, and FileBrowser binds a network share at its ROOT — this BLOCKS D5.** For local drives FileBrowser binds `<drive>/userdata` only (`internal/web/handlers.go:2450-2460`); for network paths it binds the share **root** with `:rslave` (`:2432`) and serves it with `download: true` (`internal/infra/infra.go:326`). Nothing stops an app's namespace landing there: `GetSchedulableStoragePaths()` has no `IsNetwork()` filter (`internal/settings/settings.go:904-914`, feeds the deploy dropdown at `handlers.go:462-473`), the per-app migrate target list has none (`handlers.go:674-679`), and `handleStorageMigrateApp` does not call `refuseNetworkLifecycle` though its whole-namespace sibling does (`internal/web/storage_handlers.go:397` vs `:410-424`) | M | **READY — blocks an architectural target** — 2026-07-28 | **Today this is not a secret leak** — the recovery unit's `app.yaml` is secret-stripped (`internal/backup/recovery_unit.go:73`). It becomes one under **D5**, which would put app secrets in the local unit precisely so Tier-1/Tier-2 restore stop needing the guest. Verified LIVE in demo-hp's generated compose, where the asymmetry is visible rather than inferred: `- /mnt/felhom-drives/nvme-1tb/userdata:/srv/nvme-1tb` beside `- /mnt/felhom-drives/Felhom-Share:/srv/Felhom-Share:rslave`. **Second effect, independent of D5:** `.fab` bundles already carry **plaintext** secrets by design with an OPTIONAL password (`internal/appexport/export.go:484,506-511,307`) and `storageDriveList()` does not filter network paths (`internal/web/handler_export.go:377-387`), so one can be exported onto a NAS today. Full sweep of every read surface: `07-backup-architecture.md` §10.1. Flips: `07` §7.3 (D5 target → adopted) once closed |
| R-109 | **The DR recipe records no backup target.** `BuildDRRecipeHostHalf` (`felhom-agent/internal/hub/dr_recipe.go:86`) emits `guests`, `drives`, `pve_storage` and the PBS coordinate, but nothing that says WHICH storage holds the local whole-guest archives. Harmless while the target was the well-known `local`; not harmless now — after the 2026-07-28 vzdump-target move each demo box carries **two** `content=backup` dir storages, `felhom-backup` (live) and `local` (frozen 2026-07-28 archives, never refreshed again) | XS | READY — 2026-07-28 | Found by `runbooks/RUNBOOK-vzdump-target-move-2026-07-29.md` Phase 0 §5 and confirmed live in the recipe both boxes emit. A restorer reading the recipe cannot distinguish the live target from the stale one, and picking wrong yields a silently outdated guest. Add the resolved `BackupTarget()` to the host-half. Sits with **R-105**/**R-106** as the third recipe-completeness defect. Flips: `07` §4 |
| E-2 | **Drive-role machinery around the moved vzdump target.** The 2026-07-28 runbook proved the architecture change by hand on both demo boxes; this is the machinery: a **backup-target role** on `StoragePath` beside `Schedulable`/`IsDefault`/`Kind`; **assignment in the storage wizard** (suggest by attribute, refuse the absurd, never decide by transport or `removable` — on the reference hardware demo-felhom's target IS a USB HDD and BOTH drives report `removable=0`); **unassigned drives do nothing automatically**; **stickiness** (never silently retarget); `felhom-host-install.sh` creating the target with `--is_mountpoint 1` **and** issuing the `FelhomAgentStore` ACL; **absent-target policy**; **retention/space accounting** on a drive the customer shares; the honest **single-drive label**; remaining fleet migration | M | READY — 2026-07-28 | Full scope + rationale in `runbooks/RUNBOOK-vzdump-target-move-2026-07-29.md` §7. Two traps already paid for live: the storage `path` must BE the mountpoint or the agent reports the target `disconnected` forever (`internal/storage/observe.go:321`), and the per-storage `FelhomAgentStore` grant is mandatory or every backup 403s. Absent-drive behaviour today is **fail-loudly, no silent retarget** (`is_mountpoint 1` proven live) — which is NOT the intended fall-back-and-alarm design. Flips: matrix row 4 |
## P3 — post-alpha
@@ -0,0 +1,563 @@
# RUNBOOK RECORD — the local whole-guest backup moved off the guest's own device (2026-07-28)
**Class:** supervised operational change, one box at a time. **No code, no version bump** — the
change is a PVE storage definition, a per-storage ACL grant, and a one-line target switch in
`agent.json`. **Nothing was deleted.**
**Boxes:** `demo-hp` (`felhom-host`, HP t740) first, then `demo-felhom` (N100).
**Change:** primary backup tier `local`**`felhom-backup`**, a `dir` storage on each box's
secondary drive.
**Window:** 2026-07-28 21:11 → 21:35 CEST.
> **Deliverable note.** The spec is `RUNBOOK-vzdump-target-move-2026-07-29.md`; the work ran on the
> evening of 07-28. The filename is kept as specified.
---
## 1. Phase 0 — ground truth, established read-only before anything was touched
### 1.1 The premise holds: `local` and `local-lvm` are the same physical device
| | demo-felhom | demo-hp |
|---|---|---|
| `local` (dir, `/var/lib/vz`) | `pve-root`**`sda3`** | `pve-root`**`sda3`** |
| `local-lvm` (lvmthin, `pve/data`) | **`sda3`** | **`sda3`** |
| System drive | AirDisk 512 GB SATA SSD (`sda`) | SanDisk X600 M.2 128 GB (`sda`) |
A drive failure took the guest and its only local backup together. That is the fault being closed.
### 1.2 Physical inventory and the durable id
| | demo-felhom | demo-hp |
|---|---|---|
| Secondary drive | `/dev/sdb` TOSHIBA MQ04ABF100, **USB**, rotational | `/dev/nvme0n1` KXG50PNV1T02, **NVMe** |
| Size / free before | 916 GB / 866 GB | 938 GB / 891 GB |
| Mountpoint | `/mnt/hdd_1` | `/mnt/nvme-1tb` |
| **Durable id (fs UUID)** | `47a3361a-91e0-4831-a69d-27f540ed3f48` | `91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae` |
| `/sys/block/*/removable` | `0` | `0` |
| SMART at close | — | `PASSED`, 50 °C, 7 % wear, 0 media errors |
Both drives are already enrolled Felhom drives, mounted **by filesystem UUID** through
agent-generated systemd units (`mnt-hdd_1.mount`, `mnt-nvme\x2d1tb.mount`), each marked
*"Managed by felhom-agent — do not edit by hand"*, `WantedBy=multi-user.target`. **Neither is in
`/etc/fstab`.** The UUID in the unit is the same durable id the DR recipe carries, so the target
drive was already durably identified before this change.
This is §2's principle meeting the reference hardware head-on: demo-felhom's target **is** an
external USB HDD and both drives report `removable=0`. A rule keyed on transport would disqualify
demo-felhom; a rule keyed on the `removable` flag would see no removable drive at all on either box.
Neither attribute can decide a role. Both are fine as *hints*.
### 1.3 Space
The runbook's ~14.5 GB figure is the **PBS-side** measurement. The local `zstd` archive is far
smaller:
| | newest local archive before | × `keep-last=3` | share of the target drive's free space |
|---|---|---|---|
| demo-felhom (9201) | 5.93 GB (trend 5.82 → 5.84 → 5.93) | **~17.8 GB** | **2.1 %** of 866 GB |
| demo-hp (9201) | 1.68 GB (1.61 → 1.63 → 1.68) | **~5.0 GB** | **0.6 %** of 891 GB |
Retention is `local_backup_retention: 3` on both boxes and is unchanged by this runbook.
The move also relieves the system drive — 18 GB on demo-felhom, 5.2 GB on demo-hp once the old
archives are eventually removed. On demo-hp that matters: its root is 40 GB with 24 GB free.
### 1.4 How the target is set — **configuration, not converged. The runbook holds.**
The primary tier is built from `backup.local_backup_target` (`felhom-agent/internal/config/config.go:541`,
`BackupTarget()`), which was `"local"` on both boxes. The extra tier is `felhom-pbs`, weekly.
Every writer of `agent.json` was enumerated. **There is exactly one:**
`pbsdr.seedEscrowStorageID` (`internal/pbsdr/manager.go:454`). It sets only `escrow.pbs_storage_id`,
refuses to clobber a differing existing value (warn-and-keep), and performs a
`map[string]json.RawMessage` read-modify-write that **preserves unknown keys verbatim**. Nothing on
the hub pushes agent configuration. Seven prior hand-edit backups sit beside the live file.
**A hand edit sticks. No code change was required, so the STOP in §3 did not fire.**
### 1.5 What else reads the target — no hardcoded `"local"` on the backup path
| Consumer | Verdict |
|---|---|
| Backup runners | One runner **per tier**, each constructed with its own `TargetID` (`cmd/felhom-agent/main.go:1355`). Parameterised. |
| `NewestArchiveTime` | Reads `r.target` (`internal/backup/runner.go:390`). Parameterised. |
| Restore-test rotation | Iterates `BackupTiers()`; `restoreTierForArchive` classifies from the **archive**, never from config (the R-82/R-85 fix). Parameterised. |
| Per-run prune | `localPruneSpec` suppresses only for **PBS**-type targets; a `dir` target still prunes at `keep-last=3`. Correct. |
| Customer-facing label | `backupIsPBS` (`felhom-controller/.../backup_handlers.go:174`) matches the substring `pbs`. `felhom-backup` correctly renders „Helyi tároló (felhom-backup)". **Naming trap recorded** — a target named with `pbs` in it would be mislabelled as offsite. |
Two Phase 0 findings that shaped the change are in §4 and §6.
---
## 2. The two findings Phase 0 turned up before any command ran
### F-1 — the storage path must BE the mountpoint, or the target reports disconnected forever
`reportType` (`internal/storage/observe.go:365`) calls a `dir` storage `local` only when it is
literally named `local`; anything else becomes `usb` or `local-dir`. For those two types
`reachable()` (`:321`) requires **`exactMount`** — the storage `path` must exactly equal a
mountpoint (`exactMountDevice`, `:422`).
Point the storage at a **subdirectory** of the drive and `exactMount` is false, so
`reachable=false`, `state=disconnected`, **permanently** — a false alarm designed in on day one,
and the durable id degrades to a path fallback instead of the filesystem UUID.
**Decision: `path` = the drive's mountpoint.** PVE creates `dump/` at the drive root, beside the
existing `felhom-data/`. This also gives a real safety property: the guest is bind-mounted only the
drive's `felhom-data` subpath (`/mnt/felhom-drives/<drive>`), so **the archives sit outside anything
the customer's guest can reach.**
### F-2 — `is_mountpoint` is what stops a silent retarget onto the system drive
`man pvesm`: *"Assume the given path is an externally managed mountpoint and consider the storage
offline if it is not mounted."* Without it, an unplugged or late-mounting drive leaves
`/mnt/<drive>` a bare directory on the root filesystem and vzdump writes the backup **onto the
system drive** — the exact device this change exists to escape, silently. Proven live in §6.
**Decision: `--is_mountpoint 1` on both boxes.**
---
## 3. The exact commands used, per box
Identical on both, differing only in the mountpoint. Run as `root` on the PVE host.
```bash
# 1. the storage, on the drive's OWN mountpoint, guarded (F-1 + F-2)
pvesm add dir felhom-backup --path /mnt/nvme-1tb --content backup --is_mountpoint 1 # demo-hp
pvesm add dir felhom-backup --path /mnt/hdd_1 --content backup --is_mountpoint 1 # demo-felhom
# 2. the per-storage grant — REQUIRED, and the reason the first backup failed (§4)
pveum acl modify /storage/felhom-backup --roles FelhomAgentStore --users felhom-agent@pve
pveum acl modify /storage/felhom-backup --roles FelhomAgentStore --tokens 'felhom-agent@pve!agent'
# 3. back up the config, then repoint the primary tier
cd /etc/felhom-agent
cp -p agent.json agent.json.pre-e-target-move
# in-place truncate-write (NOT sed -i): the directory is root-owned while the file is
# agent-owned 0600, so a rename would flip ownership and the non-root agent could not read it.
python3 - <<'EOF'
import json
p='/etc/felhom-agent/agent.json'
raw=open(p).read()
old='"local_backup_target": "local"'
new='"local_backup_target": "felhom-backup"'
assert raw.count(old)==1, 'occurrences: %d' % raw.count(old)
out=raw.replace(old,new); json.loads(out) # validate BEFORE writing
with open(p,'r+') as f: f.seek(0); f.write(out); f.truncate()
EOF
# 4. restart only with nothing in flight — check the PVE task list, NOT pgrep (see §8)
pvesh get /nodes/<node>/tasks --limit 20 --output-format json | python3 -c \
'import sys,json;print([t for t in json.load(sys.stdin) if "endtime" not in t] or "none running")'
systemctl restart felhom-agent
```
Resulting stanza, both boxes:
```
dir: felhom-backup
path /mnt/<drive>
content backup
is_mountpoint 1
```
**Verification that Part 1 took**, both boxes — the diff was exactly one line, ownership and mode
preserved (`-rw------- felhom-agent felhom-agent`), and both tiers armed with no rejection:
```
backup tier armed target=felhom-backup cadence=24h0m0s keep_last=3 wait_timeout=30m0s primary=true
backup tier armed target=felhom-pbs cadence=168h0m0s keep_last=0 wait_timeout=12h0m0s primary=false
```
`GET /backup/due` echoes the new tier, and the **old target no longer exists** — the cleanest proof
the switch took:
```
GET /backup/due → {"due":true,"reason":"no successful backup recorded yet","age_state":"absent"}
GET /backup/due?target=felhom-backup → {..., "target":"felhom-backup", "age_state":"absent"}
GET /backup/due?target=felhom-pbs → {"due":false,"reason":"within cadence window","age_seconds":44362}
GET /backup/due?target=local → {"ok":false,"error":"unknown backup target: local"}
```
---
## 4. Finding F-3 — the agent had no PVE privilege on the new storage (found by the first real backup)
The first triggered backup on demo-hp failed:
```
local-api: backup job failed vmid=9201 target=felhom-backup
err="proxmox: POST /nodes/felhom-host/vzdump -> HTTP 403: permission denied
at /storage/felhom-backup (missing privilege Datastore.Allocate)"
```
`FelhomAgentStore` (`Datastore.Allocate,Datastore.AllocateSpace`) is granted **per storage path**,
to both `felhom-agent@pve` and the `felhom-agent@pve!agent` token — `local`, `local-lvm` and
`felhom-pbs` each had their own pair. A new backup target needs the same pair or every backup 403s.
The grant in §3 step 2 mirrors the existing ones exactly; **no privilege was widened**, and the
storage-scoped shape (rather than a grant at `/`) is preserved.
**This is an E-2 requirement, not a one-off:** `felhom-host-install.sh` must issue the grant for
whatever target it provisions on a new install, or every new box ships with a backup tier that
403s on its first run.
---
## 5. Part 2 — the proof
### 5.1 A backup lands on the secondary drive — via the real path
Triggered through the endpoint the UI's „Mentés most" button invokes, not `--selftest`.
**Route correction worth recording:** the backups page has two triggers.
`POST /api/backup/run` is the **Tier-1 app-data** backup (DB dumps + volume dumps); the
**whole-guest** vzdump is `POST /api/guest-backup/trigger`, which goes through the controller's
quiesce loop (`ServeBackupAPI`, `backup_handlers.go`). The first attempt used the former and
correctly produced an app-data run and no vzdump.
| | demo-hp | demo-felhom |
|---|---|---|
| Archive | `felhom-backup:backup/vzdump-lxc-9201-2026_07_28-21_18_15.tar.zst` | `felhom-backup:backup/vzdump-lxc-9201-2026_07_28-21_26_28.tar.zst` |
| Size | **2,256,044,492 B (2.26 GB)** | **5,957,878,962 B (5.96 GB)** |
| Duration | 86.3 s | ~197 s |
| Mode | `snapshot``snapshotted` at 21:18:16, app resumed before the copy finished | `snapshot``snapshotted` at 21:26:29 |
| `df` on the target drive | used 92,344,320 → **2,348,572,672** (**+2,256,228,352 B**) | used 3,600,314,368 → **9,558,208,512** (**+5,957,894,144 B**) |
On both boxes the `df` delta matches the archive size, so the bytes demonstrably landed on the
secondary drive and not somewhere else.
On demo-felhom the backup was taken by the controller's **first-backup safety valve** rather than by
the manual trigger: the agent restart made the new tier report `age_state: absent`, the controller
correctly refused to withhold a first backup, and the manual trigger that followed was answered
`{"error":"mentés már folyamatban van"}`. Same code path, same quiesce loop — the trigger simply
lost the race to the mechanism that exists for exactly this state.
`uncovered_volumes: ["/etc/felhom-bootstrap", "/mnt/felhom-drives"]` — both are host binds and are
correctly outside the guest archive. Pre-existing behaviour, unchanged by this move, but see §7.
### 5.2 A restore works from that archive
Run with **no** `-archive`, so the candidate *selection* is part of the proof. The rotation needed
no nudge — it picked the new storage on its own.
```
restoring felhom-backup:backup/vzdump-lxc-9201-2026_07_28-21_18_15.tar.zst
into scratch band [990000,990009] on local-lvm …
{
"source_archive": "felhom-backup:backup/vzdump-lxc-9201-2026_07_28-21_18_15.tar.zst",
"source_tier": "local",
"scratch_vmid": 990000,
"pass": true,
"verified": "boot+running",
"mount_parity": "ok",
"duration_seconds": 114.39,
"mount_inventory": [
"mp0=/var/lib/docker (50G)", "mp1=/mnt/sys_drive (20G)",
"mp8=/mnt/felhom-drives (throwaway for the archived bind)",
"mp9=/etc/felhom-bootstrap (throwaway for the archived bind)"
]
}
=== selftest=restore-test OK (scratch 990000 restored+booted+verified+torn-down in 1m54s) ===
```
**`mount_parity: ok`, `pass: true`, scratch torn down cleanly.**
demo-felhom, same method, same result — the bigger guest (200 G + 50 G volumes):
```
restoring felhom-backup:backup/vzdump-lxc-9201-2026_07_28-21_26_28.tar.zst …
"pass": true, "verified": "boot+running", "mount_parity": "ok",
"duration_seconds": 84.10, "scratch_vmid": 990000,
"mount_inventory": ["mp0=/var/lib/docker (200G)", "mp1=/mnt/sys_drive (50G)", …]
=== selftest=restore-test OK (restored+booted+verified+torn-down in 1m24s) ===
```
| | demo-hp | demo-felhom |
|---|---|---|
| Candidate picked automatically from the new storage | yes | yes |
| `pass` / `verified` | true / `boot+running` | true / `boot+running` |
| **`mount_parity`** | **ok** | **ok** |
| Duration | 1 m 54 s | 1 m 24 s |
| Scratch torn down | yes (990000) | yes (990000) |
Run with **no** `-archive` on both boxes, so the candidate *selection* is part of the proof. The
rotation needed no nudge on either box — it picked the new storage on its own.
`source_tier: "local"` is **correct, not a stale reference to the old storage name.**
`restoreTierForArchive` classifies by the archive's storage *type*; `felhom-backup` is a `dir`
storage, so the restore correctly earns the local (10-minute) wait bound rather than the PBS one.
**Method caveat:** `--selftest=restore-test` is a separate one-shot process, so it does **not**
write the daemon's `rtState`. `GET /restore-test/status` still reads `null` on both boxes and the
customer-visible „utoljára ellenőrizve" is unchanged. The proof is real; the daemon's own 84 h
rotation is undisturbed and will record its next run normally.
### 5.3 Freshness follows the target
Immediately after the backup: `{"due":false,"age_seconds":159,"target":"felhom-backup","age_state":"known"}`.
That alone does not separate the storage read from this process's in-memory record, so the agent was
**restarted** — which empties the in-memory store (R-84) — and re-asked. Both boxes:
```
demo-hp GET /backup/status → {"phase":"idle"} ← no in-memory record at all
GET /backup/due → {"due":false,"age_seconds":188,
"target":"felhom-backup","age_state":"known"}
demo-felhom GET /backup/status → {"phase":"idle"}
GET /backup/due → {"due":false,"age_seconds":267,
"target":"felhom-backup","age_state":"known"}
```
A `known` age with an empty in-memory store **can only** have come from reading the new storage.
The tier neither looks absent (which would re-backup every cycle) nor stale. This is the positive
observable, not the absence of a complaint.
**This restart is also where the one operational error of the run happened — see §8.**
### 5.4 The DR recipe does **not** record the new target — and one field improved
**The finding:** `BuildDRRecipeHostHalf` (`internal/hub/dr_recipe.go:86`) records `guests`,
`drives`, `pve_storage` and the PBS coordinate. **Nothing in the recipe says which storage holds the
local archives.** While the target was `local` that was guessable; it is not any more, and it is now
actively misleading — after this change `local` still holds the *stale* archives while the live ones
are on `felhom-backup`. A restorer reading the recipe sees two backup-capable storages and cannot
tell which is which. **Filed as R-109**, alongside R-105/R-106.
**The improvement — this change traces and partly closes R-105.** R-105 recorded
`dr_recipe.host_half.drives = []` on every customer *including two with enrolled data drives*, with
the cause explicitly untraced. It is now traced: **the enrolled drives were never PVE storages at
all**, so they never entered `report.StorageTargets`, so `isUserDataDrive` (which needs type
`usb`/`local-dir` **and** a durable id **and** a mount path) never saw them. Making the drive a
storage populated it. Live on demo-hp after the change:
```json
"drives": [{ "durable_id": "uuid:91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae",
"mount_path": "/mnt/nvme-1tb", "intent": "enrolled",
"total_bytes": 1006980812800 }]
```
The storage observation also gained a real device, a real UUID and **SMART** for the backup drive —
`health=PASSED temp=50 poh=28451 media_err=0 pct_used=7` — none of which existed when the drive was
not a PVE storage:
```
- felhom-backup type=local-dir state=attached reach=true class=fast
durable=uuid:91d2dc2d-… mount="/mnt/nvme-1tb" dev="/dev/nvme0n1"
```
demo-felhom shows the identical shape — `drives` populated, SMART now readable on the USB HDD:
```
- felhom-backup type=local-dir state=attached reach=true class=slow
durable=uuid:47a3361a-… mount="/mnt/hdd_1" dev="/dev/sdb"
smart: health=PASSED temp=35 poh=3581 realloc=0 pending=0 offline_unc=0
"drives": [{ "durable_id": "uuid:47a3361a-91e0-4831-a69d-27f540ed3f48",
"mount_path": "/mnt/hdd_1", "intent": "enrolled",
"total_bytes": 983351140352 }]
```
**`dr_recipe.host_half.drives` is now non-empty on both demo boxes**, where R-105 recorded `[]`
fleet-wide. Note the classification detail: demo-felhom's drive is USB-attached but reports
`removable=0`, so it is typed `local-dir` rather than `usb`. That does not matter here — both types
take the same reachability branch and both satisfy `isUserDataDrive` — but it is one more reason
§2's "do not classify by transport" rule is right.
R-106 is untouched and still live on both boxes: the recipe still records `"namespace": "root"`
where the real namespaces are `demo-hp` / `demo-felhom`.
---
## 6. Part 3 — what happens when the target drive is absent
Established with **throwaway storages**, so neither live drive was unmounted (unmounting would break
the guest's `felhom-data` bind and the customer's app data on a remote box).
### Live-proven: the PVE half
| | storage on a non-mount path, **no** `is_mountpoint` | same path, **`is_mountpoint 1`** |
|---|---|---|
| Creation | succeeded silently | **refused**: `unable to activate storage 'absent-test-b' - directory is expected to be a mount point but is not mounted: '/mnt/absent-b'` |
| `pvesm status` | **`active`**, 40516856 KiB total | never created |
| Backing device | **`/dev/mapper/pve-root`** — the system drive | — |
| What it created | `/mnt/absent-a/dump/` **on the root filesystem** | nothing |
The unguarded storage reports `active` with plausible free space that is really the **root
filesystem's**, and it had already created a `dump/` directory there. A vzdump aimed at it would
write the whole-guest backup onto the system drive with no error and no alarm — **silent
retargeting onto the exact device this change exists to escape**, which §6 names as the worst
outcome. `is_mountpoint 1` converts that into a loud refusal. Both test storages were removed and
`/mnt/absent-a` deleted.
### Source-traced, not live-proven: the agent half
With the guard in place and the drive absent, the storage stays in `storage.cfg` but goes inactive.
`targetStoragePresent` (`internal/localapi/server.go:1212`) checks the target's **name presence
only**, never `Reachable`, so the tier is **not** deferred; `newestArchiveOn` cannot read the
storage and degrades to `archiveUnknown`, which stays **DUE** by design. The controller therefore
quiesces the apps and fires a vzdump, PVE refuses because the storage is not active, and the run
**fails loudly** — arming the R-88 breaker and the `backup_failed` operator mail.
So today's behaviour is: **fail and alarm; no silent retarget.** That is the safe half.
**It does not match §6's intended design** (*fall back to the system drive and alarm*): there is no
fallback at all, so an absent drive means **no local backup whatsoever** until a human intervenes,
and each attempt costs an app quiesce until the breaker backs off. Whether a corruption-only copy on
the system drive beats no copy is a product decision; the honest label matters either way.
**Filed as E-2.**
This half is traced through source, not reproduced on hardware — the boxes are remote and unmounting
a live enrolled drive would break the guest bind and the customer's data. Recorded as outstanding
proof in §9.
### Single-drive boxes
A single-drive box has nowhere to move the target. There the backup stays on the system drive and
protects against **corruption only** — it is not drive-loss protection and must not be presented as
a backup tier that survives hardware failure. **Two drives is effectively a hardware requirement for
drive-loss protection**, and that belongs in the sales/onboarding material as well as in the UI
label. Filed under E-2.
---
## 7. Scope recorded for E-2 — filed, not built
| # | Item |
|---|---|
| 1 | A **backup-target role** on `StoragePath`, alongside `Schedulable` / `IsDefault` / `Kind`. |
| 2 | **Assignment in the storage wizard** — suggestion by attribute, refusal of the absurd (a 32 GB FAT thumb drive), never a decision by transport or `removable` (§1.2 shows both fail on the reference hardware). |
| 3 | **Unassigned drives do nothing automatically** — §2's rule, enforced in code. A drive must never acquire a role by appearing. |
| 4 | **Stickiness** — an assigned target must not move because a new drive appeared, and must never silently retarget when absent. |
| 5 | **New installs**: `felhom-host-install.sh` must create the target storage with `--is_mountpoint 1` **and issue the `FelhomAgentStore` grant** (§4), or a new box's first backup 403s. |
| 6 | **Absent-target policy** per §6: decide fallback-vs-fail, and if fallback, alarm that protection is degraded rather than reporting a healthy tier. |
| 7 | **Retention and space accounting** on a drive the customer also uses — today `keep-last=3` competes with customer data with no reservation and no ceiling. |
| 8 | The honest **single-drive label**. |
| 9 | **Migrating the remaining fleet**`peti-felhom` and any box not covered here. |
| 10 | Naming guard: `backupIsPBS` matches the substring `pbs`, so a target so named would be mislabelled to the customer as offsite (§1.5). |
---
## 8. Observations
### 8.1 An operational error in this run: the agent was restarted with a backup in flight
§4.3 and §8 of the spec both say not to. It happened anyway, on demo-hp at **21:21:12**.
The manual „Mentés most" correctly fires **every** tier, not just the primary, so the trigger at
21:18 started the local vzdump *and* a `felhom-pbs` one. The local tier finished at 21:19:41. The
PBS tier was still running. The in-flight check had been done before the **first** restart (21:12,
clean) and **was not repeated** before the second one — the restart taken for the §5.3 freshness
proof — so it landed on top of a live PBS backup.
What it produced:
```
level=ERROR msg="local-api: backup job failed" vmid=9201 target=felhom-pbs
err="backup: vzdump task vmid 9201: proxmox: waiting for task UPID:…:vzdump:9201:… : context canceled"
```
**The backup had not failed.** `context canceled` is the agent being shut down while waiting, not
the task failing. Checked rather than assumed, against two independent authorities:
- the PVE task `2026-07-28T21:19:45 … vzdump 9201`**OK**;
- the PBS snapshot `felhom-pbs:backup/ct/9201/2026-07-28T19:19:45Z`**6,264,034,053 B**, a real,
plausibly-complete archive (nine orders of magnitude above the F-CRIT-2 1 MiB floor).
So the run recorded a **spurious tier failure** — a self-inflicted instance of exactly the F-A1
class ("a healthy backup reported as FAILED") that the project fixed in controller v0.179.0.
**It reached no channel, and the reason is worth keeping.** The controller's event trail for the
whole window is:
```
19:15:43Z whole_guest_backup_failed (error) felhom-backup tier — the REAL 403, backoff 15m
19:19:45Z whole_guest_backup_recovered (info) felhom-backup tier — cleared after the ACL grant
```
— and **nothing for the 21:21 PBS failure.** The agent process died with its own in-memory failure
record, and by the time the controller next polled, `NewestArchiveTime` had read ground truth from
PBS and found the successful 21:19:45 archive. The R-84 ground-truth design absorbed it: no breaker
armed on the offsite tier, no operator mail, no spurious staleness. That is the design working, not
a reason the mistake was harmless — on a slower tier, or with the restart a minute earlier, the same
slip could have aborted a multi-hour WAN upload.
**The rule that would have caught it:** re-check in flight immediately before *every* restart, not
once at the start of the procedure — and remember that a manual trigger fires all tiers, so the
offsite tier can still be running long after the local one has finished.
### 8.2 Method and tooling
- **`pgrep -f vzdump` is not a safe in-flight check.** A polling script whose own command line
contains the string matches itself, and it reported a backup in flight when the PVE task list
showed none. It caused one false "ABORT: vzdump in flight" and one wrong reading in this run.
**Use the PVE task list** (`pvesh get /nodes/<node>/tasks`, filter for a missing `endtime`).
- **`sed -i` must not be used on `agent.json`.** The directory is root-owned and the file is
agent-owned `0600`; `sed -i` renames, which would flip the file to `root:root` and leave the
non-root agent unable to read its own config. The in-place truncate-write in §3 preserves both.
- A `felhom-pbs` vzdump failed at **17:58**, hours before this change, with
`Can't connect to 10.77.0.1:8007 (Connection refused)`. **Pre-existing and unrelated** — checked
rather than assumed: ep0 is up (uptime 1 d 2 h, `proxmox-backup-proxy` active), the WG handshake
is fresh, and 8007 is open from demo-felhom now.
- The controller's first-backup safety valve behaved exactly as designed on demo-felhom: the agent
restart made the new tier report `age_state: absent`, and the controller took a backup on its own
before the manual trigger could (`{"error":"mentés már folyamatban van"}`).
- The archives are outside the customer's reach by construction — the guest is bound only the
drive's `felhom-data` subpath, while `dump/` sits at the drive root.
---
## 9. State at close
### 9.1 Old archives — left in place, as instructed
Nothing was deleted. They remain the rollback and the only evidence of what the previous
configuration produced.
| Box | Path | Contents | Size |
|---|---|---|---|
| demo-felhom | `/var/lib/vz/dump/` | 3 × 9201 (5.82 / 5.84 / 5.93 GB) + 2 × 9100 (612 / 649 MB) | **18 GB** |
| demo-hp | `/var/lib/vz/dump/` | 3 × 9201 (1.61 / 1.63 / 1.68 GB) + 1 × 9100 (649 MB) | **5.2 GB** |
**These are now stale and will never be refreshed** — the tier that wrote them no longer points
here, and `keep-last=3` prunes only the *new* target. They are also the reason R-109 matters: a
restorer reading the DR recipe sees both `local` and `felhom-backup` carrying `content=backup` and
cannot tell that one holds live archives and the other holds a frozen snapshot of 2026-07-28.
Deleting them is a separate, deliberate decision.
### 9.2 Fleet state — both boxes healthy, no thrash, no spurious staleness
| | demo-felhom | demo-hp |
|---|---|---|
| Agent | `active`, v0.110.0 | `active`, v0.110.0 |
| Primary tier | `felhom-backup`, `due=false`, `age_state=known` | `felhom-backup`, `due=false`, `age_state=known` |
| Offsite tier | `felhom-pbs`, `due=false`, `age_state=known` | `felhom-pbs`, `due=false`, `age_state=known` |
| New target | 5.96 GB used of 916 GB (**2 %**) | 2.26 GB used of 938 GB (**1 %**) |
| Target drive SMART | `PASSED`, 35 °C | `PASSED`, 50 °C, 7 % wear |
| System drive | 24 G / 94 G (27 %) | 14 G / 39 G (38 %) |
| Agent errors, last 15 min | **0** | 2, both accounted for (§4 403, §8.1 spurious) |
| Breaker | clear (`whole_guest_backup_recovered` 19:19:45Z) | clear |
No tier is stale, no tier is thrashing, and both offsite tiers report a known, recent age.
### 9.3 Outstanding proof
1. **Full drive-loss recovery** — pull the system drive, restore the guest from the vzdump on the
secondary drive, confirm it returns with named volumes, secrets and config. Needs physical
access; the boxes are remote until ~08-02. This is the proof that flips **matrix row 4** from
PARTIAL to a real local route. Everything up to it is now proven: the archive exists on separate
hardware, and a restore from it boots and passes `mount_parity`.
2. **The agent half of §6** — with the drive absent, that the tier fails loudly rather than silently
retargeting. The **PVE half is live-proven** (§6); the agent half is traced through source only,
because unmounting a live enrolled drive would break the guest's `felhom-data` bind and the
customer's app data on a remote box.
### 9.4 Backlog rows filed
| ID | What |
|---|---|
| **E-2** | The machinery around this change — backup-target role on `StoragePath`, wizard assignment, no automatic roles, stickiness, `felhom-host-install.sh` creating the target **and** its ACL, absent-target policy, retention/space accounting on a shared drive, the honest single-drive label, remaining fleet migration. Full scope in §7. |
| **R-109** | **The DR recipe records no backup target.** `BuildDRRecipeHostHalf` lists every storage's name/type/content but never says which one holds the local archives — and after this change the box carries two `content=backup` dir storages, one live and one stale. §5.4. |
| **R-105** | Partially closed and **fully traced** for its third field: `dr_recipe.host_half.drives` is now populated on both demo boxes, and the cause of the fleet-wide `[]` is identified — the enrolled drives were never PVE storages, so `isUserDataDrive` never saw them. The other two fields (`hosts.dr_record_json`, `host_escrow.directive_json`) are untouched by this run. |