controller v0.239.0: any backup tier lets an app update (R-475)
gates / gates (push) Successful in 14s

Operator ruling 2026-09-13. The update precondition walks Tier 2, Tier 1
(own recovery unit, "helyi") and Tier 3 (off-site, 15 s bound; unreachable
counts as absent with a WARN) and leans on the first FRESH copy; the
backup_max_age rule applies to whichever tier is chosen. No copy anywhere:
back up first. Refused only when nothing exists and no backup can be taken.
RunAppBackupNow tolerates a Tier-2 failure (WARN) and marks the captured
unit proven current. The hold names the tier (második meghajtó / saját
meghajtó / távoli mentés) and the date; pre-v0.239.0 holds keep their text.
A successful off-site restore now lifts an update hold. The backups page
still uses Tier2UnitRestorePoint unchanged.

Scenarios G-M tested; red-proofs M, L, the tail and the off-site clear in
felhom.eu documentation/audits/rulings-r472-r475-2026-09-13/.
This commit is contained in:
2026-09-13 17:16:24 +02:00
parent f946b0d0ca
commit b93c1543da
16 changed files with 1028 additions and 114 deletions
+25 -9
View File
@@ -3378,16 +3378,32 @@ func (a *updateGuardsAdapter) Busy(name string) (bool, string) {
return a.b.UpdateBusy(name)
}
func (a *updateGuardsAdapter) RestorePoint(name string) (stacks.UpdateRestorePoint, error) {
// RestorePoints (R-475) reads EVERY tier through backup.UpdateRestorePoints. It must never go back to
// Tier2UnitRestorePoint alone — pinned by TestR475_AdapterReadsEveryTier.
func (a *updateGuardsAdapter) RestorePoints(ctx context.Context, name string, accept func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) {
if a.b == nil {
return stacks.UpdateRestorePoint{}, fmt.Errorf("backup is not enabled on this box")
return stacks.UpdateRestorePoint{}, false, nil
}
rp, err := a.b.Tier2UnitRestorePoint(name)
if err != nil {
return stacks.UpdateRestorePoint{}, err
conv := func(p backup.UpdateTierPoint) stacks.UpdateRestorePoint {
return stacks.UpdateRestorePoint{Tier: p.Tier, ProvenAt: p.At}
}
at, proven := rp.ProvenCopyTime()
return stacks.UpdateRestorePoint{Restorable: rp.Restorable, Proven: proven, ProvenAt: at}, nil
var acc func(backup.UpdateTierPoint) bool
if accept != nil {
acc = func(p backup.UpdateTierPoint) bool { return accept(conv(p)) }
}
chosen, ok, seen := a.b.UpdateRestorePoints(ctx, name, acc)
out := make([]stacks.UpdateRestorePoint, 0, len(seen))
for _, p := range seen {
out = append(out, conv(p))
}
return conv(chosen), ok, out
}
func (a *updateGuardsAdapter) CanBackUp(name string) (bool, string) {
if a.b == nil {
return false, "backup is not enabled on this box"
}
return a.b.CanBackUpApp(name)
}
func (a *updateGuardsAdapter) BackupNow(ctx context.Context, name string) error {
@@ -3404,9 +3420,9 @@ func (a *updateGuardsAdapter) SafetyDump(ctx context.Context, name string) ([]st
return a.b.WriteUpdateSafetyDump(ctx, name)
}
func (a *updateGuardsAdapter) HoldAfterFailedUpdate(name string, at, provenCopyAt time.Time) error {
func (a *updateGuardsAdapter) HoldAfterFailedUpdate(name string, at time.Time, rp stacks.UpdateRestorePoint) error {
if a.b == nil {
return fmt.Errorf("backup is not enabled on this box — the hold cannot be recorded")
}
return a.b.HoldAfterFailedUpdate(name, at, provenCopyAt)
return a.b.HoldAfterFailedUpdate(name, at, rp.ProvenAt, rp.Tier)
}