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)
}
@@ -0,0 +1,71 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-475 — the update reads EVERY backup tier, through the one adapter.
func TestR475_TierConstantsAgree(t *testing.T) {
if stacks.UpdateTierLocal != backup.UpdateTierLocal ||
stacks.UpdateTierSecondDrive != backup.UpdateTierSecondDrive ||
stacks.UpdateTierOffsite != backup.UpdateTierOffsite {
t.Fatal("stacks and backup number the tiers differently — a hold would name the wrong copy")
}
}
// adapterMethodSelectors returns the selector names used in updateGuardsAdapter.<name>'s body.
func adapterMethodSelectors(t *testing.T, name string) string {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Recv == nil || fn.Name.Name != name || fn.Body == nil {
continue
}
star, ok := fn.Recv.List[0].Type.(*ast.StarExpr)
if !ok {
continue
}
if id, ok := star.X.(*ast.Ident); !ok || id.Name != "updateGuardsAdapter" {
continue
}
var names []string
ast.Inspect(fn.Body, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok {
names = append(names, sel.Sel.Name)
}
return true
})
return " " + strings.Join(names, " ") + " "
}
t.Fatalf("updateGuardsAdapter.%s not found in main.go", name)
return ""
}
func TestR475_AdapterReadsEveryTier(t *testing.T) {
rp := adapterMethodSelectors(t, "RestorePoints")
if !strings.Contains(rp, " UpdateRestorePoints ") {
t.Errorf("the adapter must read every tier through backup.UpdateRestorePoints; selectors:%s", rp)
}
if strings.Contains(rp, " Tier2UnitRestorePoint ") {
t.Error("the Tier-2-only predicate must not come back into the update path (R-475)")
}
if cb := adapterMethodSelectors(t, "CanBackUp"); !strings.Contains(cb, " CanBackUpApp ") {
t.Errorf("CanBackUp must ask backup.CanBackUpApp; selectors:%s", cb)
}
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " Tier ") {
t.Errorf("the hold must be told the chosen TIER, or it cannot name it; selectors:%s", h)
}
}