R-101 + F-DIAG closed, F-OPS documented (manual-restore runbook)

This commit is contained in:
2026-07-28 16:45:37 +02:00
parent 6b7d516ce5
commit f47b0a61d7
3 changed files with 340 additions and 1 deletions
+3 -1
View File
@@ -27,7 +27,9 @@ State: `BLOCKED` · `READY` · `WAITING-ON-OPERATOR` · `WATCHING`. Every row ha
| **F-CRIT-1** | ~~An app that **fails to restart** after a quiesce never alarms on any channel — `restartAll` discarded the error AND `StateStopped` was whitelisted on invariant I1, which the quiesce path had made false~~ | **SHIPPED + PROVEN-LIVE** (controller v0.179.0, 2026-07-28) | — | Both causes fixed. Live on demo-hp: alarmed 9s after grace expiry, banner shows `(stopped)`; a deliberate user stop stayed silent through 9 dead-app scans | — |
| **F-A1** | ~~A restore-test in flight made a healthy backup report as FAILED (HTTP 409 read as a tier failure): breaker armed + operator emailed, on both boxes~~ | **SHIPPED + PROVEN-LIVE** (controller v0.179.0, 2026-07-28) | — | 409 → contention: tier stays DUE, dropped before anything stops (15m), and BLOCKED alarm if contention outlives the agent's 120m ceiling (3h). Hub DB: 409 → **0** operator emails, real failure → **1** | — |
| **R-100** | ~~A restic offsite tier that fails every night never goes stale on the hub — `isStale` counted from `LastRun`, which the controller writes unconditionally on failure~~ | **SHIPPED + PROVEN-LIVE** (controller v0.181.0 + hub v0.80.0, 2026-07-28) | — | Anchored on a new `last_success`. **Severity corrected during Phase 0: this was NOT a silence**`backup_failed` does fire nightly and reaches the operator (live DB: 5 sends). The real defect is **defeated defence in depth**: the hub-side *pull* net was anchored on a field the failing controller keeps refreshing, so it could not compensate for a lost *push* (cf. F-HUB). Live on demo-hp: induced failure → `last_run` advanced 11:25:48Z, `last_success` **held** 11:24:20Z; demo-felhom healthy → anchor advanced. Legacy degrade logged once per customer, live | — |
| **R-101** | **Tier-2 (cross-drive) `LastRun` is also written on failure** (`recordTier2Failure`), and three customer surfaces render it **without** a status: the `Tier2DestDisconnected` and `Tier2DestInactive` branches of `backups_apps.html`, and the restore-confirm dialog (`Legutóbbi másolat: {{.Tier2LastRun}}`) — which presents a possibly-failed run's timestamp at the moment the customer decides whether to restore | READY (S) | — | Found by R-100's P0.3/P0.4 sweep; **filed not fixed** (out of that task's path). No hub verdict reads it, so this is a UI-honesty issue, not an alarm one. The two degraded branches do carry a `tag-warn`; the restore-confirm is the sharpest instance. Fix: pair the timestamp with the status everywhere, as the main Tier-2 branch already does | CC |
| **R-101** | ~~Tier-2 `LastRun` is written on failure and rendered to the customer as „Legutóbbi másolat" — including in the restore confirm dialog~~ | **SHIPPED + PROVEN-LIVE** (controller v0.182.0, 2026-07-28) | — | `CrossDriveBackup.LastSuccess` + `SuccessTracked`; the dialog names the last **successful** copy and discloses a failed newest attempt. Legacy rows migrate truthfully on first touch (an `ok` row adopts its time; an `error` row seeds nothing) — without the marker all 7 fleet rows would have flipped to „Még nincs sikeres másolat" on deploy. **Part 2:** the three `record*` sites rebuilt the whole struct; replaced by `tier2Update` (copy-and-overlay, safe by construction) — the naive fix would have had `recordTier2Failure` CLEAR the anchor. Live on demo-hp, rendered dialog read in both states | |
| **F-DIAG** | ~~Four distinct offsite failure causes collapse into two operator-visible strings~~ | **SHIPPED** (controller v0.182.0, 2026-07-28) | — | `ClassifyOffsiteFailure` → quota / orphaned / no_repo / no_units / transport / **unknown**, each with its own Hungarian message. Unclassifiable says so rather than being folded into a neighbour. **Secrets:** the old message was a raw `err.Error()` passthrough carrying `sftp:<user>@<host>:<path>`; redaction is now by the target's **actual** host/user/path (a first regex-only attempt leaked on a bare hostname and its own test caught it). Unit-proven; **not** yet exercised by a live offsite failure of each class | — |
| **F-OPS** | ~~A manual `pct restore` inherits the source guest's bind mounts — during a real DR, on a different host, under pressure~~ | **DOCUMENTED** (2026-07-28) | — | `documentation/runbooks/RUNBOOK-manual-guest-restore.md`: which `mpN` are volumes vs host binds, the `mp9` source-VMID trap (it can bind **another guest's bootstrap credentials**), strip-and-re-add before first boot, and a positive pre-start verification. Docs only by design — the agent already neutralises binds on its own restore paths, and a second implementation would drift | — |
| **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 | — |
@@ -0,0 +1,156 @@
# RUNBOOK — manual guest restore (`pct restore`), and the bind mounts it drags with it
**Status:** operational procedure. Written 2026-07-28 to close **F-OPS** (Campaign 8).
**Audience:** operator, at a keyboard, usually during a real disaster recovery.
> **The hazard in one sentence:** `pct restore` recreates the guest from the archive's **own config**,
> which includes the source guest's `mpN` bind mounts — so the restored guest can come up bound to a
> host path that does not exist on this host, or, worse, to a path that exists and belongs to
> **something else**.
This is not hypothetical and it is not rare: the agent's own restore paths neutralise these mountpoints
deliberately (`bindMountOverrides` in `restoretest.go`, `RestoreLXC{MountOverrides:…}` — see
`audits/SPIKE-dr-bindmount-source-2026-07-07.md`). A **manual** `pct restore` has no such protection.
The automation is safe; the human path is the one that needs this page.
Timing makes it worse: a manual restore happens during an incident, under pressure, often on a
*different* host from the one the archive came from.
---
## 1. What a Felhom guest's mounts actually look like
From live guest 9201 (2026-07-28) — the shape you should expect:
```
rootfs: local-lvm:vm-9201-disk-0,size=32G
mp0: local-lvm:vm-9201-disk-1,mp=/var/lib/docker,backup=1,size=50G
mp1: local-lvm:vm-9201-disk-2,mp=/mnt/sys_drive,backup=1,size=20G
mp8: /mnt/felhom-drives,mp=/mnt/felhom-drives ← HOST BIND
mp9: /var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1 ← HOST BIND
hookscript: local:snippets/felhom-guest-hook.sh
```
**Two kinds of `mpN`, and only one is dangerous:**
| kind | looks like | on restore |
|---|---|---|
| **Volume mount** (`mp0`, `mp1`) | starts with a storage ID — `local-lvm:vm-…` | restored from the archive. Fine. |
| **Bind mount** (`mp8`, `mp9`) | starts with an absolute **host path**`/mnt/…`, `/var/lib/…` | **NOT in the archive.** The path is taken as-is on the target host. |
The bind mounts are **structural constants** of the Felhom topology, not per-customer data — `mp8` is
the shared drive parent, `mp9` is the per-guest bootstrap dir. That is what makes them safe to rewrite
by hand: you are restoring a known layout, not guessing.
**`mp9` is the one that bites.** It embeds the **source** VMID:
`/var/lib/felhom-agent/guests/<SOURCE-VMID>/bootstrap`. Restore to a different VMID and the guest
either finds nothing there or — if that VMID exists on this host — reads **another guest's bootstrap
config, including its credentials**.
---
## 2. Before you start — three checks
Run these on the **target** host, and write the answers down.
```bash
# 1. What does the archive think its mounts are? (read the config WITHOUT restoring)
# PBS:
proxmox-backup-client restore <snapshot> pct.conf - --repository <repo> 2>/dev/null | grep -E '^(mp|rootfs)'
# vzdump tarball:
tar -xOf /path/to/vzdump-lxc-<vmid>-<ts>.tar.zst ./etc/vzdump/pct.conf 2>/dev/null | grep -E '^(mp|rootfs)'
# 2. Does every BIND path exist on THIS host?
ls -ld /mnt/felhom-drives /var/lib/felhom-agent/guests/<TARGET-VMID>/bootstrap
# 3. Is the target VMID free, and is the source VMID something else here?
pct list | awk '{print $1}' | grep -x -e <TARGET-VMID> -e <SOURCE-VMID>
```
**Stop and think if:** a bind path is missing, or the source VMID is a live guest on this host.
---
## 3. Restore, then fix the binds BEFORE first boot
Restore **without starting** the guest. `pct restore` does not auto-start, but never pass anything that
would, and do not `pct start` until §4 passes.
```bash
pct restore <TARGET-VMID> <volid> --storage <storage> --unprivileged 1
```
Then strip and re-add the binds. Do this even when the VMID is unchanged — it costs seconds and it is
the whole point of this page:
```bash
# strip every BIND mountpoint the archive carried (leave the volume mounts mp0/mp1 alone)
pct set <TARGET-VMID> --delete mp8
pct set <TARGET-VMID> --delete mp9
# re-add them for THIS host and THIS vmid
mkdir -p /var/lib/felhom-agent/guests/<TARGET-VMID>/bootstrap
pct set <TARGET-VMID> -mp8 /mnt/felhom-drives,mp=/mnt/felhom-drives
pct set <TARGET-VMID> -mp9 /var/lib/felhom-agent/guests/<TARGET-VMID>/bootstrap,mp=/etc/felhom-bootstrap,ro=1
```
> The bootstrap dir must be populated for the guest to enrol. On a host running the agent, the
> provisioning back-half owns that directory — prefer letting the agent write it to hand-copying a
> `bootstrap.json` between guests. **Never** copy one from another customer's guest: it carries that
> customer's tokens.
Also check the hookscript line: `hookscript: local:snippets/felhom-guest-hook.sh` must point at a
snippet that exists **on this host**, or the guest will refuse to start.
```bash
ls -l /var/lib/vz/snippets/felhom-guest-hook.sh || pct set <TARGET-VMID> --delete hookscript
```
---
## 4. Verify before starting — the positive check
Do not accept "no error" as evidence. Assert what must be **true**:
```bash
# every mpN is either a storage volume or a bind whose host path EXISTS on this host
pct config <TARGET-VMID> | grep -E '^mp[0-9]+:' | while read -r line; do
src=${line#*: }; src=${src%%,*}
case "$src" in
/*) [ -e "$src" ] && echo "OK bind $src" || echo "MISSING bind $src <-- fix before start" ;;
*) echo "OK volume $src" ;;
esac
done
# no mpN may still reference the SOURCE vmid
pct config <TARGET-VMID> | grep -E "guests/<SOURCE-VMID>/" && echo "STILL POINTS AT THE SOURCE GUEST"
```
Only when every line reads `OK` and the source-VMID grep is empty:
```bash
pct start <TARGET-VMID>
pct exec <TARGET-VMID> -- docker ps --format '{{.Names}} {{.Status}}'
```
---
## 5. If the guest is already running with the wrong binds
Stop it before touching the mounts — a live bind swap under a running container set is how data ends up
half-written to two places.
```bash
pct stop <TARGET-VMID>
# ...§3 fixes...
pct start <TARGET-VMID>
```
---
## What this page deliberately does NOT do
**It does not build tooling.** The agent already neutralises binds on its own restore paths; wrapping
the manual path in a script would create a second implementation of that logic, and two implementations
of one invariant is how they drift. If the manual path becomes routine, the correct move is to route it
through the agent, not to script around it.