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:
@@ -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))
|
||||
|
||||
@@ -219,6 +219,12 @@ func (s *Server) stopAppsOnPath(storagePath string) []string {
|
||||
// restartStacks starts each named stack (the gate-stopped set on drive return). Best-effort per app.
|
||||
func (s *Server) restartStacks(names []string) {
|
||||
for _, name := range names {
|
||||
// Slice 4 / R-379: the drive-return gate starts apps UNATTENDED, so it must honour a hold
|
||||
// exactly as the customer's button does. Until v0.237.0 it did not — a held app whose drive
|
||||
// blinked would have been started again. "A hold that only one path honours is not a hold."
|
||||
if s.appHeld(name) {
|
||||
continue
|
||||
}
|
||||
if err := s.stackMgr.StartStack(name); err != nil {
|
||||
s.logger.Printf("[WARN] [gate] restart %s: %v", name, err)
|
||||
}
|
||||
@@ -454,6 +460,9 @@ func (s *Server) processGuestBootChange() {
|
||||
}
|
||||
recreate := func(bs bootStack) {
|
||||
s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, bs.name, bs.state, bs.hdd)
|
||||
if s.appHeld(bs.name) {
|
||||
return
|
||||
}
|
||||
_ = s.stackMgr.StopStack(bs.name)
|
||||
if serr := s.stackMgr.StartStack(bs.name); serr != nil {
|
||||
s.logger.Printf("[WARN] [gate] boot recreate %s: %v", bs.name, serr)
|
||||
@@ -694,3 +703,16 @@ func (s *Server) notifyDriveReturned(path string, isTarget map[string]bool) {
|
||||
}
|
||||
s.notifier.NotifyStorageReconnected(label)
|
||||
}
|
||||
|
||||
// appHeld reports whether an app carries a hold (failed update or failed restore) and logs the skip.
|
||||
// Nil-safe: no backup manager means no hold store, so nothing is held.
|
||||
func (s *Server) appHeld(name string) bool {
|
||||
if s.backupMgr == nil {
|
||||
return false
|
||||
}
|
||||
held, _ := s.backupMgr.RestoreHoldFor(name)
|
||||
if held {
|
||||
s.logger.Printf("[WARN] [gate] NOT starting %s — the app is HELD (a failed update or restore); a person releases it", name)
|
||||
}
|
||||
return held
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Slice 4 — the predicate moved from this package into backup.Tier2UnitRestorePoint. The page must
|
||||
// render IDENTICALLY: the row's four unit-restore fields are compared against the coverage computed
|
||||
// the old way (the inline expression that used to live in buildAppBackupRows).
|
||||
func TestSlice4_BackupRowUnitFieldsAreUnchangedByTheExtraction(t *testing.T) {
|
||||
s, sett, m := newOffboxWebServer(t)
|
||||
dest := t.TempDir()
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: dest, Label: "flash"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{
|
||||
Enabled: true, Method: "rsync", DestinationPath: dest,
|
||||
LastRun: "2026-09-13T01:30:00Z", LastStatus: "ok", LastSuccess: "2026-09-13T01:30:00Z", SuccessTracked: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unit := filepath.Join(dest, "backups", "secondary", "app", "recovery-unit")
|
||||
if err := os.MkdirAll(unit, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(unit, "manifest.json"), []byte(`{"schema_version":2,"app_name":"app","created_at":"2026-09-12T02:15:29Z"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dest, "backups", "secondary", "app", ".felhom-tier2-layout"), []byte("2"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cov, err := m.Tier2RestoreCoverage("app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDate, wantStale := cov.UnitRestoreDate()
|
||||
rows := s.buildAppBackupRows(&backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{{StackName: "app", DisplayName: "App"}}})
|
||||
row := findRow(rows, "app")
|
||||
if row == nil {
|
||||
t.Fatal("no row")
|
||||
}
|
||||
if !cov.CanRestoreUnit() {
|
||||
t.Fatal("fixture: the copy must hold an openable unit")
|
||||
}
|
||||
if row.Tier2UnitRestorable != cov.CanRestoreUnit() || row.Tier2CopyDate != wantDate ||
|
||||
row.Tier2CopyDateProven != (cov.CopyLastSuccess != "") ||
|
||||
row.Tier2UnitConfirm != tier2UnitConfirmWithStaleness(wantDate, cov.CopyLastSuccess != "", wantStale) {
|
||||
t.Errorf("the row changed: restorable=%v date=%q proven=%v confirm=%q", row.Tier2UnitRestorable, row.Tier2CopyDate, row.Tier2CopyDateProven, row.Tier2UnitConfirm)
|
||||
}
|
||||
}
|
||||
|
||||
// The drive-return gate starts apps UNATTENDED. A held app must be skipped.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): delete the appHeld check from restartStacks. The server has NO stack
|
||||
// manager here on purpose, so the unguarded StartStack call panics and this test fails.
|
||||
func TestSlice4_DriveReturnGateSkipsAHeldApp(t *testing.T) {
|
||||
s, _, m := newOffboxWebServer(t)
|
||||
if err := m.HoldAfterFailedUpdate("held", time.Now(), time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("the drive-return gate tried to START a held app: %v", r)
|
||||
}
|
||||
}()
|
||||
s.restartStacks([]string{"held"})
|
||||
}
|
||||
Reference in New Issue
Block a user