Files
felhom-agent/internal/localapi/backup_tiers.go
T
Claude Code 739b3c3b58 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
2026-07-26 12:20:58 +02:00

57 lines
1.8 KiB
Go

package localapi
import "time"
// normalizeBackupTiers resolves the tier list the Server serves.
//
// Contract (R-82), and the reason this is a named function rather than inline setup: the UNTARGETED
// local-API endpoints must keep behaving exactly as they did before multi-tier existed, forever.
// That property lives here.
//
// - tiers == nil → synthesize ONE tier from the legacy (Backups, BackupCadence) pair and mark it
// primary. This is the pre-R-82 shape; every existing caller and test hits this path.
// - tiers supplied → keep order but hoist the primary to the front; if none is marked primary,
// the FIRST becomes primary (a tier list with no primary would leave untargeted requests with
// nothing to act on, which would silently stop backups).
// - tiers with a nil Service are dropped: a tier with no runner cannot back anything up, and
// advertising it would be an "applied and empty" tier — the exact fault R-82 exists to fix.
func normalizeBackupTiers(tiers []BackupTier, legacy BackupService, cadence time.Duration) []BackupTier {
usable := make([]BackupTier, 0, len(tiers))
for _, t := range tiers {
if t.Service == nil || t.TargetID == "" {
continue
}
if t.Cadence <= 0 {
t.Cadence = cadence
}
usable = append(usable, t)
}
if len(usable) == 0 {
if legacy == nil {
return nil
}
return []BackupTier{{TargetID: "", Cadence: cadence, Primary: true, Service: legacy}}
}
primary := -1
for i, t := range usable {
if t.Primary {
primary = i
break
}
}
if primary < 0 {
primary = 0
}
out := make([]BackupTier, 0, len(usable))
usable[primary].Primary = true
out = append(out, usable[primary])
for i, t := range usable {
if i == primary {
continue
}
t.Primary = false
out = append(out, t)
}
return out
}