0d402f711d
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
75 lines
2.9 KiB
Go
75 lines
2.9 KiB
Go
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"})
|
|
}
|