78ff991f1c
Closes R-47. No new agent coupling — MinAgent stays 0.90.0. The replay needs a running DB container, so both restore paths started the WHOLE stack first, giving the application a window to rebuild the very schema objects the dump was about to create. Measured live on 2026-07-19 (H4, DIAG-immich-restore-round2): immich-server rebuilt clip_index two seconds before the dump's CREATE INDEX, the replay aborted "already exists" under ON_ERROR_STOP=1, and immich reported schema drift. The data survived only because pg_dump emits COPY before CREATE INDEX. Both paths now open a DB-ONLY window: only the stack's database service(s) come up, the dump is replayed with the app still down, and the full start runs only after the replay exits 0. Fail-closed: a dump with no identifiable DB service refuses BEFORE the first mutation. Every exit from the window still does a best-effort full start, so a failed restore never leaves a box with a database and no application. New: appbackup.DBServiceNames (yaml.v3 services-map parse — never a line scan; immich's top-level volume keys are the decoy) sharing dbTypeForImage with DiscoverDatabases; stacks.Manager.StartStackServices (refuses an empty list — argument-less `up -d` is a full start); RedeployFromEnv split into PersistUnitRedeployConfig + its unchanged tail. StackDataProvider's RecreateStackFromUnit becomes RecreateStackDefinitionFromUnit — the hidden `up -d` inside the old name is what carried the defect on the local path. 19 new tests (ordering plus state-at-replay-time, zero-mutation fail-closed effects, replay-failure bring-up, parser decoys, empty-list refusal); three companion red-proofs run and reverted. 23/23 packages green. Not yet live-validated: STOP-1 supervised reconstitute, golden 0.153.0.
169 lines
6.7 KiB
Go
169 lines
6.7 KiB
Go
package web
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// blockProvider is a StackDataProvider whose StopStack blocks on a channel — so a Tier-2 restore
|
|
// parks mid-flight while the test asserts the HANDLER already returned (async) and that a concurrent
|
|
// POST is refused without launching a second restore.
|
|
type blockProvider struct {
|
|
hdd string
|
|
release chan struct{}
|
|
stops int32 // atomic: number of StopStack calls == number of restores that actually launched
|
|
}
|
|
|
|
func (p *blockProvider) GetStackComposePath(string) (string, bool) { return "", false }
|
|
func (p *blockProvider) ListDeployedStacks() []backup.StackSummary { return nil }
|
|
func (p *blockProvider) GetStackHDDMounts(string) []string { return nil }
|
|
func (p *blockProvider) GetStackHDDPath(string) string { return p.hdd }
|
|
func (p *blockProvider) GetDockerVolumes(string) []string { return nil }
|
|
func (p *blockProvider) StopStack(string) error {
|
|
atomic.AddInt32(&p.stops, 1)
|
|
<-p.release // park here until the test releases it
|
|
return nil
|
|
}
|
|
func (p *blockProvider) StartStack(string) error { return nil }
|
|
func (p *blockProvider) RefreshAndIsRunning(string) bool { return true }
|
|
func (p *blockProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
|
|
return backup.RecoveryInfo{}, false
|
|
}
|
|
func (p *blockProvider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
|
|
return nil, false
|
|
}
|
|
func (p *blockProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
|
func (p *blockProvider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
|
|
return nil
|
|
}
|
|
func (p *blockProvider) StartStackServices(string, []string) error { return nil }
|
|
|
|
func newAsyncRestoreServer(t *testing.T) (*Server, *blockProvider, *backup.Manager) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
live := filepath.Join(tmp, "usb")
|
|
dest := filepath.Join(tmp, "flash")
|
|
lg := log.New(io.Discard, "", 0)
|
|
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []string{live, dest} {
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: filepath.Base(p)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{
|
|
Enabled: true, Method: "rsync", DestinationPath: dest, LastRun: "2026-07-06T03:30:00Z", LastStatus: "ok",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// a recorded v2 Tier-2 copy (marker + an hdd/ leg) so RestoreTier2Files proceeds to StopStack.
|
|
destBase := filepath.Join(dest, "backups", "secondary", "app")
|
|
if err := os.MkdirAll(filepath.Join(destBase, "hdd", "appdata"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(destBase, ".felhom-tier2-layout"), []byte("2"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = tmp
|
|
m := backup.NewManager(cfg, sett, lg)
|
|
prov := &blockProvider{hdd: live, release: make(chan struct{})}
|
|
m.SetStackProvider(prov)
|
|
s := &Server{cfg: cfg, backupMgr: m, logger: lg}
|
|
return s, prov, m
|
|
}
|
|
|
|
func postTier2(s *Server) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader("stack_name=app"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
s.backupTier2RestoreHandler(w, req)
|
|
return w
|
|
}
|
|
|
|
func waitFor(t *testing.T, cond func() bool, msg string) {
|
|
t.Helper()
|
|
// Budget past waitForHealthy's 3s post-restore settling sleep (+ copier).
|
|
for i := 0; i < 700; i++ {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timeout waiting for: %s", msg)
|
|
}
|
|
|
|
// B1 — the restore handler returns INSTANTLY (302) while the restore runs in the background, and the
|
|
// op-status transitions running → terminal. RED-PROOF: on the pre-fix synchronous handler (calling
|
|
// RestoreTier2Files inline) this POST blocks in StopStack until the channel is released, so the
|
|
// sub-500ms response assertion FAILS.
|
|
func TestBackupTier2Restore_AsyncReturnsInstantly(t *testing.T) {
|
|
s, prov, m := newAsyncRestoreServer(t)
|
|
|
|
start := time.Now()
|
|
w := postTier2(s)
|
|
elapsed := time.Since(start)
|
|
|
|
if elapsed > 500*time.Millisecond {
|
|
t.Fatalf("handler blocked %v — restore is not async (the F4 shape)", elapsed)
|
|
}
|
|
if w.Code != http.StatusFound {
|
|
t.Fatalf("want 302, got %d", w.Code)
|
|
}
|
|
if loc := w.Header().Get("Location"); !strings.Contains(loc, "elindult") {
|
|
t.Fatalf("redirect should carry the 'elindult' flash; got %q", loc)
|
|
}
|
|
// the background restore is now parked in StopStack → op-status shows running.
|
|
waitFor(t, func() bool { return m.RestoreStatus().Running }, "restore op running")
|
|
st := m.RestoreStatus()
|
|
if st.Op != "tier2-restore" || st.Stack != "app" {
|
|
t.Fatalf("running status = %+v", st)
|
|
}
|
|
// release → the restore completes → terminal status.
|
|
close(prov.release)
|
|
waitFor(t, func() bool { return !m.RestoreStatus().Running && m.RestoreStatus().Last != nil }, "restore terminal")
|
|
if got := atomic.LoadInt32(&prov.stops); got != 1 {
|
|
t.Fatalf("StopStack called %d times, want exactly 1", got)
|
|
}
|
|
}
|
|
|
|
// B2 — a concurrent restore POST while one runs is REFUSED (fast-path IsRunning) and does NOT launch a
|
|
// second restore. RED-PROOF: removing the IsRunning() fast-path lets the second POST launch a second
|
|
// goroutine → StopStack count becomes 2.
|
|
func TestBackupTier2Restore_DoubleClickRefused(t *testing.T) {
|
|
s, prov, m := newAsyncRestoreServer(t)
|
|
|
|
// first restore → parks in StopStack (acquires the single-flight running flag).
|
|
_ = postTier2(s)
|
|
waitFor(t, func() bool { return m.IsRunning() }, "first restore holding the single-flight lock")
|
|
|
|
// second restore while the first runs → refused with the "már fut" flash, no second launch.
|
|
w2 := postTier2(s)
|
|
if loc := w2.Header().Get("Location"); !strings.Contains(loc, "m%C3%A1r+fut") && !strings.Contains(loc, "már fut") {
|
|
t.Fatalf("second POST should be refused with 'már fut'; got %q", loc)
|
|
}
|
|
// IsRunning flips before the goroutine reaches StopStack — wait for the FIRST stop to land, so
|
|
// the ==1 assertion below measures "no second launch", not goroutine scheduling (parallel-load flake).
|
|
waitFor(t, func() bool { return atomic.LoadInt32(&prov.stops) >= 1 }, "first restore reached StopStack")
|
|
if got := atomic.LoadInt32(&prov.stops); got != 1 {
|
|
t.Fatalf("double-click launched a second restore: StopStack count = %d, want 1", got)
|
|
}
|
|
|
|
close(prov.release)
|
|
waitFor(t, func() bool { return !m.IsRunning() }, "first restore done")
|
|
}
|