Traefik v3.7.17 (GHSA-xv5x-242w-7hf7); R-1347 a restore forgets the update-held flag; R-1348 a failed put-back at start-up mails; R-1339, R-1351, R-1323 truthful start-up lines and one lock over FileBrowser's compose. Commit only.
gates / gates (push) Successful in 29s
gates / gates (push) Successful in 29s
This commit is contained in:
1 parent
a90b62323f
commit
c4b6844d34
19 files changed
+569
-9
No files matched your search
@@ -137,6 +137,10 @@ type AppStopGuard struct {
|
||||
// whether the drive a copy lives on is bound. nil = the pre-R-1251 behaviour.
|
||||
holds AppStopHolds
|
||||
undoReachable func(stack, dir string) bool
|
||||
// heldNotify and heldPending (R-1348, appstop_putback_wait.go): the operator mail for a failed put-back. Recover runs
|
||||
// before the notifier exists, so a hold written then waits in heldPending until SetHeldNotify.
|
||||
heldNotify func(stack string)
|
||||
heldPending []string
|
||||
|
||||
// liveOp (R-1253) is the operation of THIS process that holds the marker now (set by Begin, cleared by End). The
|
||||
// marker has one slot; BeginExclusive refuses to replace a live one. A marker left by a crash has no live op.
|
||||
|
||||
@@ -126,9 +126,42 @@ func (g *AppStopGuard) holdPutBackFailed(stack, dir string, cause error) bool {
|
||||
return false
|
||||
}
|
||||
g.logger.Printf("[ERROR] [appstop] %s: the interrupted restore could NOT be put back (%v) — the app is HELD for support, the copy stays at %s (R-1251 H2)", stack, cause, dir)
|
||||
g.notifyHeld(stack)
|
||||
return true
|
||||
}
|
||||
|
||||
// SetHeldNotify wires the operator mail for a failed put-back (R-1348; main.go: the R-1340 sentence from the hold record)
|
||||
// and hands over what Recover held before the notifier existed. Each held app is mailed once. Pinned by TestR1348_* and
|
||||
// TestR1348_HeldNotifyIsWiredAtStartup.
|
||||
func (g *AppStopGuard) SetHeldNotify(fn func(stack string)) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.liveMu.Lock()
|
||||
g.heldNotify = fn
|
||||
waiting := g.heldPending
|
||||
g.heldPending = nil
|
||||
g.liveMu.Unlock()
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
for _, stack := range waiting {
|
||||
fn(stack)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *AppStopGuard) notifyHeld(stack string) {
|
||||
g.liveMu.Lock()
|
||||
fn := g.heldNotify
|
||||
if fn == nil {
|
||||
g.heldPending = append(g.heldPending, stack)
|
||||
}
|
||||
g.liveMu.Unlock()
|
||||
if fn != nil {
|
||||
fn(stack)
|
||||
}
|
||||
}
|
||||
|
||||
// clearPutBackPending lifts this app's waiting-put-back hold, and only that kind.
|
||||
func (g *AppStopGuard) clearPutBackPending(stack string) {
|
||||
if g.holds == nil {
|
||||
|
||||
@@ -219,6 +219,9 @@ type Manager struct {
|
||||
// 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
|
||||
// updateHeldForgetter (R-1347) forgets the stack manager's „last update ended held" flag when a restore lifts the update
|
||||
// hold — stacks.Manager.ForgetUpdateHeld, wired in main.go (SetUpdateHeldForgetter). nil-safe.
|
||||
updateHeldForgetter func(stackName string) bool
|
||||
|
||||
// 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,53 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-1347: a restore that lifts an UPDATE hold must also forget the stack manager's in-memory „last update ended held"
|
||||
// flag. Left set, the crash-loop watcher does not watch the app and the night leg skips it until the controller restarts
|
||||
// (D64's sibling: the operator's clear_hold already forgets it, R-1343; the restore did not).
|
||||
//
|
||||
// The consequence on the real flag — ObserveUnhealthy and legCandidate — is TestR1347_RestoreLiftsTheWatchersBlindSpot
|
||||
// (cmd/controller), and the seam's wiring TestR1347_UpdateHeldForgetterIsWiredAtStartup.
|
||||
//
|
||||
// RED-PROOF (2026-11-02): seen failing before the seam existed — the build failed on SetUpdateHeldForgetter; with the
|
||||
// call dropped from clearUpdateHoldAfterRestore it fails on „the flag was not forgotten".
|
||||
func TestR1347_ARestoreThatLiftsAnUpdateHoldForgetsTheHeldFlag(t *testing.T) {
|
||||
sett := slice4Settings(t)
|
||||
m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
|
||||
forgot := map[string]int{}
|
||||
m.SetUpdateHeldForgetter(func(s string) bool { forgot[s]++; return true })
|
||||
|
||||
_ = m.HoldAfterFailedUpdate("nextcloud", time.Now(), time.Now(), UpdateTierLocal)
|
||||
m.clearUpdateHoldAfterRestore("nextcloud")
|
||||
if _, held := sett.GetRestoreHold("nextcloud"); held {
|
||||
t.Fatal("the update hold must be lifted")
|
||||
}
|
||||
if forgot["nextcloud"] != 1 {
|
||||
t.Fatalf("the restore lifted the update hold, but the flag was not forgotten (calls: %v)", forgot)
|
||||
}
|
||||
|
||||
// A RESTORE hold (R-379) stays, so nothing about it is forgotten.
|
||||
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "rst", At: "2026-08-22T14:00:00Z"})
|
||||
m.clearUpdateHoldAfterRestore("rst")
|
||||
if _, held := sett.GetRestoreHold("rst"); !held || forgot["rst"] != 0 {
|
||||
t.Fatalf("a restore hold is not lifted by a restore (held %v, forget calls %d)", held, forgot["rst"])
|
||||
}
|
||||
|
||||
// No hold at all: nothing to forget (a plain restore of a healthy app).
|
||||
m.clearUpdateHoldAfterRestore("plain")
|
||||
if forgot["plain"] != 0 {
|
||||
t.Fatalf("no hold was lifted, yet the flag was touched %d time(s)", forgot["plain"])
|
||||
}
|
||||
|
||||
// nil seam: no crash (the pre-R-1347 behaviour).
|
||||
m2 := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
|
||||
_ = m2.HoldAfterFailedUpdate("other", time.Now(), time.Now(), UpdateTierLocal)
|
||||
m2.clearUpdateHoldAfterRestore("other")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-1348: the restore_mixed hold written at START-UP after an interrupted put-back fails must reach the operator as the
|
||||
// R-1340 mail. Recover runs long before the notifier exists (main.go), so the guard keeps the name and hands it over when
|
||||
// SetHeldNotify is wired; a failure after that (a drive that returned at run time) goes out at once.
|
||||
//
|
||||
// RED-PROOF (2026-11-02): seen failing before the seam existed (build: SetHeldNotify); with the call dropped from
|
||||
// holdPutBackFailed these fail on „no mail for a held app".
|
||||
func TestR1348_AFailedPutBackAtStartupMailsOnceTheNotifierIsWired(t *testing.T) {
|
||||
r := newR1251Rig(t)
|
||||
r.putErr = errors.New("compose down: timeout")
|
||||
g := r.guard()
|
||||
if res := g.Recover(); res == nil || len(res.Held) != 1 {
|
||||
t.Fatalf("recovery must hold the app: %+v", res)
|
||||
}
|
||||
var mailed []string
|
||||
g.SetHeldNotify(func(stack string) { mailed = append(mailed, stack) })
|
||||
if len(mailed) != 1 || mailed[0] != "immich" {
|
||||
t.Fatalf("no mail for a held app: the hold from Recover must be handed over when the notifier is wired, got %v", mailed)
|
||||
}
|
||||
h, ok := r.sett.GetRestoreHold("immich")
|
||||
if !ok || h.Reason != settings.HoldReasonRestoreMixed || h.MixedCause != settings.MixedCauseInterruptedPutBack ||
|
||||
!strings.Contains(h.FailedStep, "compose down: timeout") {
|
||||
t.Fatalf("the mail reads this record, and it is wrong: %+v", h)
|
||||
}
|
||||
g.SetHeldNotify(func(stack string) { mailed = append(mailed, "again:"+stack) })
|
||||
if len(mailed) != 1 {
|
||||
t.Fatalf("a held app is mailed once, not at every wiring: %v", mailed)
|
||||
}
|
||||
}
|
||||
|
||||
// A clean put-back, or one still waiting for its drive, is no failure: no mail.
|
||||
func TestR1348_NoMailWithoutAFailedPutBack(t *testing.T) {
|
||||
r := newR1251Rig(t)
|
||||
g := r.guard()
|
||||
g.Recover()
|
||||
var mailed []string
|
||||
g.SetHeldNotify(func(stack string) { mailed = append(mailed, stack) })
|
||||
if len(mailed) != 0 {
|
||||
t.Fatalf("a put-back that worked mailed: %v", mailed)
|
||||
}
|
||||
|
||||
w := newR1251Rig(t)
|
||||
w.unbind(t)
|
||||
gw := w.guard()
|
||||
gw.Recover()
|
||||
gw.SetHeldNotify(func(stack string) { mailed = append(mailed, stack) })
|
||||
if len(mailed) != 0 {
|
||||
t.Fatalf("a put-back that waits for its drive has its own hold and reminder, not this mail: %v", mailed)
|
||||
}
|
||||
}
|
||||
|
||||
// After the wiring a failed put-back (the drive returned at run time: ResumePutBack) mails at once.
|
||||
func TestR1348_AFailedResumeMailsAtOnce(t *testing.T) {
|
||||
r := newR1251Rig(t)
|
||||
r.unbind(t)
|
||||
g := r.guard()
|
||||
g.Recover() // waiting for its drive
|
||||
var mailed []string
|
||||
g.SetHeldNotify(func(stack string) { mailed = append(mailed, stack) })
|
||||
r.bind(t)
|
||||
r.putErr = errors.New("tar xf failed")
|
||||
g.ResumePutBack("immich")
|
||||
if len(mailed) != 1 || mailed[0] != "immich" {
|
||||
t.Fatalf("a put-back that failed on resume must mail: %v", mailed)
|
||||
}
|
||||
}
|
||||
@@ -674,6 +674,12 @@ 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-1347: the hold store and the stack manager's in-memory flag go together, as in the operator's clear (D64 = B, R-1343).
|
||||
// Left set, the crash-loop watcher would not watch the restored app and the night leg would skip it until the next
|
||||
// controller restart. A running update is not touched (ForgetUpdateHeld refuses it). Pinned by TestR1347_*.
|
||||
if m.updateHeldForgetter != nil && m.updateHeldForgetter(stackName) {
|
||||
m.logger.Printf("[INFO] [backup] %s: the restored app is watched and updated again — its update-held flag is forgotten (R-1347)", stackName)
|
||||
}
|
||||
// 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
|
||||
@@ -691,6 +697,12 @@ func (m *Manager) SetUndoCopyRemover(fn func(stackName string) int) {
|
||||
m.undoCopyRemover = fn
|
||||
}
|
||||
|
||||
// SetUpdateHeldForgetter wires R-1347's seam: stacks.Manager.ForgetUpdateHeld. INIT-ONLY, main.go — pinned by
|
||||
// TestR1347_UpdateHeldForgetterIsWiredAtStartup (an AST walk). The backup package cannot import stacks.
|
||||
func (m *Manager) SetUpdateHeldForgetter(fn func(stackName string) bool) {
|
||||
m.updateHeldForgetter = fn
|
||||
}
|
||||
|
||||
// UpdateHeldStacks is the set of apps held stopped after a failed update (R-660, v0.268.0) — the
|
||||
// FOURTH way the product stops an app on purpose, and until v0.268.0 the one `classifyRunStates` did
|
||||
// not know: each hold's `app_update_held` was followed ~11 s later by an `app_start_failed` for the
|
||||
|
||||
Reference in new issue
Block a user