controller v0.268.0: the undo finds volumes by definition; a held app names only a whole copy; one press = one tested step (R-658, R-659, R-660, R-651; 09 §6.4 part 5)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
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:
@@ -608,3 +608,132 @@ func (m *Manager) clearUpdateHoldAfterRestore(stackName string) {
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] %s: restore completed — the update hold (set %s) is CLEARED", stackName, h.At)
|
||||
}
|
||||
|
||||
// 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
|
||||
// same app (chaos rounds 8 and 11, 2026-09-23 night). A RESTORE hold (R-379) is not in the set: it
|
||||
// has no event of its own, so the app-down alarm stays its only voice. Nil-safe.
|
||||
func (m *Manager) UpdateHeldStacks() map[string]bool {
|
||||
if m == nil || m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
var out map[string]bool
|
||||
for _, h := range m.settings.ListRestoreHolds() {
|
||||
if h.Reason != settings.HoldReasonUpdateFailed {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]bool{}
|
||||
}
|
||||
out[h.Stack] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── R-659 (v0.268.0): the hold names only a copy that can bring the app back WHOLE ────────────────
|
||||
//
|
||||
// MEASURED 2026-09-24 00:00 on 9202 (chaos round 11): nextcloud's update and its undo both failed; the
|
||||
// hold named „saját meghajtó" (the precondition copy — decision 8 lets an update lean on any tier);
|
||||
// the household pressed exactly that restore and was REFUSED, because the unit holds no copy of the
|
||||
// app's files on the drive (R-538). The box had no other copy, so nothing on any page brought the app
|
||||
// back. Operator ruling 2026-09-24 (`09` §3 decision 25, option A): the hold names only a copy that
|
||||
// brings the app back whole; with none, it says so, says support is informed, and support is told.
|
||||
//
|
||||
// THE TRUTH TABLE, read from the restores' OWN refusals (measured from source, v0.267.0), not from
|
||||
// what each tier stores:
|
||||
//
|
||||
// app own unit (1) second drive (2) off-site (3)
|
||||
// no declared drive files whole (unit restore) whole („Teljes visszaállítás") whole (full restore)
|
||||
// declared drive files NOT — refused (R-538) NOT — its unit restore is refused whole („Teljes
|
||||
// (DeclaredDriveFileLegs) by the same guard; its file restore visszaállítás (fájlok
|
||||
// only ADDS missing files, no database + adatbázis)")
|
||||
//
|
||||
// So the question is asked of the SAME predicate the refusal uses (DeclaredDriveFileLegs), and a test
|
||||
// pins that the two cannot drift (TestR659_TruthTableAgreesWithTheRestoresRefusal). Tier 2 holds a
|
||||
// file app's files AND its unit, but no single action brings the app back whole from it — R-661.
|
||||
|
||||
// WholeOnTier reports whether a copy on `tier` can bring this app back WHOLE through the restore the
|
||||
// Mentések page offers for that tier.
|
||||
func (m *Manager) WholeOnTier(stackName string, tier int) bool {
|
||||
switch tier {
|
||||
case UpdateTierOffsite:
|
||||
return true
|
||||
case UpdateTierLocal, UpdateTierSecondDrive:
|
||||
return !m.HasDriveFileLegs(stackName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HoldCopies walks EVERY tier (not only until the first acceptable one, as the update does) and
|
||||
// returns the newest copy that brings the app back whole, whether there is one, and every copy seen.
|
||||
func (m *Manager) HoldCopies(ctx context.Context, stackName string) (UpdateTierPoint, bool, []UpdateTierPoint) {
|
||||
var seen []UpdateTierPoint
|
||||
var best UpdateTierPoint
|
||||
found := false
|
||||
for _, tier := range []int{UpdateTierSecondDrive, UpdateTierLocal, UpdateTierOffsite} {
|
||||
p, ok := m.updateTierPoint(ctx, stackName, tier)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seen = append(seen, p)
|
||||
if m.WholeOnTier(stackName, tier) && (!found || p.At.After(best.At)) {
|
||||
best, found = p, true
|
||||
}
|
||||
}
|
||||
return best, found, seen
|
||||
}
|
||||
|
||||
// HoldAfterFailedUpdateWhole records the update hold naming the newest WHOLE copy, or — with none —
|
||||
// a hold that names nothing and says support is informed (NoWholeCopy). The copies seen are recorded
|
||||
// either way. Returns whether no whole copy exists.
|
||||
func (m *Manager) HoldAfterFailedUpdateWhole(ctx context.Context, stackName string, at time.Time, undoState string) (bool, error) {
|
||||
best, found, seen := m.HoldCopies(ctx, stackName)
|
||||
var seenS []string
|
||||
for _, p := range seen {
|
||||
seenS = append(seenS, fmt.Sprintf("tier %d at %s", p.Tier, p.At.UTC().Format(time.RFC3339)))
|
||||
}
|
||||
if !found {
|
||||
if m == nil || m.settings == nil {
|
||||
return true, fmt.Errorf("no settings wired — the update hold for %s cannot be persisted", stackName)
|
||||
}
|
||||
h := settings.RestoreHold{Stack: stackName, At: at.UTC().Format(time.RFC3339), Reason: settings.HoldReasonUpdateFailed,
|
||||
UndoState: undoState, NoWholeCopy: true, CopiesSeen: seenS}
|
||||
if err := m.settings.SetRestoreHold(h); err != nil {
|
||||
return true, fmt.Errorf("persisting the update hold for %s: %w", stackName, err)
|
||||
}
|
||||
m.logger.Printf("[ERROR] [backup] %s is HELD STOPPED after a failed update and NO copy on this box brings it back whole (seen: %v; drive files declared: %v; undo: %q) — support must act (R-659)",
|
||||
stackName, seenS, m.HasDriveFileLegs(stackName), undoState)
|
||||
return true, nil
|
||||
}
|
||||
if err := m.HoldAfterFailedUpdateHolding(stackName, at, best.At, best.Tier, m.UpdateCopyHolds(stackName, best.Tier), undoState); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if h, ok := m.settings.GetRestoreHold(stackName); ok {
|
||||
h.CopiesSeen = seenS
|
||||
_ = m.settings.SetRestoreHold(h)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// HoldNoWholeCopy reports whether the app's hold names no copy (R-659) — the page then offers no
|
||||
// restore button for it.
|
||||
func (m *Manager) HoldNoWholeCopy(stackName string) bool {
|
||||
if m == nil || m.settings == nil {
|
||||
return false
|
||||
}
|
||||
h, ok := m.settings.GetRestoreHold(stackName)
|
||||
return ok && h.Reason == settings.HoldReasonUpdateFailed && h.NoWholeCopy
|
||||
}
|
||||
|
||||
// UpdateHold returns the stored update hold, for the operator event (R-659).
|
||||
func (m *Manager) UpdateHold(stackName string) (settings.RestoreHold, bool) {
|
||||
if m == nil || m.settings == nil {
|
||||
return settings.RestoreHold{}, false
|
||||
}
|
||||
h, ok := m.settings.GetRestoreHold(stackName)
|
||||
if !ok || h.Reason != settings.HoldReasonUpdateFailed {
|
||||
return settings.RestoreHold{}, false
|
||||
}
|
||||
return h, true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user