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

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:
2026-09-13 11:41:31 +02:00
parent 1552716722
commit 0d402f711d
25 changed files with 2709 additions and 123 deletions
+7 -4
View File
@@ -1429,12 +1429,15 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
// a disconnected destination, a pre-v2 layout — so an offer is never rendered for a
// copy the action would refuse. On any refusal the action is simply not offered; the
// row keeps rendering everything else it already showed.
if cov, covErr := s.backupMgr.Tier2RestoreCoverage(app.StackName); covErr == nil {
row.Tier2UnitRestorable = cov.CanRestoreUnit()
//
// Slice 4: the computation lives in backup.Tier2UnitRestorePoint, because the guarded
// update asks the same question and a second copy of a predicate drifts (R-203).
if rp, rpErr := s.backupMgr.Tier2UnitRestorePoint(app.StackName); rpErr == nil {
row.Tier2UnitRestorable = rp.Restorable
// R-403: the UNIT action names the PACKAGE's date, not the run's. After a
// preserved leg those are different dates and the run's is the flattering one.
pkgDate, stale := cov.UnitRestoreDate()
row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, cov.CopyLastSuccess != ""
pkgDate, stale := rp.CopyDate, rp.PackagePreserved
row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, rp.CopyDateProven
row.Tier2UnitConfirm = tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
if stale && pkgDate != "" {
row.Tier2UnitStaleNotice = fmt.Sprintf(tier2UnitStaleNoticeFmt, fmtRFC3339Local(pkgDate))