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:
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user