Files
felhom-controller/controller/internal/api/backup_snapshots_test.go
T
admin 78ff991f1c v0.153.0 — R-47: the DB replay no longer races the app, on BOTH restore paths
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.
2026-07-20 17:01:52 +02:00

149 lines
5.5 KiB
Go

package api
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// snapshotsStubProvider implements backup.StackDataProvider for the F1 endpoint tests: one known
// stack ("app") living on `hdd`. Everything else is inert.
type snapshotsStubProvider struct{ hdd string }
func (p *snapshotsStubProvider) GetStackComposePath(name string) (string, bool) {
if name == "app" {
return filepath.Join(p.hdd, "compose", "docker-compose.yml"), true
}
return "", false
}
func (p *snapshotsStubProvider) ListDeployedStacks() []backup.StackSummary { return nil }
func (p *snapshotsStubProvider) GetStackHDDMounts(string) []string { return nil }
func (p *snapshotsStubProvider) GetStackHDDPath(string) string { return p.hdd }
func (p *snapshotsStubProvider) GetDockerVolumes(string) []string { return nil }
func (p *snapshotsStubProvider) StopStack(string) error { return nil }
func (p *snapshotsStubProvider) StartStack(string) error { return nil }
func (p *snapshotsStubProvider) RefreshAndIsRunning(string) bool { return true }
func (p *snapshotsStubProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
return backup.RecoveryInfo{}, false
}
func (p *snapshotsStubProvider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
return nil, false
}
func (p *snapshotsStubProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *snapshotsStubProvider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
return nil
}
func (p *snapshotsStubProvider) StartStackServices(string, []string) error { return nil }
// newSnapshotsRouter wires a Router with a real backup.Manager over a tempdir drive.
func newSnapshotsRouter(t *testing.T) (*Router, string) {
t.Helper()
drive := filepath.Join(t.TempDir(), "drive")
cfg := &config.Config{}
cfg.Paths.SystemDataPath = filepath.Join(t.TempDir(), "sys")
mgr := backup.NewManager(cfg, nil, log.New(io.Discard, "", 0))
mgr.SetStackProvider(&snapshotsStubProvider{hdd: drive})
return &Router{cfg: cfg, backupMgr: mgr, logger: log.New(io.Discard, "", 0)}, drive
}
// getSnapshots dispatches through Router.ServeHTTP so the tests also prove the ROUTE is
// registered — the F1 bug was precisely a fetch to a route that did not exist.
func getSnapshots(t *testing.T, r *Router, rawQuery string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/backup/snapshots?"+rawQuery, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
type snapshotsResp struct {
OK bool `json:"ok"`
Error string `json:"error"`
Data []struct {
Time string `json:"time"`
ShortID string `json:"short_id"`
Tier int `json:"tier"`
DriveLabel string `json:"drive_label"`
} `json:"data"`
}
func decodeSnapshots(t *testing.T, rec *httptest.ResponseRecorder) snapshotsResp {
t.Helper()
var v snapshotsResp
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return v
}
// Scenario A (data half): a recovery unit on disk → 200 with EXACTLY ONE tier-1 "helyi" entry the
// restore panel JS can render and POST back.
func TestBackupSnapshots_UnitOnDisk(t *testing.T) {
r, drive := newSnapshotsRouter(t)
manifest := backup.RecoveryUnitManifestPath(drive, "app")
if err := os.MkdirAll(filepath.Dir(manifest), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(manifest, []byte("{}"), 0644); err != nil {
t.Fatal(err)
}
rec := getSnapshots(t, r, "stack=app")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
v := decodeSnapshots(t, rec)
if !v.OK || len(v.Data) != 1 {
t.Fatalf("want ok with exactly 1 entry, got %+v", v)
}
if v.Data[0].Tier != 1 || v.Data[0].ShortID != "helyi" || v.Data[0].Time == "" {
t.Errorf("entry = %+v (want tier 1, short_id helyi, non-empty time)", v.Data[0])
}
}
// Scenario B: known stack, no recovery unit yet → ok:true with an EMPTY data list (the JS shows
// "Nincs elérhető mentés" and keeps the button disabled — reached honestly, not via a 404).
func TestBackupSnapshots_NoBackupYet(t *testing.T) {
r, _ := newSnapshotsRouter(t)
rec := getSnapshots(t, r, "stack=app")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
v := decodeSnapshots(t, rec)
if !v.OK || len(v.Data) != 0 {
t.Errorf("want ok with empty list, got %+v", v)
}
}
// Scenario C: guards — traversal and empty names are 400 BEFORE any filesystem work; an unknown
// stack is 404; a Router without a backup manager is 400.
func TestBackupSnapshots_Guards(t *testing.T) {
r, _ := newSnapshotsRouter(t)
for _, q := range []string{"stack=../../etc", "stack=", "stack=a/b", "stack=.."} {
rec := getSnapshots(t, r, q)
if rec.Code != http.StatusBadRequest {
t.Errorf("%q: status = %d, want 400; body=%s", q, rec.Code, rec.Body.String())
}
}
rec := getSnapshots(t, r, "stack=ghost")
if rec.Code != http.StatusNotFound {
t.Errorf("unknown stack: status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
noMgr := &Router{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0)}
rec = getSnapshots(t, noMgr, "stack=app")
if rec.Code != http.StatusBadRequest {
t.Errorf("nil backupMgr: status = %d, want 400", rec.Code)
}
}