v0.193.0 — the reserve guards the write that fills the disk, and its promise is true (R-181)
gates / gates (push) Successful in 9s

B2's capture floor (v0.192.0) was consulted in exactly ONE place —
captureAllRecoveryUnits, which writes a few KB. The two legs that write the BULK
into the same backups/primary/<app> tree, the DB dump and the volume dump, ran
FIRST and unguarded. Measured live on demo-hp 2026-08-03 06:40:03: opengist's
volume dump wrote 2.0 GB with no check, free fell to 1.0 GB, and the floor then
refused the cheap write it had already lost the argument to. Its refusal message
claimed "the previous unit is untouched" — measured false: that app's tar had
gone 182,272 B -> 2,147,666,432 B under a stale manifest. Sixth entry in
CLAUDE.md's table of shipped guarantees the code did not provide.

Fix: ONE admission verdict per app per run (internal/backup/admission.go), taken
before that app's FIRST write and covering all three legs — they write under one
per-app root, which is why one verdict can honestly cover them.

- Lazy, at the app's first write, NOT once at run start: app A's dump can put app
  B under the reserve, so a run-start verdict reads a disk that no longer exists.
- Remembered for the run, never re-decided between an app's own legs — that is
  the split this closes. Reset per run.
- Placed ahead of DumpAppVolumesSafe, which stops the stack as its first act, so
  a refused app is never bounced. After the volume-less check, which has no write.
- Exactly one operator alert per refused app per run.
- Leg order unchanged: volume dumps still precede the capture.

The floor is now SIZE-AWARE: it asks whether THIS app's write would cross the
reserve, not only whether the filesystem is already below it — which is how an
app was admitted at 96% and then allowed to write 2 GB. Estimate = the app's
previous .sql + .tar on disk. No history -> headroom-only, deliberately, and the
alert says so.

A container-based du per volume was MEASURED and rejected: 66 timed runs on
demo-hp guest 9201, median ~355 ms/volume (341-404) on volumes holding tens of
KB — container start-up, not the walk. Decisive on top: docker run needs the
writable layer, so it can fail under exactly the pressure the reserve handles.

The message was NOT weakened; the behaviour was moved so the wording became true.
It now also names which term bound. Every claim is checked against a sha256
fingerprint of the tree it describes, never against the log line.

Still refuses and never deletes: nothing here is generational.

11 new tests through the production functions. The DB leg cannot run without
Docker, so its gate is pinned by an AST walk of backup.go asserting admitApp
precedes DumpOne (strings.Contains is insufficient — a commented-out call still
contains the string). 4 red-proofs demonstrated failing then restored.
This commit is contained in:
2026-08-03 10:53:48 +02:00
parent 4be6467b50
commit fef07c3923
8 changed files with 1119 additions and 33 deletions
+21
View File
@@ -623,6 +623,27 @@ Per-app export creates a self-contained `.fab` file (tar.gz, optionally encrypte
The backup system implements a **3-2-1 backup architecture**. Each tier is a **complete,
self-sufficient backup** — any single tier can fully restore an app.
**The reserve — per-app backup admission (v0.192.0 decision B2, widened by v0.193.0 / R-181).**
`internal/backup/admission.go`. Since the `mp1``mp0` merge (R-165) local backups and Docker's
data-root share one filesystem, so an unbounded backup write is a stopped box rather than a slow one.
Before **any** of an app's three local write legs runs — DB dump, volume dump, recovery-unit capture —
`admitApp` takes **one verdict for that app for that run** and the other two legs reuse it. A refused
app writes nothing at all, is **not stopped**, keeps its previous unit byte-identical, and produces
**exactly one** operator alert (`recovery_unit_capture_failed`, operator-tier).
- **The verdict is lazy, not run-wide.** It is taken at the app's first write, because app A's dump
can put app B under the reserve; a verdict taken at run start would read a disk that no longer
exists by the time B writes.
- **It is never re-decided between an app's own legs**, and the memo is reset per run.
- **Two questions, both against two thresholds (97% used / 1 GiB free).** *Headroom*: is the
filesystem already below the reserve? *Size*: would this app's own write take it below? The size
estimate is the app's **previous** `.sql` + `.tar` already on disk. **No history → headroom-only**,
deliberately — otherwise the first backup is the one that can never happen — and the alert says so.
- **The thresholds sit beyond `fillwatch`'s critical band (95% / 2 GiB)**, so the customer is always
warned before a refusal is possible.
- **It refuses; it never deletes.** Nothing here is generational — one unit per app at one fixed path
— so "prune the oldest" could only destroy a different app's only local copy.
**Sidebar behaviour (v0.146.0).** Groups that own sub-pages — Tárhely, Biztonsági mentés,
Megosztás — render as **accordions**: the header is a real `<button class="nav-group-toggle">`
(keyboard- and AT-reachable for free) carrying a chevron, and **exactly one group is open at a