v0.237.0: the Update button takes a backup first, and tells the truth (update arc slice 4 — R-448, R-443, R-439)
gates / gates (push) Successful in 13s
gates / gates (push) Successful in 13s
POST /api/stacks/{name}/update is now a guarded job answering 202:
cheap refusals (hold — R-439, busy, migration, deploying, memory via the
deploy's own memoryVerdict, a fixed 2 GB disk floor, and no restorable
Tier-2 copy) → backup-first when the proven copy is older than
update.backup_max_age (24h) → safety dump BEFORE the pin moves → pin →
pull (failure puts the pin back) → up → health (.felhom.yml check or 60 s
settle, update.health_timeout 5m). Not healthy → the app is stopped and
HELD (RestoreHold reason update_failed, same store and gate as R-379) and
the page names the backup to restore from; the pin stays. Success is only
ever update_phase=done after health (R-443). UpdateStack is deleted.
The restorable-unit predicate is EXTRACTED to backup.Tier2UnitRestorePoint
and shared with the backups page (row pinned unchanged). The copy is aged
by the last successful Tier-2 copy, not the manifest created_at — measured
on demo-hp that created_at moves only on definition changes.
Crash safety: update-journal.json before each phase; RecoverUpdates before
the boot sweep, ResumeInterruptedUpdates after the guards are wired.
Three unattended start paths ignored a hold and now honour it: the
drive-return gate (restart + boot recreate) and the nightly volume dump.
The nightly capture and Tier-2 run skip held apps so the restore point
survives. No automatic rollback — measured per-app; route back = restore.
Tests A–H across stacks/backup/api/web/cmd; six red-proofs seen to fail.
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -476,9 +476,20 @@ func main() {
|
||||
// R-171: hand the boot sweep the settings it needs to answer "is this app's drive live?" BEFORE
|
||||
// the goroutine starts — an unwired gate is silently the pre-v0.190.0 behaviour that started apps
|
||||
// onto absent drives. TestMainWiresBootDriveGate walks this file's AST for the assignment.
|
||||
// --- Slice 4: guarded-update crash recovery (Scenario G) ---
|
||||
// BEFORE the boot reconciler, and the order is load-bearing: an update interrupted after `up` is
|
||||
// marked Updating here, and bootDriveGate refuses an Updating app — started after the sweep, the
|
||||
// sweep could bring up a half-updated app with no record of why. Pin-backs for updates interrupted
|
||||
// before anything ran happen here too. The resumed health wait itself is launched further down,
|
||||
// once the backup side is wired, because a resumed update that fails must be able to HOLD.
|
||||
if resumed := stackMgr.RecoverUpdates(); len(resumed) > 0 {
|
||||
logger.Printf("[WARN] [update] %d interrupted update(s) will resume after the backup side is wired: %v", len(resumed), resumed)
|
||||
}
|
||||
|
||||
bootDriveSettings = sett
|
||||
bootQuiesceLoop = quiesceLoop
|
||||
bootAppStopGuard = appStopGuard
|
||||
bootStackMgr = stackMgr
|
||||
go runBootReconcile(ctx, stackMgr, logger)
|
||||
|
||||
// --- Start CPU collector ---
|
||||
@@ -537,6 +548,16 @@ func main() {
|
||||
backupMgr.SetSharesReconciler(stackMgr.ReconcileSamba)
|
||||
}
|
||||
|
||||
// --- Slice 4: the guarded update's backup side ---
|
||||
// Wired unconditionally: with backup disabled the adapter answers "no restore point" and every
|
||||
// update is refused with the no-backup sentence — the precondition cannot be met, which is true.
|
||||
// An UNWIRED manager also refuses (fail closed); TestSlice4_UpdateGuardsAreWiredAtStartup walks
|
||||
// this file for the call, because a seam built and never wired has shipped here seven times.
|
||||
stackMgr.SetUpdateGuards(&updateGuardsAdapter{b: backupMgr, q: quiesceLoop})
|
||||
if n := stackMgr.ResumeInterruptedUpdates(ctx); n > 0 {
|
||||
logger.Printf("[WARN] [update] resumed %d interrupted update(s)", n)
|
||||
}
|
||||
|
||||
// SLICE 2: the offsite apply-bridge is launched further down, AFTER the self-updater is constructed
|
||||
// (R-71a: the bridge's settle-gate reads the updater's floor/update-running state to defer the
|
||||
// consume past a managed day-0 floor-update). See "offsite apply-bridge" below.
|
||||
@@ -1906,6 +1927,7 @@ var (
|
||||
bootDriveSettings *settings.Settings
|
||||
bootQuiesceLoop *quiesce.Loop
|
||||
bootAppStopGuard *backup.AppStopGuard
|
||||
bootStackMgr *stacks.Manager
|
||||
)
|
||||
|
||||
// bootDriveGate answers bootrecon.StartGate for the real controller. It enforces §8.2: an app that
|
||||
@@ -1943,6 +1965,11 @@ func (g bootDriveGate) MayStart(stackName string) (bool, string) {
|
||||
if bootQuiesceLoop.SuppressedStacks()[stackName] {
|
||||
return false, "a whole-guest backup (quiesce) is holding it — the quiesce loop restarts its own stacks"
|
||||
}
|
||||
// 1b. slice 4: a guarded update is moving this app (including one RecoverUpdates marked for
|
||||
// resumption). The update job owns the bring-up and ends in healthy or held.
|
||||
if bootStackMgr != nil && bootStackMgr.IsUpdating(stackName) {
|
||||
return false, "a guarded update is in progress — the update brings it up and verifies it"
|
||||
}
|
||||
// 2. an app-data operation in flight
|
||||
for _, held := range bootAppStopGuard.HeldStacks() {
|
||||
if held == stackName {
|
||||
@@ -1985,6 +2012,9 @@ func (g driveStartGate) MayStart(stackName string) (bool, string) {
|
||||
// app-stop guard's Recover reaches it directly. A hold only one path honours is not a hold.
|
||||
if g.sett != nil {
|
||||
if h, ok := g.sett.GetRestoreHold(stackName); ok {
|
||||
if h.Reason == settings.HoldReasonUpdateFailed {
|
||||
return false, "held after a failed update (" + h.At + ") — restore it from its backup to start it"
|
||||
}
|
||||
return false, "held after a failed restore whose rollback also failed (" + h.At + ") — clear the hold to start it"
|
||||
}
|
||||
}
|
||||
@@ -2236,7 +2266,11 @@ func scanDeployedAppRunStates(mgr *stacks.Manager, q *quiesce.Loop, g *backup.Ap
|
||||
// `g` covers the per-app operations — the nightly volume dump, an off-site reconstitution and a
|
||||
// .fab export. Both are nil-safe, and the union is taken here rather than inside classifyRunStates
|
||||
// so that pure function keeps its single `quiesced` parameter and its existing tests.
|
||||
return classifyRunStates(mgr.GetStacks(), unionSuppressed(q.SuppressedStacks(), g.SuppressedStacks()), q.FailedRestarts(), time.Now())
|
||||
// Slice 4: a THIRD mechanism moves an app on purpose — the guarded update recreates it and waits
|
||||
// for health, and it ends in healthy or HELD. Counting it as dead mid-update would be R-330's false
|
||||
// alarm one mechanism over.
|
||||
suppressed := unionSuppressed(unionSuppressed(q.SuppressedStacks(), g.SuppressedStacks()), mgr.UpdatingStacks())
|
||||
return classifyRunStates(mgr.GetStacks(), suppressed, q.FailedRestarts(), time.Now())
|
||||
}
|
||||
|
||||
// unionSuppressed merges the suppression sets of the two mechanisms that stop apps on purpose.
|
||||
@@ -3313,3 +3347,61 @@ func integrityOKMsg(res backup.IntegrityResult) string {
|
||||
}
|
||||
return msg + ")"
|
||||
}
|
||||
|
||||
// updateGuardsAdapter implements stacks.UpdateGuards over the backup manager (slice 4). The stacks
|
||||
// package cannot import backup, so this is the one place the two meet. Nil-safe on b: a box with
|
||||
// backup disabled has no restore point, and the update is refused for that true reason.
|
||||
type updateGuardsAdapter struct {
|
||||
b *backup.Manager
|
||||
q *quiesce.Loop
|
||||
}
|
||||
|
||||
func (a *updateGuardsAdapter) HoldFor(name string) (bool, string) {
|
||||
if a.b == nil {
|
||||
return false, ""
|
||||
}
|
||||
return a.b.RestoreHoldFor(name)
|
||||
}
|
||||
|
||||
func (a *updateGuardsAdapter) Busy(name string) (bool, string) {
|
||||
if a.q.SuppressedStacks()[name] {
|
||||
return true, "a whole-guest backup (quiesce) is holding it"
|
||||
}
|
||||
if a.b == nil {
|
||||
return false, ""
|
||||
}
|
||||
return a.b.UpdateBusy(name)
|
||||
}
|
||||
|
||||
func (a *updateGuardsAdapter) RestorePoint(name string) (stacks.UpdateRestorePoint, error) {
|
||||
if a.b == nil {
|
||||
return stacks.UpdateRestorePoint{}, fmt.Errorf("backup is not enabled on this box")
|
||||
}
|
||||
rp, err := a.b.Tier2UnitRestorePoint(name)
|
||||
if err != nil {
|
||||
return stacks.UpdateRestorePoint{}, err
|
||||
}
|
||||
at, proven := rp.ProvenCopyTime()
|
||||
return stacks.UpdateRestorePoint{Restorable: rp.Restorable, Proven: proven, ProvenAt: at}, nil
|
||||
}
|
||||
|
||||
func (a *updateGuardsAdapter) BackupNow(ctx context.Context, name string) error {
|
||||
if a.b == nil {
|
||||
return fmt.Errorf("backup is not enabled on this box")
|
||||
}
|
||||
return a.b.RunAppBackupNow(ctx, name)
|
||||
}
|
||||
|
||||
func (a *updateGuardsAdapter) SafetyDump(ctx context.Context, name string) ([]string, error) {
|
||||
if a.b == nil {
|
||||
return nil, fmt.Errorf("backup is not enabled on this box")
|
||||
}
|
||||
return a.b.WriteUpdateSafetyDump(ctx, name)
|
||||
}
|
||||
|
||||
func (a *updateGuardsAdapter) HoldAfterFailedUpdate(name string, at, provenCopyAt time.Time) 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user