controller v0.239.0: any backup tier lets an app update (R-475)
gates / gates (push) Successful in 14s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user