v0.97.0 — R-82 Slice A: per-target backup tiers (local daily + PBS weekly)

Mechanism only. No box changes behaviour until a backup_targets entry is
added to its config (Slice D); an untouched config resolves to exactly one
tier and behaves byte-identically to v0.96.0.

- config: BackupTargetConfig + ExtraTargets + BackupTiers(); each tier carries
  its OWN cadence and retention (keep-last=3 is three days on a daily tier and
  three weeks on a weekly one). A missing cadence is REJECTED, not defaulted —
  a weekly DR tier silently running daily would fill the 37.2 GB datastore.
  main.go logs every rejection at ERROR.
- /backup/due?target= judges a tier against its OWN newest successful backup.
  Without that filter a fresh local backup satisfies the weekly PBS cadence and
  the DR tier never runs — today's bug, re-created in code.
- GET /backup/tiers advertises the tiers; a 404 is the controller's pre-R-82
  capability probe (Slice B).
- Jobs keyed by (vmid,target): single-flight is per tier, which is what lets
  the weekly night run both backups in ONE quiesce window. Job ids are unique
  per tier by construction, not by clock luck.
- One runner per tier: the runner holds target+retention as immutable state,
  so parameterising one runner would risk pairing tier A's target with tier B's
  retention.

COMPATIBILITY (frozen): untargeted /backup/due, POST /backup and
/backup/status keep the primary tier and the pre-R-82 response BYTES —
Target is omitempty and stays empty. The primary's job-id format is unchanged.

NOT changed: the local tier; PBS is still never pruned by the per-run flag
(keep_last defaults to 0 = never prune — enabling DR pruning is irreversible
and needs an operator ruling).

Tests 748->768. Red-proof #1 observed and restored.
Phase 0: felhom.eu/documentation/audits/SPIKE-r82-phase0-2026-07-26.md
This commit is contained in:
Claude Code
2026-07-26 12:20:58 +02:00
parent dfd5d731ee
commit 739b3c3b58
8 changed files with 980 additions and 36 deletions
+73
View File
@@ -1,3 +1,76 @@
## v0.97.0 — R-82 Slice A: per-target backup tiers (local daily + PBS weekly) (2026-07-26)
Additive; **MinAgent floor rises** for the multi-tier contract (a controller that wants per-tier
backups needs this agent — but see the compatibility rule: an OLD controller is unaffected).
Slice A of R-82. `BackupTarget()` returned ONE string and `BackupCadence()` ONE 24h window, so
"local daily **and** PBS weekly" was not expressible at all — which is why the DR promise is
currently unbacked (demo-felhom holds one PBS snapshot from 2026-07-18, demo-hp zero, ever).
Phase-0 gate results: `felhom.eu/documentation/audits/SPIKE-r82-phase0-2026-07-26.md`.
**This slice ships the mechanism only. No box's behaviour changes until a `backup_targets` entry is
added to its config (Slice D).** An untouched config resolves to exactly one tier and behaves
byte-identically to v0.96.0.
### The compatibility rule (load-bearing)
The agent and controller deploy independently, so the untargeted local-API contract is **frozen**:
- `GET /backup/due` with **no** `?target=` → the PRIMARY tier, same cadence, same response **bytes**.
`BackupDueResponse.Target` is `omitempty` and left EMPTY for untargeted requests, so an old
controller cannot tell this agent from the old one. Pinned by a red-proofed test.
- Same for `POST /backup` and `GET /backup/status`.
- The primary tier's **job-id format is unchanged**; only additive tiers carry a target segment.
### Added
- **`config.BackupTargetConfig` + `BackupConfig.ExtraTargets`** (`backup_targets`) — each tier
carries its OWN cadence and its OWN retention. Those are semantically different per tier:
`keep-last=3` is three DAYS on a daily tier and three WEEKS on a weekly one, so sharing one knob
guarantees one of them is wrong.
- **`BackupConfig.BackupTiers() ([]BackupTier, []string)`** — resolves the tier list, primary first,
plus warnings the caller MUST log. A tier is **rejected, not defaulted**, when its cadence is
missing: silently defaulting a weekly DR tier to the 24h local default would fill the 37.2 GB
datastore. Empty/duplicate targets are rejected too. `main.go` logs every rejection at **ERROR**
a silently dropped backup tier is the "applied and empty" fault this task exists to fix.
- **`GET /backup/tiers`** — advertises the tiers, primary first. This is the controller's capability
probe: a **404 means a pre-R-82 agent**, and Slice B falls back to single-tier on it.
- **`localapi.BackupTier` + `normalizeBackupTiers`** — nil tiers synthesize the legacy single tier,
so every existing caller and test hits the pre-R-82 path untouched. A tier with a nil runner is
DROPPED rather than advertised (advertising one would be an applied-and-empty tier).
### Changed
- **`/backup/due?target=` judges that tier against ITS OWN newest successful backup**
(`latestSuccessfulBackupForTarget`). Without this filter a fresh daily local backup would satisfy
the weekly PBS cadence and the DR tier would never run — today's bug, re-created in code. The
store was already keyed by target, so this is a lookup change, not a data-model change.
- **Backup jobs are keyed by (vmid, target), not vmid.** Single-flight is now PER TIER, which is
what lets the weekly night run both backups inside ONE quiesce window (Slice B). Keying by vmid
alone handed the second caller the first tier's job id — **caught by its own test**, and it would
have made the controller believe a PBS backup ran when only the local one had.
- **Job ids are unique per tier by construction**, not by clock luck (two tiers can start in the
same nanosecond). The primary keeps the old format; additive tiers carry the target segment.
- **One runner per tier** (`main.go`). The runner holds target/mode/notes/retention as immutable
construction state and `localPruneSpec` reads that retention — parameterising a single runner by
target would risk pairing tier A's target with tier B's retention.
- An unknown `?target=` is a **400, never a silent fallback to the primary**. A controller asking
about a tier this agent does not serve must find out, not act on another tier's freshness.
### NOT changed (deliberate)
- **The local tier.** Same target, same 24h cadence, same keep-last=3 clamp. It is the only honest
tier today and this slice does not touch it.
- **PBS is still never pruned by the per-run `--prune-backups` flag** (`localPruneSpec`). Per-tier
retention is plumbed and a tier's `keep_last` defaults to 0 = never prune, but turning on
automatic pruning of the DR datastore is irreversible and needs an operator ruling — R-82 Phase 0
already flagged retention as needing one. Recorded, not decided here.
- The fail-safe-toward-due rule on an unparseable timestamp. A spurious backup is cheap; a skipped
one is not.
### Tests
768 (was 748), +20 across `internal/localapi/backup_tiers_test.go` and
`internal/config/backup_tiers_test.go`. Red-proof #1 (old controller ↔ new agent) observed:
removing the untargeted compat branch makes the untargeted body carry `"target":"local"` and the
test fails on it. Restored.
## v0.96.0 — R-50 island NIC: provision attaches the guest's island net1 (2026-07-25)
Additive; **MinAgent unchanged** (no controller coupling — the controller dials whatever `bootstrap.json`