controller v0.272.0: the backup page says when a whole-box backup does not fit (R-685); R-671, R-670, R-677
gates / gates (push) Successful in 26s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-25 11:19:27 +02:00
parent 71accca427
commit 44ae4dea70
21 changed files with 376 additions and 10 deletions
+3
View File
@@ -174,6 +174,9 @@ type Manager struct {
// updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck.
updatingCheck func(stackName string) bool
// undoCopyRemover (R-671, v0.272.0) deletes an app's leftover undo copies — stacks.Manager.RemoveUndoCopies,
// wired in main.go (SetUndoCopyRemover). nil-safe: without it the copies stay, as before.
undoCopyRemover func(stackName string) int
// R-475 update-precondition seams, one per tier. Nil → the real Tier2UnitRestorePoint /
// ListRestorePoints / OffsiteSnapshotTimes. They let a test reach Tier 1 and Tier 3 without a drive
@@ -0,0 +1,39 @@
package backup
import (
"io"
"log"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-671 (v0.272.0) — the undo copies kept by an update hold are removed when a restore lifts that hold; a
// RESTORE hold (R-379) lifts nothing and removes nothing; an app a guarded update is moving is never touched.
//
// COMPANION RED-PROOF (REPORT.md): drop the undoCopyRemover call from clearUpdateHoldAfterRestore — this test
// fails at "the lifted update hold's undo copies were left behind".
func TestR671_RestoreThatClearsAnUpdateHoldRemovesItsUndoCopies(t *testing.T) {
sett := slice4Settings(t)
m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
removed := map[string]int{}
m.SetUndoCopyRemover(func(s string) int { removed[s]++; return 3 })
_ = m.HoldAfterFailedUpdate("nextcloud", time.Now(), time.Now(), UpdateTierLocal)
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "rst", At: "2026-08-22T14:00:00Z"})
m.clearUpdateHoldAfterRestore("nextcloud")
m.clearUpdateHoldAfterRestore("rst")
if removed["nextcloud"] != 1 {
t.Fatalf("the lifted update hold's undo copies were left behind (remover calls: %v)", removed)
}
if removed["rst"] != 0 {
t.Fatal("a restore hold is not lifted by a restore, so nothing of it may be removed")
}
// an app mid-update: its copies are the live undo — never removed
_ = m.HoldAfterFailedUpdate("moving", time.Now(), time.Now(), UpdateTierLocal)
m.SetUpdatingCheck(func(s string) bool { return s == "moving" })
m.clearUpdateHoldAfterRestore("moving")
if removed["moving"] != 0 {
t.Fatal("the undo copies of an app a guarded update is moving must never be removed")
}
}
@@ -607,6 +607,21 @@ func (m *Manager) clearUpdateHoldAfterRestore(stackName string) {
return
}
m.logger.Printf("[INFO] [backup] %s: restore completed — the update hold (set %s) is CLEARED", stackName, h.At)
// R-671 (v0.272.0): the undo copies the hold kept describe the state this restore just replaced. They
// were kept so the hold's data stayed recoverable; once the app is restored whole they are dead weight
// (measured 2026-09-24 on 9202: three nextcloud copies, ~0.9 GiB, outlived the hold and nothing named
// them). Removed here, and only here — never for a restore hold (R-379), never while an update moves the
// app. Pinned by TestR671_RestoreThatClearsAnUpdateHoldRemovesItsUndoCopies.
if m.undoCopyRemover != nil && (m.updatingCheck == nil || !m.updatingCheck(stackName)) {
n := m.undoCopyRemover(stackName)
m.logger.Printf("[INFO] [backup] %s: removed %d undo cop(y/ies) the lifted update hold had kept (R-671)", stackName, n)
}
}
// SetUndoCopyRemover wires the undo-copy cleanup (R-671): stacks.Manager.RemoveUndoCopies. INIT-ONLY, main.go —
// pinned by TestUndoCopyRemoverIsWiredAtStartup (an AST walk). The backup package cannot import stacks.
func (m *Manager) SetUndoCopyRemover(fn func(stackName string) int) {
m.undoCopyRemover = fn
}
// UpdateHeldStacks is the set of apps held stopped after a failed update (R-660, v0.268.0) — the