v0.174.0 — R-82 Slice B: one quiesce window, two backup tiers

MinAgent UNCHANGED — degrades gracefully against ANY older agent.

The agent gained per-target tiers in v0.97.0. The controller owns quiescing,
so the multi-tier schedule is reconciled here: every due tier is collected up
front and run inside ONE quiesce window (one stop, N sequential backups, one
resume). Two cycles on the weekly night would mean two app outages for one
night's work.

Dedup rule: local-only -> one quiesce; PBS-only -> one quiesce; BOTH due ->
ONE window with both backups inside; neither -> no quiesce.

- quiesce.TieredBackend + BackupTier + ErrTiersUnsupported (optional extension)
- agentapi: BackupTiers/BackupDueFor/StartBackupFor/BackupStatusFor;
  targetQuery("") yields an EMPTY suffix so untargeted hits the pre-R-82 route
  byte-for-byte
- Loop.resolveDueTiers = the dedup rule in one place, agent order preserved
- quiesceAndPollTiers + pollTier: app stays quiesced until the LAST tier
  snapshots (resuming earlier loses app-consistency on the DR tier). Consequence
  stated in the docs: both-due-night downtime = first tier's full backup + last
  tier's snapshot, which is why tiers run fast-first.
- Manual 'Mentes most' covers EVERY tier, due-ness ignored.
- Window-gate safety valve now uses the OLDEST due tier, so a stale DR tier
  cannot be starved by a fresher local one.

Capability detection: /backup/tiers 404 = pre-R-82 agent (the documented
route-probe mechanism). Not a featureProbes row on purpose — the loop needs the
tier LIST, not a yes/no. Degrade logged exactly once per process.

Tests +11, full suite green. Red-proofs #2 and #3 observed and restored.
This commit is contained in:
Claude Code
2026-07-26 14:40:44 +02:00
parent 47fda06ba1
commit de96efc0c5
8 changed files with 924 additions and 42 deletions
+73
View File
@@ -1,5 +1,78 @@
## Changelog
### v0.174.0 — R-82 Slice B: one quiesce window, two backup tiers (2026-07-26)
**MinAgent UNCHANGED — deliberately.** This release degrades gracefully against ANY older agent; it
does not require v0.97.0. Against a pre-R-82 agent it uses the untargeted single-tier path exactly as
before, logs the degrade once, and **still takes the backup**.
The agent gained per-target backup tiers in v0.97.0 ("local daily + PBS weekly"). The **controller**
owns quiescing, so the multi-tier schedule has to be reconciled here: on the weekly night both tiers
come due at once, and two quiesce cycles would mean **two app outages for one night's work**
undoing the entire argument for weekly-over-daily.
### The dedup rule (specified, not emergent)
| local due | PBS due | result |
|---|---|---|
| yes | no | one quiesce, local backup |
| no | yes | one quiesce, PBS backup |
| **yes** | **yes** | **ONE quiesce window, BOTH backups inside it — never two cycles** |
| no | no | no quiesce |
### Added
- **`quiesce.TieredBackend`** (optional extension to `Backend`) + `quiesce.BackupTier`,
`ErrTiersUnsupported`. A backend that does not implement it — or whose `Tiers` returns
`ErrTiersUnsupported` — drives the pre-R-82 single-tier path unchanged.
- **`agentapi` per-tier client**: `BackupTiers`, `BackupDueFor`, `StartBackupFor`,
`BackupStatusFor` (`internal/agentapi/backup_tiers.go`). `targetQuery("")` yields an EMPTY suffix,
so an untargeted call hits the untargeted route byte-for-byte.
- **`Loop.resolveDueTiers`** — the dedup rule in one place, returning due tiers in AGENT ORDER.
- **`Loop.quiesceAndPollTiers` + `pollTier`** — one marker, one stop, N sequential backups, one
resume, tail polled to completion.
### Capability detection
`GET /backup/tiers` 404 ⇒ pre-R-82 agent. This is the project's documented ROUTE-PROBE mechanism
(`internal/agentapi/features.go`: "a route that shipped together with the coupled semantics either
answers (2xx ⇒ supported) or 404s"). It is **not** registered in the `featureProbes` table on
purpose: that table answers a yes/no at a UI entry point, whereas the loop needs the tier LIST
itself, so a table row would be a second probe of the same route for no gain. The degrade is logged
**exactly once per process** — once because it is a steady state during a rollout, never zero times
because a silent degrade is indistinguishable from multi-tier working.
### Two decisions worth stating plainly
**The app stays quiesced until the LAST tier snapshots.** Resuming after tier 1's snapshot would
leave the following tier capturing a RUNNING app — losing app-consistency on exactly the DR tier we
most want it on. **Consequence, user-visible:** on the both-due night downtime is
*(first tier's full backup)* + *(last tier's snapshot)*, not one snapshot. Tiers must therefore run
**fast-first**: vzdump holds a guest lock so they are necessarily sequential, and the agent
advertises primary (local) first — local-then-PBS makes downtime ≈ local backup + PBS snapshot,
whereas the reverse would be ≈ PBS backup + local snapshot, far worse.
**A manual "Mentés most" covers EVERY tier**, in one window, due-ness ignored. A manual run that
silently skipped the DR tier would be the same applied-and-empty fault in a different costume.
### Resilience (unchanged guarantees, extended per tier)
- Marker written BEFORE anything stops; unquiesce guaranteed by `defer` and fires **exactly once**
no matter which tier fails; a crash between two backups leaves the marker and `Recover()` restarts
the stacks at startup.
- One tier failing to START does not prevent the other tier's backup, and the app still resumes once.
- One tier's due-check erroring does not drop the other tier's backup.
- An agent advertising ZERO tiers falls back to the untargeted path — never "nothing to do".
- The window gate's safety valve now evaluates the OLDEST (most overdue) due tier, so a stale DR
tier cannot be starved by a fresher local one (`oldestAge`; a never-backed-up tier wins outright).
### Tests
+11 in `internal/quiesce/tiers_test.go`; full suite green. Red-proofs observed and restored:
- **#3 both-due night** — a per-tier cycle instead of one window fails with
`want EXACTLY 1 stop and 1 start, got stops=2 starts=2`. The COUNT is the assertion; asserting
only "both backups ran" would pass against a double-quiesce implementation.
- **#2 new controller ↔ old agent** — treating `ErrTiersUnsupported` as "nothing due" fails with
`OLD AGENT: a backup MUST still be taken via the untargeted path; got started=[]`. The hollow
version of this test asserts only "no error", which passes while silently skipping the backup.
### v0.173.0 — R-77: endpoint-drift detection, samba protected-set gate, channel log honesty (2026-07-26)
Source: `felhom.eu/documentation/audits/DIAG-agent-channel-2026-07-26.md`.