Files
felhom.eu/documentation/audits/SPIKE-r165-mp1-merge-2026-08-02.md
T
admin 41dbecb264
gates / gates (push) Successful in 8s
docs: R-167 + R-158 CLOSED, R-165 SPIKED, R-174..R-177 filed
R-167/R-158 shipped and proven live (controller v0.191.x, hub v0.89.0):
two new capability-map rows PROVEN-LIVE with live citations, and
07-backup-architecture.md §7.5's closing claim "nothing warns when an app
crosses the line" is now false and rewritten (S-1: an architectural
contract changed in the same session). §7.5 also gains the caveat that its
size bound is ONE BOX'S, not the fleet's.

Part 3 SPIKE (audits/SPIKE-r165-mp1-merge-2026-08-02.md): M1-M5 measured,
NO layout touched. Three findings the merge session must not re-derive:
"the layout" is not one thing (200G/50G vs 50G/20G vs 16G/8G); mp1 is a
BULKHEAD and not only a ceiling, so after the merge an overflow reaches
/var/lib/docker; the golden fails closed on the split in four places.
D-a's condition (1) is currently SATISFIED — no external box is in the
hub's register, and both demo boxes are Tier 0 and reinstallable.
Recommendation given, choice NOT made — it ends at the operator's ruling.

CONTEXT.md S-11 (D-c's routing, and why R-158's own backup_failed proposal
was overruled) and S-12 (the monitoring landed BEFORE the merge).
STATUS.md gains the plain-language section and the merge decision, with two
older entries trimmed so the page did not grow.

New rows R-174 (closed same session), R-175, R-176, R-177; each ID grepped
free before minting.
2026-08-02 23:56:16 +02:00

249 lines
16 KiB
Markdown

