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.
This commit is contained in:
@@ -100,6 +100,12 @@ func ParseComposeImages(composePath string) []string {
|
||||
return appbackup.ParseComposeImages(composePath)
|
||||
}
|
||||
|
||||
// DBServiceNames forwards to appbackup.DBServiceNames — the compose SERVICE names holding a database,
|
||||
// i.e. the argument list for the DB-only bring-up both restore paths use before a dump replay (R-47).
|
||||
func DBServiceNames(composePath string) ([]string, error) {
|
||||
return appbackup.DBServiceNames(composePath)
|
||||
}
|
||||
|
||||
// humanizeBytes forwards to appbackup.HumanizeBytes; kept unexported so the
|
||||
// many in-package call sites (backup.go, crossdrive.go, restore code) need no edit.
|
||||
func humanizeBytes(b int64) string {
|
||||
|
||||
@@ -25,17 +25,22 @@ type offbox3aProvider struct {
|
||||
has map[string]bool
|
||||
}
|
||||
|
||||
func (p *offbox3aProvider) GetStackComposePath(string) (string, bool) { return "", false }
|
||||
func (p *offbox3aProvider) ListDeployedStacks() []StackSummary { return nil }
|
||||
func (p *offbox3aProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *offbox3aProvider) GetStackHDDPath(n string) string { return p.hdd[n] }
|
||||
func (p *offbox3aProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *offbox3aProvider) StopStack(string) error { return nil }
|
||||
func (p *offbox3aProvider) StartStack(string) error { return nil }
|
||||
func (p *offbox3aProvider) RefreshAndIsRunning(string) bool { return false }
|
||||
func (p *offbox3aProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) { return RecoveryInfo{}, false }
|
||||
func (p *offbox3aProvider) GetStackComposePath(string) (string, bool) { return "", false }
|
||||
func (p *offbox3aProvider) ListDeployedStacks() []StackSummary { return nil }
|
||||
func (p *offbox3aProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *offbox3aProvider) GetStackHDDPath(n string) string { return p.hdd[n] }
|
||||
func (p *offbox3aProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *offbox3aProvider) StopStack(string) error { return nil }
|
||||
func (p *offbox3aProvider) StartStack(string) error { return nil }
|
||||
func (p *offbox3aProvider) RefreshAndIsRunning(string) bool { return false }
|
||||
func (p *offbox3aProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
||||
return RecoveryInfo{}, false
|
||||
}
|
||||
func (p *offbox3aProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
||||
func (p *offbox3aProvider) RecreateStackFromUnit(_, _ string, _ map[string]string) error { return nil }
|
||||
func (p *offbox3aProvider) RecreateStackDefinitionFromUnit(_, _ string, _ map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (p *offbox3aProvider) StartStackServices(string, []string) error { return nil }
|
||||
func (p *offbox3aProvider) GetStackClassifiedBinds(n string) ([]ClassifiedBind, bool) {
|
||||
return p.binds[n], p.has[n]
|
||||
}
|
||||
|
||||
@@ -239,6 +239,20 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string) (Of
|
||||
res.Skewed = res.OffsiteRunID == ""
|
||||
res.LooksEmpty = m.sniffScratchDump(scratchDumpDir, stack)
|
||||
|
||||
// --- WHICH SERVICE HOLDS THE DATABASE (R-47) ------------------------------------------------
|
||||
// Read from the LIVE compose, not the scratch one: reconstitution never overwrites the stack dir,
|
||||
// so the live file is what `docker compose up` will actually act on. Resolved BEFORE the first
|
||||
// mutation so the refusal below costs nothing.
|
||||
var dbServices []string
|
||||
if composePath, cOK := m.stackProvider.GetStackComposePath(stack); cOK && composePath != "" {
|
||||
svcs, dsErr := DBServiceNames(composePath)
|
||||
if dsErr != nil {
|
||||
// "cannot tell" is not "no database" — leave dbServices empty and let the gate refuse.
|
||||
m.logger.Printf("[WARN] [offbox] %s: could not read the live compose services: %v", stack, dsErr)
|
||||
}
|
||||
dbServices = svcs
|
||||
}
|
||||
|
||||
// --- THE UNDO, BEFORE THE ACT ---------------------------------------------------------------
|
||||
// Taken while the stack is still UP (a stopped database cannot be dumped) and before a single
|
||||
// byte is overwritten, so a failure here aborts with the live app completely untouched.
|
||||
@@ -253,6 +267,12 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string) (Of
|
||||
// Fail-closed: never replay when the undo is not verifiably on disk.
|
||||
return res, fmt.Errorf("a biztonsági mentés nem található a lemezen — a visszaállítás biztonsági okból nem indult el")
|
||||
}
|
||||
// Fail-closed (R-47): the app HAS a database but no compose service can be identified to
|
||||
// start alone for the replay. The only alternative would be to start everything and replay
|
||||
// into the race that produced H4 — refusing with the live app untouched is the better outcome.
|
||||
if len(dbServices) == 0 {
|
||||
return res, fmt.Errorf("Az adatbázis-szolgáltatás nem azonosítható a(z) %s alkalmazásban — a visszaállítás biztonsági okból nem indult el.", stack)
|
||||
}
|
||||
}
|
||||
|
||||
// --- FILES ----------------------------------------------------------------------------------
|
||||
@@ -280,19 +300,31 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string) (Of
|
||||
}
|
||||
|
||||
// --- DATABASE -------------------------------------------------------------------------------
|
||||
// The stack must be UP for the replay: ImportDump talks to the running container using its own
|
||||
// discovered credentials (the same precedence RestoreFromRecoveryUnit uses — the logical dump
|
||||
// wins over whatever the file copy just laid down for the DB's own data dir).
|
||||
if err := m.stackProvider.StartStack(stack); err != nil {
|
||||
return res, fmt.Errorf("a(z) %s újraindítása sikertelen a fájlok visszaállítása után: %w", stack, err)
|
||||
}
|
||||
// The DB container must be UP for the replay (ImportDump talks to it with its own discovered
|
||||
// credentials), but NOTHING ELSE may be — R-47. Until v0.153.0 this was a full StartStack, which
|
||||
// gave the application a window to rebuild the very schema objects the dump was about to create:
|
||||
// measured at 2 s on 2026-07-19, and the replay aborted `relation "clip_index" already exists`
|
||||
// under ON_ERROR_STOP=1 (H4). Starting only the database service closes that window entirely.
|
||||
if hasDB {
|
||||
if err := m.stackProvider.StartStackServices(stack, dbServices); err != nil {
|
||||
// Best-effort bring-up: a failed restore must not also be an outage.
|
||||
if sErr := m.stackProvider.StartStack(stack); sErr != nil {
|
||||
m.logger.Printf("[WARN] [offbox] %s: full start after failed DB-only start also failed: %v", stack, sErr)
|
||||
}
|
||||
return res, fmt.Errorf("a(z) %s adatbázis-szolgáltatásának indítása sikertelen: %w", stack, err)
|
||||
}
|
||||
n, iErr := m.reimportDBDumpsFrom(ctx, stack, scratchDumpDir)
|
||||
res.DBsReplayed = n
|
||||
if iErr != nil {
|
||||
if sErr := m.stackProvider.StartStack(stack); sErr != nil {
|
||||
m.logger.Printf("[WARN] [offbox] %s: full start after failed replay also failed: %v", stack, sErr)
|
||||
}
|
||||
return res, fmt.Errorf("az adatbázis visszaállítása sikertelen: %w — a korábbi állapot mentése megvan: %s", iErr, filepath.Base(safety))
|
||||
}
|
||||
}
|
||||
if err := m.stackProvider.StartStack(stack); err != nil {
|
||||
return res, fmt.Errorf("a(z) %s újraindítása sikertelen a fájlok visszaállítása után: %w", stack, err)
|
||||
}
|
||||
if err := m.waitForHealthy(stack, 90*time.Second); err != nil {
|
||||
m.logger.Printf("[WARN] [offbox] %s reconstituted but health check failed: %v", stack, err)
|
||||
}
|
||||
|
||||
@@ -20,13 +20,34 @@ import (
|
||||
// rather than a description of the current implementation.
|
||||
|
||||
// recordingProvider records stop/start call ORDER so the reconstitution sequence can be asserted.
|
||||
//
|
||||
// R-47 widened it: it now also records the DB-ONLY bring-up and, critically, whether a FULL start
|
||||
// has happened yet — the state the replay must observe as `false`. That single flag is what
|
||||
// separates the fixed sequence from the one that produced H4, in which the whole stack was already
|
||||
// up (and rebuilding its own schema) when the dump replay began.
|
||||
type recordingProvider struct {
|
||||
offbox3aProvider
|
||||
calls []string
|
||||
calls []string
|
||||
composePath string // the LIVE compose the DB-service resolver reads
|
||||
gotServices []string // services passed to StartStackServices
|
||||
fullStarted bool // a FULL StartStack has happened
|
||||
startSvcErr error // injected StartStackServices failure
|
||||
}
|
||||
|
||||
func (p *recordingProvider) StopStack(string) error { p.calls = append(p.calls, "stop"); return nil }
|
||||
func (p *recordingProvider) StartStack(string) error { p.calls = append(p.calls, "start"); return nil }
|
||||
func (p *recordingProvider) StopStack(string) error { p.calls = append(p.calls, "stop"); return nil }
|
||||
func (p *recordingProvider) StartStack(string) error {
|
||||
p.fullStarted = true
|
||||
p.calls = append(p.calls, "start")
|
||||
return nil
|
||||
}
|
||||
func (p *recordingProvider) StartStackServices(_ string, services []string) error {
|
||||
p.gotServices = append([]string(nil), services...)
|
||||
p.calls = append(p.calls, "startsvc:"+strings.Join(services, ","))
|
||||
return p.startSvcErr
|
||||
}
|
||||
func (p *recordingProvider) GetStackComposePath(string) (string, bool) {
|
||||
return p.composePath, p.composePath != ""
|
||||
}
|
||||
|
||||
// The app really is up again after StartStack, so the post-restore health wait returns at once.
|
||||
// Leaving it false would make each test sit through the full 90s deadline.
|
||||
@@ -78,6 +99,15 @@ func reconFixture(t *testing.T, runID, dumpsAt string, dumpBody string) (*Manage
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The LIVE compose the reconstitution reads to learn WHICH service holds the database (R-47).
|
||||
// Immich-shaped on purpose: an app service, a redis service that must never be mistaken for a
|
||||
// database, and a top-level `volumes:` key whose entry looks exactly like a service to a line scan.
|
||||
liveStackDir := t.TempDir()
|
||||
prov.composePath = filepath.Join(liveStackDir, "docker-compose.yml")
|
||||
if err := os.WriteFile(prov.composePath, []byte(immichLikeCompose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scratch, liveNs, err := m.offboxRestoreScratchDir("immich")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -163,9 +193,10 @@ func TestReconstituteReplaysDBAndOrdersOperations(t *testing.T) {
|
||||
if res.FilesPlaced != 3 {
|
||||
t.Fatalf("expected the userdata placement to be counted, got %d", res.FilesPlaced)
|
||||
}
|
||||
// stop BEFORE the file copy, start BEFORE the replay (ImportDump needs a live container).
|
||||
if got := strings.Join(prov.calls, ","); got != "stop,start" {
|
||||
t.Fatalf("expected stop then start around the restore, got %q", got)
|
||||
// stop BEFORE the file copy; then ONLY the database service up for the replay (R-47 — a full
|
||||
// start here is the H4 race); the full start comes last.
|
||||
if got := strings.Join(prov.calls, ","); got != "stop,startsvc:immich-postgres,start" {
|
||||
t.Fatalf("expected stop → db-only start → full start around the restore, got %q", got)
|
||||
}
|
||||
if res.SafetyDump == "" {
|
||||
t.Fatal("no safety dump recorded — the undo must exist")
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-47 (v0.153.0) — the DB replay must not race the app, on BOTH restore paths.
|
||||
//
|
||||
// Every test here is a regression guard for a measured incident, not a description of the code.
|
||||
// On 2026-07-19 (DIAG-immich-restore-round2-2026-07-19, H4) the offsite reconstitution started the
|
||||
// WHOLE stack before replaying the dump. immich-server used the window to rebuild `clip_index` two
|
||||
// seconds before the dump's own CREATE INDEX; the replay aborted `relation "clip_index" already
|
||||
// exists` under ON_ERROR_STOP=1, and immich then reported schema drift. The data survived only by
|
||||
// accident of pg_dump's ordering (COPY before CREATE INDEX) — a collision earlier in the script
|
||||
// would have left a genuinely half-restored database, reported identically.
|
||||
//
|
||||
// The property under test is therefore an ORDERING plus a STATE-AT-REPLAY-TIME: at the moment the
|
||||
// import fires, the database service must be up and the full stack must NOT be. Asserting only
|
||||
// "no error" would pass on the pre-fix shape, which is exactly how this shipped.
|
||||
|
||||
// immichLikeCompose is the catalog's immich template reduced to what the resolver reads: the app
|
||||
// services, the DB service, a redis that must never be mistaken for a database, and the top-level
|
||||
// `volumes:`/`networks:` keys (including `immich_postgres_data`) that a line scan would misread.
|
||||
const immichLikeCompose = `services:
|
||||
immich-server:
|
||||
image: ghcr.io/immich-app/immich-server:v3.0.3
|
||||
immich-machine-learning:
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.0.3
|
||||
immich-postgres:
|
||||
image: ghcr.io/immich-app/postgres:16-vectorchord0.4.3-pgvectors0.2.0
|
||||
immich-redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
immich_ml_cache:
|
||||
immich_postgres_data:
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
`
|
||||
|
||||
// noDBCompose is a DB-free app: nothing here may ever trigger the DB-only phase.
|
||||
const noDBCompose = `services:
|
||||
app:
|
||||
image: ghcr.io/x/app:1
|
||||
cache:
|
||||
image: redis:7-alpine
|
||||
`
|
||||
|
||||
// --- Group A: offsite reconstitute, DB-bearing app (the H4 killer) --------------------------------
|
||||
|
||||
// TestReconstituteReplaysWithOnlyTheDBServiceUp is the core R-47 assertion for the offsite path.
|
||||
// It does not merely check the call ORDER — it captures the provider's state AT THE MOMENT the
|
||||
// import fires, because that is what H4 was: the sequence looked right, and the app was up.
|
||||
//
|
||||
// COMPANION RED-PROOF: replacing the DB-only bring-up in ReconstituteFromOffsite with the pre-fix
|
||||
// full StartStack makes this fail on `full stack was ALREADY UP when the replay fired`.
|
||||
func TestReconstituteReplaysWithOnlyTheDBServiceUp(t *testing.T) {
|
||||
m, prov, imported := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
|
||||
|
||||
var dbUpAtReplay, fullUpAtReplay bool
|
||||
m.importDBDump = func(_ context.Context, _ DiscoveredDB, p string) error {
|
||||
dbUpAtReplay = len(prov.gotServices) > 0
|
||||
fullUpAtReplay = prov.fullStarted
|
||||
*imported = append(*imported, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
res, err := m.ReconstituteFromOffsite(context.Background(), "immich")
|
||||
if err != nil {
|
||||
t.Fatalf("reconstitute: %v", err)
|
||||
}
|
||||
if res.DBsReplayed != 1 {
|
||||
t.Fatalf("expected exactly one replay, got %d", res.DBsReplayed)
|
||||
}
|
||||
if !dbUpAtReplay {
|
||||
t.Fatal("the database service was NOT started before the replay — ImportDump has no container to talk to")
|
||||
}
|
||||
if fullUpAtReplay {
|
||||
t.Fatal("the FULL stack was already up when the replay fired — this is H4 exactly: the app races the dump's schema")
|
||||
}
|
||||
if got := strings.Join(prov.gotServices, ","); got != "immich-postgres" {
|
||||
t.Fatalf("DB-only phase started %q, want only the database service immich-postgres", got)
|
||||
}
|
||||
if got := strings.Join(prov.calls, ","); got != "stop,startsvc:immich-postgres,start" {
|
||||
t.Fatalf("sequence = %q, want stop → db-only start → replay → full start", got)
|
||||
}
|
||||
// The undo must have existed before any of it.
|
||||
if res.SafetyDump == "" {
|
||||
t.Fatal("no safety dump recorded")
|
||||
}
|
||||
if _, sErr := os.Stat(res.SafetyDump); sErr != nil {
|
||||
t.Fatalf("safety dump not on disk before the mutation: %v", sErr)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group B: offsite reconstitute, no-DB app (flow unchanged) ------------------------------------
|
||||
|
||||
// TestReconstituteNoDBAppNeverStartsServicesOnly asserts the NEGATIVE: an app with no database must
|
||||
// take exactly one full start and must never enter the DB-only phase. Without this, a bug that
|
||||
// armed the phase for every app would show up first as a customer's stack half-started.
|
||||
func TestReconstituteNoDBAppNeverStartsServicesOnly(t *testing.T) {
|
||||
m, prov, imported := reconFixture(t, "run1", "2026-07-19T06:00:00Z", "")
|
||||
prov.composePath = writeLiveCompose(t, noDBCompose)
|
||||
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { return nil, nil }
|
||||
|
||||
res, err := m.ReconstituteFromOffsite(context.Background(), "immich")
|
||||
if err != nil {
|
||||
t.Fatalf("a no-DB app must restore unchanged, got: %v", err)
|
||||
}
|
||||
if len(prov.gotServices) != 0 {
|
||||
t.Fatalf("the DB-only phase ran for an app with no database: %v", prov.gotServices)
|
||||
}
|
||||
if got := strings.Join(prov.calls, ","); got != "stop,start" {
|
||||
t.Fatalf("sequence = %q, want the unchanged stop → full start", got)
|
||||
}
|
||||
if len(*imported) != 0 || res.DBsReplayed != 0 {
|
||||
t.Fatalf("a no-DB app must not replay anything: imported=%v replayed=%d", *imported, res.DBsReplayed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group C: fail-closed, both paths -------------------------------------------------------------
|
||||
|
||||
// TestReconstituteRefusesWhenNoDBServiceIdentifiable is the security-adjacent gate. A dump exists and
|
||||
// a live database was discovered, but the live compose names no startable database service. The only
|
||||
// alternative to refusing would be to start everything and replay into the H4 race, so this must
|
||||
// refuse — and it must refuse with ZERO mutations, which is what the effect assertions below prove.
|
||||
// Asserting `err != nil` alone would pass even if the app had already been stopped and overwritten.
|
||||
//
|
||||
// COMPANION RED-PROOF: deleting the `len(dbServices) == 0` gate makes this fail on
|
||||
// `the app was stopped despite the refusal`.
|
||||
func TestReconstituteRefusesWhenNoDBServiceIdentifiable(t *testing.T) {
|
||||
m, prov, imported := reconFixture(t, "run1", "2026-07-19T06:00:00Z", pgDump(1))
|
||||
// A live compose whose services are all app/cache images — nothing to start alone.
|
||||
prov.composePath = writeLiveCompose(t, noDBCompose)
|
||||
var copied bool
|
||||
m.SetOffboxFullPlaceCopier(func(_, _ string) (int, error) { copied = true; return 1, nil })
|
||||
|
||||
_, err := m.ReconstituteFromOffsite(context.Background(), "immich")
|
||||
if err == nil {
|
||||
t.Fatal("expected a refusal: a dump exists but no database service can be started for it")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nem azonosítható") {
|
||||
t.Fatalf("refusal must say the database service could not be identified, got: %v", err)
|
||||
}
|
||||
if len(prov.calls) != 0 {
|
||||
t.Fatalf("ZERO mutations required, but the provider was called: %v", prov.calls)
|
||||
}
|
||||
if copied {
|
||||
t.Fatal("files were overwritten despite the refusal")
|
||||
}
|
||||
if len(*imported) != 0 {
|
||||
t.Fatalf("a replay happened despite the refusal: %v", *imported)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreFromUnitRefusesWhenNoDBServiceIdentifiable is the local path's sibling gate, with the
|
||||
// same zero-mutation requirement: no stop, no volume restore, no definition recreate.
|
||||
func TestRestoreFromUnitRefusesWhenNoDBServiceIdentifiable(t *testing.T) {
|
||||
m, prov, _ := r47UnitFixture(t, noDBCompose, true)
|
||||
|
||||
err := m.RestoreFromRecoveryUnit("app")
|
||||
if err == nil {
|
||||
t.Fatal("expected a refusal: the unit carries a dump but names no startable database service")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nem azonosítható") {
|
||||
t.Fatalf("refusal must say the database service could not be identified, got: %v", err)
|
||||
}
|
||||
if len(prov.calls) != 0 {
|
||||
t.Fatalf("ZERO mutations required, but the provider was called: %v", prov.calls)
|
||||
}
|
||||
if prov.stopped {
|
||||
t.Fatal("the app was stopped despite the refusal")
|
||||
}
|
||||
if prov.gotEnv != nil {
|
||||
t.Fatal("the definition was recreated despite the refusal")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group D: local restore-from-unit ordering ----------------------------------------------------
|
||||
|
||||
// TestRestoreFromUnitReplaysWithOnlyTheDBServiceUp is Group A's twin on the local path — the SAME
|
||||
// class defect lived here, in the shape `RecreateStackFromUnit` (which ended in a full `up -d`)
|
||||
// followed by the replay. Splitting the persist from the start is what makes this orderable at all.
|
||||
//
|
||||
// COMPANION RED-PROOF: restoring the pre-fix shape (RecreateStackDefinitionFromUnit performing a
|
||||
// full start, replay after) makes this fail on `full stack was ALREADY UP when the replay fired`.
|
||||
func TestRestoreFromUnitReplaysWithOnlyTheDBServiceUp(t *testing.T) {
|
||||
m, prov, imported := r47UnitFixture(t, immichLikeCompose, true)
|
||||
|
||||
var dbUpAtReplay, fullUpAtReplay, definitionPersisted bool
|
||||
m.importDBDump = func(_ context.Context, _ DiscoveredDB, p string) error {
|
||||
dbUpAtReplay = len(prov.gotServices) > 0
|
||||
fullUpAtReplay = prov.fullStarted
|
||||
definitionPersisted = prov.gotEnv != nil
|
||||
*imported = append(*imported, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
||||
t.Fatalf("restore-from-unit: %v", err)
|
||||
}
|
||||
if len(*imported) != 1 {
|
||||
t.Fatalf("expected exactly one replay, got %v", *imported)
|
||||
}
|
||||
if !definitionPersisted {
|
||||
t.Fatal("the app definition was not persisted before the replay — the DB service could not have been started from it")
|
||||
}
|
||||
if !dbUpAtReplay {
|
||||
t.Fatal("the database service was NOT started before the replay")
|
||||
}
|
||||
if fullUpAtReplay {
|
||||
t.Fatal("the FULL stack was already up when the replay fired — the H4 race, on the local path")
|
||||
}
|
||||
if got := strings.Join(prov.calls, ","); got != "stop,recreate,startsvc:immich-postgres,start" {
|
||||
t.Fatalf("sequence = %q, want stop → recreate(definition only) → db-only start → replay → full start", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreFromUnitNoDumpsTakesOneFullStart is the local no-DB negative: without a replayable dump
|
||||
// there is no DB-only window at all, just the definition and one full start.
|
||||
func TestRestoreFromUnitNoDumpsTakesOneFullStart(t *testing.T) {
|
||||
m, prov, imported := r47UnitFixture(t, noDBCompose, false)
|
||||
|
||||
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
||||
t.Fatalf("restore-from-unit: %v", err)
|
||||
}
|
||||
if len(prov.gotServices) != 0 {
|
||||
t.Fatalf("the DB-only phase ran with nothing to replay: %v", prov.gotServices)
|
||||
}
|
||||
if got := strings.Join(prov.calls, ","); got != "stop,recreate,start" {
|
||||
t.Fatalf("sequence = %q, want stop → recreate → full start", got)
|
||||
}
|
||||
if len(*imported) != 0 {
|
||||
t.Fatalf("nothing should have been replayed, got %v", *imported)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreFromUnitIgnoresSafetyDumpsWhenDecidingToReplay guards the one file-naming trap in the
|
||||
// gate: `pre-restore-*.sql` safety dumps live in the SAME directory as the real dumps (deliberately —
|
||||
// an undo the customer cannot see is not much of one) but are never a replay source. Counting them
|
||||
// would arm the DB-only phase, and its refusal, for an app that has nothing to replay.
|
||||
func TestRestoreFromUnitIgnoresSafetyDumpsWhenDecidingToReplay(t *testing.T) {
|
||||
m, prov, _ := r47UnitFixture(t, noDBCompose, false)
|
||||
// A safety dump present for a DB-less app must not arm anything — including the refusal.
|
||||
mustWrite(t, filepath.Join(AppDBDumpPath(prov.hdd, "app"),
|
||||
preRestoreDumpPrefix+"20260720T101010Z-app-postgres.sql"), pgDump(1))
|
||||
|
||||
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
||||
t.Fatalf("a lone safety dump must not turn into a refusal: %v", err)
|
||||
}
|
||||
if len(prov.gotServices) != 0 {
|
||||
t.Fatalf("a safety dump armed the DB-only phase: %v", prov.gotServices)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group E: a failed replay never strands the box DB-only ---------------------------------------
|
||||
|
||||
// TestReconstituteReplayFailureStillBringsTheStackUp: the DB-only window is a deliberate half-started
|
||||
// state, so EVERY exit from it must end in a full start. Otherwise a failed restore leaves the
|
||||
// customer with a running database and no application — an outage caused by the recovery tool.
|
||||
func TestReconstituteReplayFailureStillBringsTheStackUp(t *testing.T) {
|
||||
m, prov, _ := reconFixture(t, "run1", "2026-07-19T06:00:00Z", pgDump(1))
|
||||
m.importDBDump = func(context.Context, DiscoveredDB, string) error {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
|
||||
res, err := m.ReconstituteFromOffsite(context.Background(), "immich")
|
||||
if err == nil {
|
||||
t.Fatal("a failed replay must be surfaced, not swallowed")
|
||||
}
|
||||
if !prov.fullStarted {
|
||||
t.Fatal("the stack was left DB-ONLY after a failed replay — the app is down and nothing will bring it up")
|
||||
}
|
||||
if got := strings.Join(prov.calls, ","); got != "stop,startsvc:immich-postgres,start" {
|
||||
t.Fatalf("sequence = %q, want the best-effort full start after the failure", got)
|
||||
}
|
||||
// The existing message shape stays: the operator needs the undo's filename.
|
||||
if !strings.Contains(err.Error(), filepath.Base(res.SafetyDump)) {
|
||||
t.Fatalf("the error must name the safety dump so the operator can undo, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconstituteDBOnlyStartFailureStillBringsTheStackUp covers the other exit from the window: the
|
||||
// DB-only start itself failing. Same requirement — the app must not be left down.
|
||||
func TestReconstituteDBOnlyStartFailureStillBringsTheStackUp(t *testing.T) {
|
||||
m, prov, imported := reconFixture(t, "run1", "2026-07-19T06:00:00Z", pgDump(1))
|
||||
prov.startSvcErr = context.DeadlineExceeded
|
||||
|
||||
if _, err := m.ReconstituteFromOffsite(context.Background(), "immich"); err == nil {
|
||||
t.Fatal("a failed DB-only start must be surfaced")
|
||||
}
|
||||
if !prov.fullStarted {
|
||||
t.Fatal("the stack was left down after a failed DB-only start")
|
||||
}
|
||||
if len(*imported) != 0 {
|
||||
t.Fatalf("nothing may be replayed when the database never came up: %v", *imported)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreFromUnitReplayFailureStillBringsTheStackUp is the local path's version, and it also
|
||||
// pins the pre-existing semantics: a replay error becomes a dataErr and surfaces as the "completed
|
||||
// with data errors" outcome, with the app back up.
|
||||
func TestRestoreFromUnitReplayFailureStillBringsTheStackUp(t *testing.T) {
|
||||
m, prov, _ := r47UnitFixture(t, immichLikeCompose, true)
|
||||
m.importDBDump = func(context.Context, DiscoveredDB, string) error {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
|
||||
err := m.RestoreFromRecoveryUnit("app")
|
||||
if err == nil {
|
||||
t.Fatal("a failed replay must be surfaced, not swallowed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "completed with data errors") {
|
||||
t.Fatalf("the pre-existing outcome semantics must be preserved, got: %v", err)
|
||||
}
|
||||
if !prov.fullStarted {
|
||||
t.Fatal("the stack was left DB-ONLY after a failed replay")
|
||||
}
|
||||
if got := strings.Join(prov.calls, ","); got != "stop,recreate,startsvc:immich-postgres,start" {
|
||||
t.Fatalf("sequence = %q, want the full start to follow the failed replay", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- fixtures -------------------------------------------------------------------------------------
|
||||
|
||||
// writeLiveCompose drops a compose file in its own temp dir and returns the path.
|
||||
func writeLiveCompose(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "docker-compose.yml")
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// r47UnitFixture builds a Manager whose local recovery unit for "app" carries the given compose and,
|
||||
// optionally, a replayable `app-postgres.sql` dump. The provider records call order; the DB
|
||||
// discovery/import seams are injected so no Docker is touched.
|
||||
func r47UnitFixture(t *testing.T, compose string, withDump bool) (*Manager, *fakeRecoveryProvider, *[]string) {
|
||||
t.Helper()
|
||||
drive := filepath.Join(t.TempDir(), "drive")
|
||||
composeDir := RecoveryUnitComposePath(drive, "app")
|
||||
mustWrite(t, filepath.Join(composeDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: app\n")
|
||||
mustWrite(t, filepath.Join(composeDir, "docker-compose.yml"), compose)
|
||||
man := &RecoveryManifest{SchemaVersion: 1, AppName: "app", ControllerVer: "v"}
|
||||
if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if withDump {
|
||||
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "app"), "app-postgres.sql"), pgDump(1))
|
||||
}
|
||||
|
||||
prov := &fakeRecoveryProvider{hdd: drive, running: true}
|
||||
m := &Manager{
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
systemDataPath: filepath.Join(drive, "..", "sys"),
|
||||
stackProvider: prov,
|
||||
}
|
||||
|
||||
db := DiscoveredDB{StackName: "app", ContainerName: "immich-postgres", DBType: DBTypePostgres}
|
||||
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { return []DiscoveredDB{db}, nil }
|
||||
var imported []string
|
||||
m.importDBDump = func(_ context.Context, _ DiscoveredDB, p string) error {
|
||||
imported = append(imported, p)
|
||||
return nil
|
||||
}
|
||||
return m, prov, &imported
|
||||
}
|
||||
@@ -12,13 +12,23 @@ import (
|
||||
)
|
||||
|
||||
// fakeRecoveryProvider is a configurable StackDataProvider for the capture + restore tests.
|
||||
//
|
||||
// It records the ORDER of every mutating call (R-47): the local restore path's correctness is an
|
||||
// ordering property — definition persisted, then the DB service alone, then the replay, then the
|
||||
// full start — and an ordering guarantee nothing observes is one refactor from silently reverting to
|
||||
// the shape that produced H4.
|
||||
type fakeRecoveryProvider struct {
|
||||
info RecoveryInfo
|
||||
hdd string
|
||||
secrets map[string]string // returned by RecoverStackSecrets
|
||||
gotEnv map[string]string // captured by RecreateStackFromUnit
|
||||
gotEnv map[string]string // captured by RecreateStackDefinitionFromUnit
|
||||
running bool // returned by RefreshAndIsRunning
|
||||
stopped bool
|
||||
|
||||
calls []string // ordered log: stop / recreate / startsvc:<a,b> / start
|
||||
gotServices []string // services passed to StartStackServices
|
||||
startSvcErr error // injected StartStackServices failure
|
||||
fullStarted bool // a FULL StartStack happened
|
||||
}
|
||||
|
||||
func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) {
|
||||
@@ -28,9 +38,17 @@ func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { return nil
|
||||
func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd }
|
||||
func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (f *fakeRecoveryProvider) StopStack(string) error { f.stopped = true; return nil }
|
||||
func (f *fakeRecoveryProvider) StartStack(string) error { return nil }
|
||||
func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.running }
|
||||
func (f *fakeRecoveryProvider) StopStack(string) error {
|
||||
f.stopped = true
|
||||
f.calls = append(f.calls, "stop")
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRecoveryProvider) StartStack(string) error {
|
||||
f.fullStarted = true
|
||||
f.calls = append(f.calls, "start")
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.running }
|
||||
func (f *fakeRecoveryProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
||||
return f.info, true
|
||||
}
|
||||
@@ -40,10 +58,16 @@ func (f *fakeRecoveryProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind
|
||||
func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string {
|
||||
return f.secrets
|
||||
}
|
||||
func (f *fakeRecoveryProvider) RecreateStackFromUnit(_, _ string, fullEnv map[string]string) error {
|
||||
func (f *fakeRecoveryProvider) RecreateStackDefinitionFromUnit(_, _ string, fullEnv map[string]string) error {
|
||||
f.gotEnv = fullEnv
|
||||
f.calls = append(f.calls, "recreate")
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRecoveryProvider) StartStackServices(_ string, services []string) error {
|
||||
f.gotServices = append([]string(nil), services...)
|
||||
f.calls = append(f.calls, "startsvc:"+strings.Join(services, ","))
|
||||
return f.startSvcErr
|
||||
}
|
||||
|
||||
// TestCaptureRecoveryUnitIsSecretFree proves the captured unit (a) contains compose+config+manifest,
|
||||
// (b) enumerates the existing dumps, and (c) is SECRET-FREE: a secret value present in the SOURCE
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -64,6 +65,26 @@ func readStrippedEnv(path string) map[string]string {
|
||||
return s.Env
|
||||
}
|
||||
|
||||
// hasReplayableDump reports whether dumpDir holds a .sql dump that the replay could actually use.
|
||||
// The `pre-restore-` safety dumps are EXCLUDED: they live in the same directory (deliberately — an
|
||||
// undo the customer cannot see is not much of one) but are never a replay source, so counting them
|
||||
// would arm the DB-only phase, and its fail-closed gate, for an app that has nothing to replay.
|
||||
func hasReplayableDump(dumpDir string) bool {
|
||||
entries, err := os.ReadDir(dumpDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(e.Name(), preRestoreDumpPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RestoreFromRecoveryUnit recreates an app from its on-drive recovery unit + the guest's own secrets.
|
||||
//
|
||||
// It reads the unit manifest, recovers the secret values from the guest's live app.yaml, applies the
|
||||
@@ -148,7 +169,22 @@ func (m *Manager) RestoreFromRecoveryUnit(stackName string) error {
|
||||
m.logger.Printf("[INFO] [backup] Restoring %s from recovery unit: images=%d, secrets recovered=%d/%d, data_keys=%d",
|
||||
stackName, len(manifest.ImagePins), len(manifest.SecretEnvVars)-len(missing), len(manifest.SecretEnvVars), len(manifest.DataKeyEnvVars))
|
||||
|
||||
// Stop, restore named-volume data, then recreate the definition + redeploy with the recovered env.
|
||||
// R-47: which compose service holds the database, and is there anything to replay? Resolved from
|
||||
// the UNIT's compose, because that file is about to BECOME the live one. Both answers are needed
|
||||
// BEFORE the first mutation, so the refusal below leaves the live app completely untouched.
|
||||
dbServices, dsErr := DBServiceNames(filepath.Join(composeDir, "docker-compose.yml"))
|
||||
if dsErr != nil {
|
||||
// "cannot tell" is not "no database" — leave it empty and let the gate decide.
|
||||
m.logger.Printf("[WARN] [backup] %s: could not read the unit's compose services: %v", stackName, dsErr)
|
||||
}
|
||||
hasDumps := hasReplayableDump(AppDBDumpPath(nsRoot, stackName))
|
||||
if hasDumps && len(dbServices) == 0 {
|
||||
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: a .sql dump exists but no database service is identifiable in the unit's compose", stackName)
|
||||
return fmt.Errorf("Az adatbázis-szolgáltatás nem azonosítható a(z) %s alkalmazásban — a visszaállítás biztonsági okból nem indult el.", stackName)
|
||||
}
|
||||
|
||||
// Stop, restore named-volume data, recreate the definition, replay the DB with ONLY the database
|
||||
// service running, and only then start the whole stack.
|
||||
// F17: surface a data-restore failure instead of swallowing it (we still bring the app back up).
|
||||
var dataErr error
|
||||
if err := m.stackProvider.StopStack(stackName); err != nil {
|
||||
@@ -158,17 +194,30 @@ func (m *Manager) RestoreFromRecoveryUnit(stackName string) error {
|
||||
m.logger.Printf("[ERROR] [backup] volume restore for %s: %v", stackName, err)
|
||||
dataErr = err
|
||||
}
|
||||
if err := m.stackProvider.RecreateStackFromUnit(stackName, composeDir, fullEnv); err != nil {
|
||||
if err := m.stackProvider.RecreateStackDefinitionFromUnit(stackName, composeDir, fullEnv); err != nil {
|
||||
return fmt.Errorf("recreating %s from unit: %w", stackName, err)
|
||||
}
|
||||
// F17: the captured .sql dump is the authoritative logical DB state — replay it into the now-running
|
||||
// DB container AFTER the volume restore, so the dump WINS over any volume-tar copy of the database.
|
||||
if _, err := m.reimportDBDumpsCtx(stackName, nsRoot); err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] DB re-import for %s: %v", stackName, err)
|
||||
if dataErr == nil {
|
||||
dataErr = err
|
||||
// F17: the captured .sql dump is the authoritative logical DB state — replay it AFTER the volume
|
||||
// restore, so the dump WINS over any volume-tar copy of the database.
|
||||
// R-47: the replay happens with ONLY the database service up. This used to run after
|
||||
// RecreateStackFromUnit had already brought the WHOLE stack up, letting the application rebuild
|
||||
// schema objects underneath the replay (H4, DIAG-immich-restore-round2-2026-07-19).
|
||||
if hasDumps {
|
||||
if err := m.stackProvider.StartStackServices(stackName, dbServices); err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] DB-only start for %s: %v", stackName, err)
|
||||
if dataErr == nil {
|
||||
dataErr = err
|
||||
}
|
||||
} else if _, err := m.reimportDBDumpsCtx(stackName, nsRoot); err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] DB re-import for %s: %v", stackName, err)
|
||||
if dataErr == nil {
|
||||
dataErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := m.stackProvider.StartStack(stackName); err != nil {
|
||||
return fmt.Errorf("starting %s after restore from unit: %w", stackName, err)
|
||||
}
|
||||
if err := m.waitForHealthy(stackName, 90*time.Second); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] %s restored but health check failed: %v", stackName, err)
|
||||
}
|
||||
|
||||
@@ -44,9 +44,10 @@ func (f *t2rFakeProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
||||
}
|
||||
func (f *t2rFakeProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) { return nil, false }
|
||||
func (f *t2rFakeProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
||||
func (f *t2rFakeProvider) RecreateStackFromUnit(string, string, map[string]string) error {
|
||||
func (f *t2rFakeProvider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *t2rFakeProvider) StartStackServices(string, []string) error { return nil }
|
||||
|
||||
// newT2RManager builds a Manager with a RECORDED Tier-2 copy for "app": live drive + a populated
|
||||
// <dest>/backups/secondary/app/appdata dir, and a CrossDriveBackup entry pointing at dest.
|
||||
|
||||
@@ -21,17 +21,22 @@ type t2v2Provider struct {
|
||||
has map[string]bool
|
||||
}
|
||||
|
||||
func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", false }
|
||||
func (p *t2v2Provider) ListDeployedStacks() []StackSummary { return nil }
|
||||
func (p *t2v2Provider) GetStackHDDMounts(n string) []string { return p.mounts[n] }
|
||||
func (p *t2v2Provider) GetStackHDDPath(string) string { return p.hdd }
|
||||
func (p *t2v2Provider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *t2v2Provider) StopStack(string) error { return nil }
|
||||
func (p *t2v2Provider) StartStack(string) error { return nil }
|
||||
func (p *t2v2Provider) RefreshAndIsRunning(string) bool { return true }
|
||||
func (p *t2v2Provider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) { return RecoveryInfo{}, false }
|
||||
func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", false }
|
||||
func (p *t2v2Provider) ListDeployedStacks() []StackSummary { return nil }
|
||||
func (p *t2v2Provider) GetStackHDDMounts(n string) []string { return p.mounts[n] }
|
||||
func (p *t2v2Provider) GetStackHDDPath(string) string { return p.hdd }
|
||||
func (p *t2v2Provider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *t2v2Provider) StopStack(string) error { return nil }
|
||||
func (p *t2v2Provider) StartStack(string) error { return nil }
|
||||
func (p *t2v2Provider) RefreshAndIsRunning(string) bool { return true }
|
||||
func (p *t2v2Provider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
||||
return RecoveryInfo{}, false
|
||||
}
|
||||
func (p *t2v2Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
||||
func (p *t2v2Provider) RecreateStackFromUnit(string, string, map[string]string) error { return nil }
|
||||
func (p *t2v2Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (p *t2v2Provider) StartStackServices(string, []string) error { return nil }
|
||||
func (p *t2v2Provider) GetStackClassifiedBinds(n string) ([]ClassifiedBind, bool) {
|
||||
return p.binds[n], p.has[n]
|
||||
}
|
||||
@@ -299,7 +304,10 @@ func TestTier2V2_RestoreRefusesOldLayout(t *testing.T) {
|
||||
if err := os.Remove(filepath.Join(destDrive, "backups", "secondary", "app", tier2LayoutMarker)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.restoreFilesCopier = func(string, string) (int, error) { t.Fatal("copier must not run on an old-layout refusal"); return 0, nil }
|
||||
m.restoreFilesCopier = func(string, string) (int, error) {
|
||||
t.Fatal("copier must not run on an old-layout refusal")
|
||||
return 0, nil
|
||||
}
|
||||
if _, err := m.RestoreTier2Files("app"); err == nil || !strings.Contains(err.Error(), "régi formátumú") {
|
||||
t.Fatalf("restore must refuse a pre-v2 copy with the marker-refusal string, got %v", err)
|
||||
}
|
||||
|
||||
@@ -39,9 +39,10 @@ func (f *volDumpFakeProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind,
|
||||
return nil, false
|
||||
}
|
||||
func (f *volDumpFakeProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
||||
func (f *volDumpFakeProvider) RecreateStackFromUnit(string, string, map[string]string) error {
|
||||
func (f *volDumpFakeProvider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *volDumpFakeProvider) StartStackServices(string, []string) error { return nil }
|
||||
|
||||
// TestRunVolumeDumps_GatesPrecedeDump proves Scenario D/E's gating: the dump is invoked ONLY for
|
||||
// a volume-bearing, unprotected stack on a writable drive. The negatives are the point —
|
||||
|
||||
Reference in New Issue
Block a user