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
@@ -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"})
}