739b3c3b58
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
57 lines
1.8 KiB
Go
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
|
|
}
|