# SPIKE — measuring the `mp1` → `mp0` merge (R-165, decision D-a)
**Date:** 2026-08-02 · **Author:** Claude Code · **Status:** MEASUREMENT ONLY — **no layout changed**
> **Decision D-a is already taken** (`CONTEXT.md` S-5): the dedicated backup partition is merged away
> rather than resized. This document measures **how**, never **whether**. It ends in a question for the
> operator and a **STOP** — the merge itself is next session's supervised work.
## 0. What was and was not touched
Nothing was created, resized, moved or deleted. No golden was rebuilt, no guest config edited, no
partition altered. Every figure below is a read of live state or of committed source. `ep0` and Peti's
box were not contacted at all (`runbooks/target-selection.md`, decision D-d).
The monitoring D-a requires (**R-167**) shipped **before** this measurement, in controller v0.191.x and
hub v0.89.0, and was proven on guest 9201. That ordering is the point: D-a's condition (2) says the
monitoring lands with the merge and never after, and landing it first is strictly better.
---
## M1 — what is actually there
**`pct config 9201`, both demo hosts, read 2026-08-02.**
| | demo-felhom (N100) | demo-hp (t740) | golden default |
|---|---|---|---|
| `rootfs` | 32 G | 32 G | `OS_SIZE_GB=32` |
| `mp0` `/var/lib/docker` | **200 G**, `backup=1` | **50 G**, `backup=1` | `GOLDEN_DOCKER_GB=16` |
| `mp1` `/mnt/sys_drive` | **50 G**, `backup=1` | **20 G**, `backup=1` | `GOLDEN_SYSDATA_GB=8` |
| `mp8` `/mnt/felhom-drives` | bind | bind | — |
| `mp9` bootstrap | bind, `ro=1` | bind, `ro=1` | — |
**Occupancy (`df`, in-guest):**
| filesystem | demo-felhom | demo-hp |
|---|---|---|
| `/` | 945 M / 32 G (4%) | 942 M / 32 G (4%) |
| `/var/lib/docker` (`mp0`) | 13 G / 197 G (7%) | 5.4 G / 50 G (12%) |
| `/mnt/sys_drive` (`mp1`) | **2.0 G / 50 G (5%)** | **92 M / 20 G (1%)** |
| data drive | 21 G / 916 G (3%) — `hdd_1` | 6.7 G / 938 G (1%) — `nvme-1tb` |
**THE FIRST FINDING IS THAT "THE LAYOUT" IS NOT ONE THING, AND THE SPREAD IS WIDER THAN §7.5 RECORDS.**
`architecture/07-backup-architecture.md` §7.5 states the default appliance as `mp0 50G / mp1 20G`
that is demo-hp exactly, and it is **not** demo-felhom, which ships **200 G / 50 G**, four and two and
a half times larger. Both differ again from the golden's own `16 G / 8 G`, because `provision` grows
the volumes after restore. **Any merge plan expressed as a fixed pair of numbers is already wrong for
one of the two boxes that exist.**
**The corollary matters more than the numbers.** §7.5's headline bound — *"≈ 19 GB of app data for a
file-only app, ≈ 10 GB for a DB-backed one"* — is derived from `mp1 = 20 G`. On demo-felhom, where
`mp1` is 50 G, the real bound is ~49 GB / ~24 GB. **The architecture doc states one bound as if it
were the fleet's, and it is one box's.** That is a documentation defect independent of the merge and
is filed as its own row.
---
## M2 — what lives on `mp1` (it is not only backups)
**`du`, in-guest, read 2026-08-02.**
```
demo-felhom /mnt/sys_drive/felhom-data 2.0 G
├── backups/primary 269 M Tier-1 units of DRIVELESS apps (~30 apps)
├── backups/secondary 1.7 G Tier-2 mirrors
└── userdata/import 12 K the canonical drop-zone (R-75)
demo-hp /mnt/sys_drive/felhom-data 92 M
├── backups/primary uptime-kuma, paperless
├── backups/secondary paperless-ngx
└── userdata/import paperless
```
**Four distinct things would move, not one**, confirming the task's warning that a plan accounting
only for the units is wrong:
1. **`backups/primary/<app>`** — the RETAINED Tier-1 recovery unit of every app with no data drive.
Thirty apps on demo-felhom.
2. **`backups/secondary/<app>`** — Tier-2 cross-drive mirrors, including the `_shares` pseudo-stack.
On demo-felhom this is **1.7 G of the 2.0 G — the majority is Tier 2, not Tier 1.**
3. **`userdata/import`** — the canonical, app-INDEPENDENT drop zone (`GetImportRoot`, R-75). It lives
on the system drive **by contract**, not by convenience.
4. **`userdata/`** more generally — the system-data userdata namespace for driveless apps.
**Observed occupancy is far below capacity on both boxes** (5% and 1%). The measured pressure today is
zero; the constraint R-163 records is a *ceiling* problem, not a *current fill* problem. That is worth
stating plainly because it bears on urgency, not on correctness.
---
## M3 — which merge shapes exist, and what each breaks
Three interacting assertions exist today, and a merge touches all three. **All three were read at
source; none had been measured before.**
**(a) The golden build ASSERTS the split and ABORTS if it is absent.**
`felhom-agent/configs/build-golden.sh:130`:
```
findmnt -no SOURCE,FSTYPE /mnt/sys_drive | grep -q . || {
echo "[golden] FATAL: /mnt/sys_drive is NOT a separate mount — the mp1 split did not take"; exit 1; }
```
There is a sibling assertion for `mp0` at :126, and **two more** at :315-324 that abort if the vzdump
log shows `excluding volume mount point mp0` or `mp1`. So the golden build fails closed on the split
in **four** places, not one.
**(b) The whole-guest archive's scope is `rootfs + mp0 + mp1`,** and only because both carry
`backup=1`. `build-golden.sh:69` records why: *"backup=1 is MANDATORY: without it vzdump EXCLUDES the
volume (extra mountpoints default backup=0)"*.
**(c) `mount_parity` compares the ARCHIVE's `mpN` set against the RESTORED guest's**
(`felhom-agent/internal/reconcile/restoretest.go:271-283`, `mountParity` at :347). Per slot it requires
the same mount path and a restored size **not smaller** than the archive's, and it checks the reverse
direction too. A mismatch fails the restore-test outright.
| shape | (a) golden assertion | (b) archive scope | (c) `mount_parity` | verdict |
|---|---|---|---|---|
| **S1 — one volume, two directories.** `mp1` stops existing; `/mnt/sys_drive` becomes a directory on the `mp0` filesystem | **BREAKS** — :130 aborts the build. Must be deleted, and :319's vzdump guard for `mp1` with it | **HOLDS** — scope becomes `rootfs + mp0`, still complete, because the data moved onto `mp0` | **HOLDS for new archives** (no `mp1` in archive ⇒ none required in restore). **A pre-merge archive restored into a merged guest is a different question — see below** | the shape D-a describes |
| **S2 — two mounts, one backing pool.** `mp0` and `mp1` remain separate `mpN` slots on the same storage | **HOLDS** — both are still separate mounts | **HOLDS** unchanged | **HOLDS** unchanged | **does NOT remove the ceiling** — two filesystems still have two independent `df`s. This is thin-provisioning, not a merge, and it converts a clean per-app refusal into a shared-pool exhaustion that neither volume can see coming |
| **S3 — grow `mp1`, keep the split** (**the shape D-a REJECTED — measured here as the baseline**) | **HOLDS** | **HOLDS** | **HOLDS** | zero structural risk, one `--sysdata-grow` value. It is *"the same wall further away"* — D-a's own words — and does not close R-163 |
**The one genuinely unmeasured item in M3, stated as unmeasured:** whether a **pre-merge archive**
(carrying `mp1`) restores cleanly into the merged world. Reading `mountParity` says it should — the
restore recreates `mp1` from the archive, so archive and restored guest agree, and parity passes. But
**that was reasoned from source and not executed**, and this project's own record is that four
production designs specced against unvalidated mechanisms were all wrong. **It is a one-command
restore-test on a Tier-0 box and should be run before the merge, not after.**
---
## M4 — the bulkhead question (the important one)
**Today `mp1` is not only a ceiling; it is a BULKHEAD.** An app whose recovery unit outgrows the space
is refused **per app**, its last good unit is preserved **byte-identical** (R-158's measurement), and
— critically — **the overflow cannot reach `/var/lib/docker`**, because it is a different filesystem.
The container runtime keeps running.
After S1 the same overflow lands on the filesystem Docker itself runs on. A runaway recovery-unit
capture would fill `/var/lib/docker`, and a full Docker data-root is not a degraded state, it is a
stopped one.
**This is the one place where "the merge is cheap" stops being true**, and it is why the warnings
shipped first rather than alongside.
Four candidate replacements. **No choice is made here — this is the operator's ruling.**
| # | replacement | what it costs | what it leaves open |
|---|---|---|---|
| **B1 — a reserved block percentage on the merged filesystem.** `tune2fs -m` reserving N% for root; the controller runs unprivileged, so a capture cannot consume the reserve while root-owned Docker can | one `tune2fs` at build time; no code | the reserve protects *root*, not *Docker's runtime need* specifically; sizing it is a guess without a measured worst case |
| **B2 — a refusal threshold in the capture path.** `captureAllRecoveryUnits` refuses when free space would drop below a floor, per app, and emits the alert R-158 just wired | small, local, testable; **reuses the alert that now exists** and restores the per-app-refusal semantics the bulkhead gave for free | the floor is a number needing justification; it protects the capture path only — a customer filling `mp0` through app data is untouched by it |
| **B3 — a filesystem quota on the backup directory.** XFS project quota / ext4 project quota on `<mp0>/sys_drive/backups` | enforces at the filesystem, so **every** writer is bounded, not only the capture path | recreates a fixed ceiling — i.e. it is R-163 again inside one volume, and D-a's objection to *"a bigger number is the same wall further away"* applies to it word for word |
| **B4 — R-167's warnings are deemed sufficient.** No hard stop; the customer is warned at 85% / 5 GiB and critically at 95% / 2 GiB | zero — it is already shipped and proven live | **a warning is not a bulkhead.** It depends on a human acting within the window, and the failure it fails to prevent is "Docker's data-root is full", which is the worst failure on the box |
**The measured input to that ruling:** on demo-felhom `mp1` holds 2.0 G against a 50 G ceiling, and
`mp0` has 175 G free. The overflow scenario is not close today on either box. **B2 is the only option
that preserves the property the bulkhead actually provided** — a per-app refusal with the last good
unit intact — and it is the one that reuses what R-158 just built.
---
## M5 — existing boxes, and what a migration costs
**Read from the hub's own registers, 2026-08-02. No box was contacted.**
The hub's `/hosts` register holds **four** hosts, of which **two are ONLINE**:
| host | customer | agent | status |
|---|---|---|---|
| `demo-felhom-8363b5` | Demo Ügyfél | 0.119.0 | **ONLINE** |
| `demo-hp-bb76ea` | Demo HP | 0.119.0 | **ONLINE** |
| `drill-r50-0a4f9a` | drill-r50 | 0.113.0 | DOWN |
| `sess-f-2670b5` | R-120 golden 0.186.0 proof | 0.116.0 | DOWN |
**The customer register lists five customers**`david`, `demo-felhom`, `demo-hp`, `peti-felhom`,
`sess-f`.
**THE FINDING THAT CHANGES THE COST: `peti-felhom` EXISTS AS A CUSTOMER BUT HAS NO HOST IN THE
REGISTER.** Consistent with the long-standing *"guest not on agent node"* stop in the Peti-return
runbook. So:
- **The two demo boxes are the entire measurable migrated population, and both are Tier 0 —
disposable, per decision D-d.** For them a "migration" is not required at all: they can be
**reinstalled** from a merged golden, which is cheaper and lower-risk than migrating in place, and
D-d explicitly permits it.
- **D-a's condition (1) — "it must land before any external install" — is currently SATISFIED.** No
external box appears in the hub's host register. **This is the cheapest this decision will ever be,
and the window is open now.**
**UNMEASURED, and reported as unmeasured rather than omitted:** Peti's box's actual disk layout. It
does not report to the hub, so the hub holds no `pct config` for it, and it is protected by D-d and
`target-selection.md` — so it was not contacted. **Whether a merged golden implies an in-place
migration for that box, and what that costs, is not established by this spike.** Nor is whether the
box is currently restorable at every point of such a migration. Both are inputs the operator has and
this session does not.
**Also unmeasured:** the per-box in-place migration procedure itself (move `<mp1>/felhom-data` onto
`mp0`, drop the `mp1` slot, verify) has not been executed even once on a throwaway guest. If an
in-place migration is ever needed, **that rehearsal is the first thing to do, and its "is the box
restorable at every point?" answer is currently unknown.**
---
## Ranked options, and a recommendation
**Ranked by what the measurements support, not by preference.**
1. **S1 (one volume, two directories) + B2 (a refusal threshold in the capture path), shipped as a
FRESH-INSTALL shape, with the demo boxes REINSTALLED rather than migrated.**
It is what D-a describes; it genuinely removes the ceiling rather than moving it; the four golden
assertions and the two vzdump guards are a bounded, greppable edit; the archive scope stays
complete; and B2 restores the per-app refusal that is the bulkhead's real value, reusing the alert
R-158 just wired. The migration cost for the measurable population is **zero**, because both boxes
are Tier 0 and reinstallable.
2. **S1 + B4 (warnings only).** Cheapest, and everything it needs is already shipped and proven live.
Rejected as the *recommendation* only because the failure it declines to prevent — a full Docker
data-root — is the worst one on the box, and it depends on a human acting inside the window.
3. **S3 (grow `mp1`, keep the split).** Zero structural risk, one number. It is the measured baseline
and D-a rejected it; recorded so the decision is compared against something.
4. **S2 (two mounts, one pool).** Not recommended at all. It keeps both assertions satisfied while
delivering none of the benefit, and converts a clean per-app refusal into a shared-pool exhaustion
neither `df` can see coming — strictly worse than today.
**Two things to do BEFORE the merge session, both cheap, both currently unmeasured:**
- **Run one restore-test of a PRE-MERGE archive into a merged-layout guest on a Tier-0 box.** M3
reasons it passes; nothing has executed it. This project's own record is that reasoning from source
about an unvalidated mechanism has been wrong four times.
- **Rehearse the in-place migration once on a throwaway guest**, and record whether the guest is
restorable at every point of it. Only needed if Peti's box turns out to require migrating rather
than reinstalling — which is the operator's information, not the hub's.
---
## THE QUESTION FOR THE OPERATOR
**Which shape, and what replaces the bulkhead?**
The recommendation is **S1 + B2**, fresh-install shape, demo boxes reinstalled. The two open inputs
only the operator has are:
1. **Does Peti's box need an in-place migration, or can it be reinstalled?** The hub cannot answer
this — the host is not in its register. It decides whether the migration rehearsal is required work
or optional insurance.
2. **Is B2's per-app refusal the right replacement for the bulkhead, or is B4 (warnings only)
acceptable?** B2 costs a small amount of code and a justified floor; B4 costs nothing and is
already live, but accepts that a full Docker data-root is reachable.
**STOP.** The merge is next session's work, and it is a supervised one.