diff --git a/CHANGELOG.md b/CHANGELOG.md index f316474..cadb6a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +## v0.219.0 — the off-site restore refused every app that has no data drive (2026-08-22, R-356) +**MinAgent: 0.129.0** (unchanged — no new agent coupling) + +**What broke.** Forty of the fifty-three catalogue apps could not be restored from the off-site copy +at all. The customer opened the restore page, pressed the button, and was told the app **"nincs +telepítve"** — while it was running in front of them — and instructed to reinstall it "ugyanerre a +helyre", a place those apps never offer. The instruction could not be followed, so the restore never +started. + +**Why.** `ReconstituteFromOffsite` and `PlaceOffsiteRestore` both resolved the restore destination +with the RAW `HDD_PATH` (`stackProvider.GetStackHDDPath`) and read an empty answer as "the app is not +installed". One predicate was answering two questions. Measured in the catalogue at `459766cb1639`: +**53 templates, 13 declare `needs_hdd: true`, 40 declare `false`** — and for those 40 the answer is +*correctly* empty, permanently. The capture side never had this defect: `CaptureRecoveryUnit` resolves +via `GetAppDrivePath`, which falls back to the system data path — which is why the 2026-08-21 refusal +was able to print `/mnt/sys_drive`, a destination the backup had recorded and the restore refused to +use. + +**The fix separates the two questions.** *Installed?* is now asked directly, of +`ListDeployedStacks()`, through a new `Manager.isStackDeployed` that fails CLOSED on a nil provider — +"cannot tell" must not become "go ahead" when the caller's next act is a write. *Where?* is then +answered by `GetAppDrivePath`, the same resolver the capture side wrote the snapshot with, so the +restore aims at the place the backup came from. + +**The 13-class behaviour is unchanged.** A drive app still resolves to its own drive, the placement +mismatch check still fires when the recorded drive differs, and `ackPlacementChange` is still required +to pass it. That claim is not an absence claim: the red-proof plants the system-data fallback into the +drive path, and three tests convict it. + +**A third refusal now exists, with its own sentence.** Installed, driveless, and the box cannot name +its own data root ⇒ refuse and name the storage page. Widening `nincs telepítve` to cover this would +send a customer to reinstall a running app and hide the real fault. + +**Why now.** v0.218.0 (R-354) taught the reconstitution to replay an app's named volumes. Those forty +apps keep **all** of their data in exactly those volumes, so that fix could not reach the apps that +need it most until this one shipped. + +**Changed:** `internal/backup/backup.go` (`isStackDeployed`), `internal/backup/offbox_reconstitute.go`, +`internal/backup/offbox_restore.go`. **Not changed, deliberately:** `offboxCaptureSet`'s raw +`GetStackHDDPath` call — capture resolves an app's declared `userdata`/`import` file legs against that +value, and a system-data fallback there would write a snapshot claiming to hold files it does not. + +**Tests:** `internal/backup/r356_hot_only_restore_test.go` (Scenarios A–E, both entry points, and the +positive/negative control on the deployment predicate) and `cmd/controller/r356_deployed_seam_test.go` +(AST walk over the production adapter wiring). Five companion red-proofs, each SEEN failing. + +**Fixture correction in the same commit:** several existing fixtures marked an app "installed" by +giving it an HDD path. That is the conflation this change removes, so they now state deployment as its +own fact. No assertion was weakened. + ## v0.218.0 — the database nobody backed up, and the restore that returned most apps nothing (2026-08-22, R-354/R-355) **MinAgent: 0.129.0** (unchanged — no new agent coupling) diff --git a/controller/cmd/controller/r356_deployed_seam_test.go b/controller/cmd/controller/r356_deployed_seam_test.go new file mode 100644 index 0000000..a243d25 --- /dev/null +++ b/controller/cmd/controller/r356_deployed_seam_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// R-356 §10 seam discipline. The restore now decides "is this app installed?" from +// ListDeployedStacks. That answer only means anything if the PRODUCTION adapter is the thing +// answering it, and if that adapter really filters on Deployed — a component that is correct in a +// fake and unreachable in production is the four-times-shipped defect class in this repo. +// +// AST, never strings.Contains: a commented-out call still satisfies a substring match. + +// funcBody returns the named top-level func's body from main.go, or fails. +func funcBodyInMain(t *testing.T, name string) *ast.BlockStmt { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "main.go", nil, 0) + if err != nil { + t.Fatalf("parse main.go: %v", err) + } + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil || fn.Name.Name != name { + continue + } + return fn.Body + } + t.Fatalf("func %s not found in main.go", name) + return nil +} + +// TestMainWiresTheStackProviderIntoTheBackupManager: without this call the backup manager's +// stackProvider is nil, isStackDeployed fails closed, and EVERY off-site restore refuses. +func TestMainWiresTheStackProviderIntoTheBackupManager(t *testing.T) { + body := funcBodyInMain(t, "main") + var found bool + ast.Inspect(body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "SetStackProvider" || len(call.Args) != 1 { + return true + } + found = true + return false + }) + if !found { + t.Fatal("main() never calls SetStackProvider — the restore's deployment check would fail closed for every app") + } +} + +// TestStackAdapterListDeployedFiltersOnDeployed pins the semantics R-356 depends on: the adapter +// returns DEPLOYED stacks, not all known ones. Verified by the presence of the `!s.Deployed` +// continue-guard in the method body — remove it and an app that was never deployed would be told it +// has somewhere to restore to. +func TestStackAdapterListDeployedFiltersOnDeployed(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "main.go", nil, 0) + if err != nil { + t.Fatalf("parse main.go: %v", err) + } + var body *ast.BlockStmt + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || fn.Name.Name != "ListDeployedStacks" || fn.Body == nil { + continue + } + body = fn.Body + } + if body == nil { + t.Fatal("stackAdapter.ListDeployedStacks not found in main.go") + } + var guarded bool + ast.Inspect(body, func(n ast.Node) bool { + un, ok := n.(*ast.UnaryExpr) + if !ok || un.Op != token.NOT { + return true + } + sel, ok := un.X.(*ast.SelectorExpr) + if ok && sel.Sel.Name == "Deployed" { + guarded = true + } + return true + }) + if !guarded { + t.Fatal("ListDeployedStacks does not test !s.Deployed — an undeployed app would be reported installed") + } +} diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 21944ca..f3f8ca5 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -374,6 +374,28 @@ func (m *Manager) knownStackNames() []string { return names } +// isStackDeployed reports whether the named app is currently deployed on this box. R-356: the +// restore side used to answer "is it installed?" by asking "does it have a drive?" — true for the 13 +// needs_hdd apps and permanently false for the other 40, which are correctly driveless. The two +// questions are now separate, and this one is the one about installation. +// +// Nil provider ⇒ FALSE, deliberately: with no provider we cannot tell whether the app exists, and the +// caller's next act is to WRITE customer data to a destination. "Cannot tell" must not become +// "go ahead" — it fails closed into the refusal, which is recoverable, instead of into a copy, which +// is not. +func (m *Manager) isStackDeployed(stack string) bool { + stack = strings.TrimSpace(stack) + if stack == "" { + return false + } + for _, name := range m.knownStackNames() { + if name == stack { + return true + } + } + return false +} + // groupStacksByDrive groups deployed stacks by their home drive path. func (m *Manager) groupStacksByDrive() map[string][]StackSummary { if m.stackProvider == nil { diff --git a/controller/internal/backup/offbox_place_test.go b/controller/internal/backup/offbox_place_test.go index 15b3130..6f5a284 100644 --- a/controller/internal/backup/offbox_place_test.go +++ b/controller/internal/backup/offbox_place_test.go @@ -15,6 +15,10 @@ func placeFixture(t *testing.T, full bool) (*Manager, *offbox3aProvider, string, drive := t.TempDir() m, _, prov := classifiedOffboxManager(t, drive) prov.hdd["immich"] = drive + // R-356: "installed" is now asked directly (ListDeployedStacks), not inferred from owning a + // drive. The fixture must say so — before, the HDD path alone stood in for both facts, which is + // exactly the conflation this change removes. + prov.deployed = map[string]bool{"immich": true} scratch, liveNs, err := m.offboxRestoreScratchDir("immich") if err != nil { @@ -61,7 +65,10 @@ func placeFixture(t *testing.T, full bool) (*Manager, *offbox3aProvider, string, // A (F-3a-1a): undeployed placement refused with ZERO copies (never merges onto the SSD namespace). func TestPlace_UndeployedRefused(t *testing.T) { m, prov, scratch, copies := placeFixture(t, true) - prov.hdd["immich"] = "" // undeployed + // R-356: undeployed means ABSENT FROM ListDeployedStacks. Clearing only the HDD path no longer + // makes an app look uninstalled — a driveless app is the normal case for 40 of the 53 apps. + prov.deployed = map[string]bool{} + prov.hdd["immich"] = "" err := m.PlaceOffsiteRestore(context.Background(), "immich") if err == nil || !strings.Contains(err.Error(), "nincs telepítve") { t.Fatalf("undeployed must refuse with 'nincs telepítve', got %v", err) diff --git a/controller/internal/backup/offbox_placement_refusal_test.go b/controller/internal/backup/offbox_placement_refusal_test.go index d974cca..b3755cd 100644 --- a/controller/internal/backup/offbox_placement_refusal_test.go +++ b/controller/internal/backup/offbox_placement_refusal_test.go @@ -54,10 +54,13 @@ func TestReconstitute_NotInstalled_RefusalNamesTheRecordedDrive(t *testing.T) { t.Fatal(err) } - // The app is not installed: no live HDD path. This is the rebuilt-machine shape. + // The app is not installed: absent from the deployed set. This is the rebuilt-machine shape. + // R-356: the HDD path is cleared too (a rebuilt box has neither), but the DEPLOYED set is what + // the refusal now consults — an empty HDD path on its own is the normal shape of 40 apps. + prov.deployed = map[string]bool{} prov.hdd = map[string]string{} - if got := prov.GetStackHDDPath("immich"); got != "" { - t.Fatalf("fixture: the app must look uninstalled, got hdd=%q", got) + if m.isStackDeployed("immich") { + t.Fatal("fixture: the app must look uninstalled") } callsBefore := len(prov.calls) diff --git a/controller/internal/backup/offbox_reconstitute.go b/controller/internal/backup/offbox_reconstitute.go index 685434e..93d2d38 100644 --- a/controller/internal/backup/offbox_reconstitute.go +++ b/controller/internal/backup/offbox_reconstitute.go @@ -211,19 +211,24 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack } res.SnapshotID = id - hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack)) - if hdd == "" { - // R-253: the same sentence the restore page now shows, so the page and the refusal cannot - // drift apart again. It is a REFUSAL, not a failure — the data is untouched and the customer - // has one step to take. The restore deliberately does NOT deploy the app itself: the - // destination is the app's own HDD path, which is a drive the CUSTOMER chooses at deploy - // time, and picking it for them is the decision this whole recovery path exists to leave - // with them. - // R-351: the refusal now NAMES the place the backup recorded, when it can read it. The - // prepared scratch already contains the unit, so this is a local file read — no network call, - // nothing restored, and it happens on a path that was going to refuse anyway. Telling - // somebody to reinstall without telling them where the data belongs is what forced the - // 2026-08-21 operator to remember two values the backup already held. + // R-253: the same sentence the restore page now shows, so the page and the refusal cannot + // drift apart again. It is a REFUSAL, not a failure — the data is untouched and the customer + // has one step to take. The restore deliberately does NOT deploy the app itself: the + // destination is the app's own HDD path, which is a drive the CUSTOMER chooses at deploy + // time, and picking it for them is the decision this whole recovery path exists to leave + // with them. + // R-351: the refusal now NAMES the place the backup recorded, when it can read it. The + // prepared scratch already contains the unit, so this is a local file read — no network call, + // nothing restored, and it happens on a path that was going to refuse anyway. Telling + // somebody to reinstall without telling them where the data belongs is what forced the + // 2026-08-21 operator to remember two values the backup already held. + // R-356: this refusal used to be reached by `GetStackHDDPath(stack) == ""` — one predicate + // answering two questions. It now covers ONLY "the app is not deployed", and it stopped + // covering "the app has no drive". The reason the two came apart: the drive choice is the + // CUSTOMER's, and 40 of the 53 catalog apps were never offered one — they have no choice to + // leave with them, and their data lives on the system data path by design. The R-253 decision + // above is untouched for the 13 apps that DO have a drive to get wrong. + if !m.isStackDeployed(stack) { if rec := m.recordedPlacementFromScratch(scratch); rec.Known() { return res, fmt.Errorf("a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait. "+ "A mentése szerint az adatai itt voltak: %s. Telepítsd újra az alkalmazást (Alkalmazások) "+ @@ -232,6 +237,19 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack return res, fmt.Errorf("a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait — "+ "telepítsd újra az alkalmazást (Alkalmazások), utána ez a visszaállítás működni fog", stack) } + // The destination is resolved by the SAME rule the capture side used to write this snapshot + // (CaptureRecoveryUnit → GetAppDrivePath): the app's drive if it has one, the system data path + // otherwise. Anything else and the restore would aim at a different place than the backup came + // from, which is the mismatch prompt firing on a box where nothing actually moved. + hdd := strings.TrimSpace(m.GetAppDrivePath(stack)) + if hdd == "" { + // A DIFFERENT failure from the one above, so it gets a different sentence: the app IS + // installed, but the box cannot name its own data root (systemDataPath unset). Saying + // "nincs telepítve" here would send the customer to reinstall an app that is already + // running, and the real fault would stay invisible. + return res, fmt.Errorf("a(z) %s telepítve van, de a vezérlő nem tudja megállapítani, hová tartoznak az adatai "+ + "(nincs beállítva rendszer-adatterület). Ellenőrizd a tárhely beállításait (Tárhely), utána indítsd újra a visszaállítást", stack) + } liveNs := m.namespaceRoot(hdd) placements, err := mapOffsiteRestorePaths(paths, stack, scratch, liveNs) diff --git a/controller/internal/backup/offbox_reconstitute_test.go b/controller/internal/backup/offbox_reconstitute_test.go index 9683856..d3817b1 100644 --- a/controller/internal/backup/offbox_reconstitute_test.go +++ b/controller/internal/backup/offbox_reconstitute_test.go @@ -93,6 +93,9 @@ func reconFixture(t *testing.T, runID, dumpsAt string, dumpBody string) (*Manage m, sett := newOffboxManager(t) prov := &recordingProvider{offbox3aProvider: offbox3aProvider{ hdd: map[string]string{"immich": drive}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}, + // R-356: the app is DEPLOYED, stated as its own fact. It used to be implied by owning a + // drive; the restore now asks the two questions separately. + deployed: map[string]bool{"immich": true}, }} m.SetStackProvider(prov) if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "USB", Schedulable: true}); err != nil { diff --git a/controller/internal/backup/offbox_restore.go b/controller/internal/backup/offbox_restore.go index a232e37..3457277 100644 --- a/controller/internal/backup/offbox_restore.go +++ b/controller/internal/backup/offbox_restore.go @@ -407,16 +407,21 @@ func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error { return err } _ = id - // F-3a-1a: the live target uses the RAW HDD path (mirrors offboxCaptureSet). NOT AppNamespaceRoot — - // its systemDataPath fallback would merge userdata onto the SSD system namespace. Empty HDD ⇒ - // undeployed ⇒ refuse: the app must be restored first, then its data placed under its live drive. - hdd := "" - if m.stackProvider != nil { - hdd = strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack)) - } - if hdd == "" { + // F-3a-1a said: the live target uses the RAW HDD path, and an empty HDD means undeployed. + // R-356 split that: "undeployed" is now asked directly, and the destination is resolved by the + // SAME rule the capture side wrote this snapshot with (GetAppDrivePath — drive if the app has + // one, system data path otherwise). For the 13 needs_hdd apps nothing changes; for the 40 that + // were never offered a drive the old test was permanently true and this merge was unreachable. + if !m.isStackDeployed(stack) { return fmt.Errorf("a(z) %s nincs telepítve — előbb állítsd helyre az alkalmazást, utána az adatokat", stack) } + hdd := strings.TrimSpace(m.GetAppDrivePath(stack)) + if hdd == "" { + // Installed, but the box cannot name its own data root. Distinct reason ⇒ distinct sentence: + // telling the customer to reinstall a running app would hide the real fault. + return fmt.Errorf("a(z) %s telepítve van, de a vezérlő nem tudja megállapítani, hová tartoznak az adatai "+ + "(nincs beállítva rendszer-adatterület). Ellenőrizd a tárhely beállításait (Tárhely), utána indítsd újra a visszaállítást", stack) + } liveNs := m.namespaceRoot(hdd) // F-3a-1b: headroom gate — a missing-only merge copies at most the scratch size; refuse before any // copy if the live drive lacks that (conservative — scratch and live often share a drive). diff --git a/controller/internal/backup/r356_hot_only_restore_test.go b/controller/internal/backup/r356_hot_only_restore_test.go new file mode 100644 index 0000000..9c293fd --- /dev/null +++ b/controller/internal/backup/r356_hot_only_restore_test.go @@ -0,0 +1,314 @@ +package backup + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// R-356. Forty of the fifty-three catalogue apps declare no data drive. The off-site restore resolved +// its destination with the RAW HDD_PATH and treated an empty answer as "the app is not installed", so +// those forty were refused permanently — while running — with a message telling the customer to +// reinstall them "in the same place", a place those apps never offer. One predicate was answering two +// questions. This file pins the two questions apart. +// +// Measured in app-catalog-felhom.eu @ 459766cb1639: 53 templates, 13 `needs_hdd: true`, 40 `false`. + +// hotOnlyFixture is reconFixture with the app made DRIVELESS — the shape of those forty apps. The +// prepared scratch still resolves to the registered storage path (offboxRestoreScratchDir step 2), so +// the srcs the fixture laid down are still where the code looks for them; only the DESTINATION moves, +// which is precisely what this change is about. +// +// The manifest is rewritten to record the system data path, because that is what the capture side +// actually writes for a driveless app (CaptureRecoveryUnit → GetAppDrivePath → systemDataPath). +func hotOnlyFixture(t *testing.T) (*Manager, *recordingProvider, string) { + t.Helper() + m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1)) + sysPath := m.systemDataPath + if strings.TrimSpace(sysPath) == "" { + t.Fatal("fixture: systemDataPath must be set — the whole scenario is about falling back to it") + } + manPath := scratchManifestPath(t, m, "immich") + if err := writeManifest(manPath, &RecoveryManifest{ + SchemaVersion: 2, AppName: "immich", OffsiteRunID: "20260719T060000Z", + DumpsAt: "2026-07-19T06:00:00Z", + Drive: sysPath, NamespaceRoot: NamespaceRootFor(sysPath, sysPath), + }); err != nil { + t.Fatal(err) + } + // Driveless: the app has no HDD_PATH. It is still DEPLOYED — that is the whole point. + prov.hdd = map[string]string{} + if !m.isStackDeployed("immich") { + t.Fatal("fixture: the app must be deployed — a driveless app is not an absent app") + } + return m, prov, sysPath +} + +// SCENARIO A — a deployed app with NO drive restores, and lands on the system data path. +// +// WRONG OUTCOMES THIS PINS: (1) any error saying `nincs telepitve` for a running app; (2) a placement +// MISMATCH prompt, which would mean the restore resolver and the capture resolver disagree — the +// defect moving rather than closing. +func TestR356_ScenarioA_DrivelessAppRestoresToTheSystemDataPath(t *testing.T) { + m, prov, sysPath := hotOnlyFixture(t) + + res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false) + if err != nil { + t.Fatalf("a deployed driveless app must restore, got refusal: %v", err) + } + // THE STORED EFFECT, not the absence of an error: the destination the run actually resolved. + if got, want := filepath.Clean(res.Placement.LiveDrive), filepath.Clean(sysPath); got != want { + t.Errorf("destination drive = %q, want the system data path %q", got, want) + } + wantNs := NamespaceRootFor(sysPath, sysPath) + if got := filepath.Clean(res.Placement.LiveNamespaceRoot); got != filepath.Clean(wantNs) { + t.Errorf("destination namespace = %q, want %q", got, wantNs) + } + if !strings.HasSuffix(filepath.Clean(wantNs), string(filepath.Separator)+"felhom-data") { + t.Errorf("the system-data fallback must append the felhom-data namespace segment, got %q", wantNs) + } + // The capture and the restore must agree. A mismatch here is the same defect at a new address. + if res.Placement.Mismatch { + t.Errorf("recorded %q vs live %q reported as a MISMATCH — capture and restore disagree", + res.Placement.Recorded.Drive, res.Placement.LiveDrive) + } + if !res.Placement.Known { + t.Error("the fixture records a drive; the run must have read it back") + } + // The run reached the app: stopped, replayed, started. A "success" that touched nothing is the + // R-354 shape wearing a new hat. + joined := strings.Join(prov.calls, ",") + if !strings.Contains(joined, "stop") || !strings.Contains(joined, "start") { + t.Errorf("the restore must have driven the stack; calls: %v", prov.calls) + } +} + +// SCENARIO B — a deployed app WITH a drive is byte-for-byte unchanged. +// +// WRONG OUTCOME THIS PINS: the system-data fallback engaging for a drive app under any condition — +// that would restore a drive app's data onto the SSD, silently, which is the hazard the capture-side +// comment warns about. +func TestR356_ScenarioB_DriveAppDestinationIsStillItsDrive(t *testing.T) { + m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1)) + drive := prov.GetStackHDDPath("immich") + if drive == "" { + t.Fatal("fixture: the drive app must have a drive") + } + manPath := scratchManifestPath(t, m, "immich") + if err := writeManifest(manPath, &RecoveryManifest{ + SchemaVersion: 2, AppName: "immich", OffsiteRunID: "20260719T060000Z", + DumpsAt: "2026-07-19T06:00:00Z", Drive: drive, NamespaceRoot: drive, + }); err != nil { + t.Fatal(err) + } + res, err := m.ReconstituteFromOffsite(context.Background(), "immich", false) + if err != nil { + t.Fatalf("a drive app recording its own drive must restore: %v", err) + } + if got := filepath.Clean(res.Placement.LiveDrive); got != filepath.Clean(drive) { + t.Fatalf("destination = %q, want the app's own drive %q", got, drive) + } + if got := filepath.Clean(res.Placement.LiveDrive); got == filepath.Clean(m.systemDataPath) { + t.Fatalf("a drive app resolved to the SYSTEM data path %q — silent misplacement", got) + } + if res.Placement.Mismatch { + t.Error("same drive recorded and live must not report a mismatch") + } +} + +// SCENARIO B, second half — the mismatch refusal that protects a drive app still fires, and still +// needs the customer's explicit acknowledgement. +func TestR356_ScenarioB_DriveAppMismatchStillRefusesWithoutAck(t *testing.T) { + const recordedDrive = "/mnt/felhom-drives/hdd_9" + m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1)) + live := prov.GetStackHDDPath("immich") + manPath := scratchManifestPath(t, m, "immich") + if err := writeManifest(manPath, &RecoveryManifest{ + SchemaVersion: 2, AppName: "immich", Drive: recordedDrive, NamespaceRoot: recordedDrive, + }); err != nil { + t.Fatal(err) + } + callsBefore := len(prov.calls) + _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false) + if err == nil { + t.Fatal("a drive app whose recorded drive differs must still be REFUSED without an acknowledgement") + } + for _, must := range []string{recordedDrive, live} { + if !strings.Contains(err.Error(), must) { + t.Errorf("the refusal must name %q; got: %v", must, err) + } + } + if len(prov.calls) != callsBefore { + t.Errorf("the refusal must not touch the app; calls: %v", prov.calls[callsBefore:]) + } + if _, err := m.ReconstituteFromOffsite(context.Background(), "immich", true); err != nil { + t.Fatalf("an acknowledged placement change must still proceed: %v", err) + } +} + +// SCENARIO C — an app that is genuinely NOT installed is still refused, with the R-351 sentence and +// the recorded drive. +// +// WRONG OUTCOME THIS PINS: proceeding to a destination for an app that does not exist, placing data +// with nothing to read it and no way for the customer to see that happened. +func TestR356_ScenarioC_UndeployedAppIsStillRefused(t *testing.T) { + const recordedDrive = "/mnt/felhom-drives/hdd_1" + m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1)) + manPath := scratchManifestPath(t, m, "immich") + if err := writeManifest(manPath, &RecoveryManifest{ + SchemaVersion: 2, AppName: "immich", Drive: recordedDrive, NamespaceRoot: recordedDrive, + }); err != nil { + t.Fatal(err) + } + prov.deployed = map[string]bool{} // genuinely absent + callsBefore := len(prov.calls) + + _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false) + if err == nil { + t.Fatal("an app that is not installed must be refused") + } + if !strings.Contains(err.Error(), "nincs telep") { + t.Errorf("the not-installed refusal must keep its sentence; got: %v", err) + } + if !strings.Contains(err.Error(), recordedDrive) { + t.Errorf("the refusal must name the recorded drive; got: %v", err) + } + if len(prov.calls) != callsBefore { + t.Errorf("a refused restore must not touch the app; calls: %v", prov.calls[callsBefore:]) + } +} + +// SCENARIO C, control — the predicate must not be satisfied by an app of a merely SIMILAR name, and +// an empty name is never deployed. +func TestR356_IsStackDeployedIsExactAndFailsClosed(t *testing.T) { + m, prov, _ := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1)) + if !m.isStackDeployed("immich") { // POSITIVE control + t.Error("a deployed app must be reported deployed") + } + if m.isStackDeployed("immich-2") { // NEGATIVE control + t.Error("an app that is not in the deployed set must not be reported deployed") + } + if m.isStackDeployed("") { + t.Error("an empty stack name must never be reported deployed") + } + prov.deployed = map[string]bool{} + if m.isStackDeployed("immich") { + t.Error("clearing the deployed set must be visible to the predicate") + } + // Nil provider ⇒ we cannot tell ⇒ FALSE. The caller's next act is a WRITE; "cannot tell" must + // fail into the recoverable refusal, never into a copy. + bare := &Manager{} + if bare.isStackDeployed("immich") { + t.Error("a Manager with no stack provider must fail CLOSED") + } +} + +// SCENARIO D — deployed, driveless, and the box cannot name its own data root. Fails closed with a +// DIFFERENT sentence: the app is installed, so telling the customer to reinstall it would hide the +// real fault. +func TestR356_ScenarioD_NoDataRootRefusesWithItsOwnReason(t *testing.T) { + m, _, _ := hotOnlyFixture(t) + m.systemDataPath = "" // the box cannot resolve its own data root + + _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false) + if err == nil { + t.Fatal("an unresolvable destination must be REFUSED, never guessed") + } + if strings.Contains(err.Error(), "nincs telep") { + t.Errorf("a running app must not be told it is not installed; got: %v", err) + } + // The reason AND a route. „Tarhely" is the ASCII stem of „Tárhely" — accented bytes stay out of + // the comparison. + if !strings.Contains(err.Error(), "rhely") { + t.Errorf("the refusal must name a route the customer can take; got: %v", err) + } +} + +// SCENARIO E — PlaceOffsiteRestore is a SEPARATE wizard intent with its own copy of the same +// refusal. Fixing one entry point and leaving the other is the wrong outcome: the customer reaches +// both from the same page. +func TestR356_ScenarioE_PlaceDrivelessAppMergesOntoTheSystemNamespace(t *testing.T) { + m, prov, scratch, copies := placeFixture(t, true) + drive := prov.hdd["immich"] + prov.hdd["immich"] = "" // driveless, still deployed + + // The scratch was laid out against the drive namespace, which is where offboxRestoreScratchDir + // still resolves (the drive stays a registered storage path). Only the DESTINATION moves. + if _, err := os.Stat(scratch); err != nil { + t.Fatalf("fixture: scratch must exist: %v", err) + } + var dsts []string + m.SetOffboxPlaceCopier(func(_, dst string) (int, error) { dsts = append(dsts, dst); return 1, nil }) + + if err := m.PlaceOffsiteRestore(context.Background(), "immich"); err != nil { + t.Fatalf("a deployed driveless app must be placeable, got: %v", err) + } + if *copies != 0 { + t.Fatalf("fixture: the counter copier was replaced, got %d", *copies) + } + if len(dsts) == 0 { + t.Fatal("nothing was placed — a merge that copies nothing is the failure this task exists to end") + } + wantNs := filepath.Clean(NamespaceRootFor(m.systemDataPath, m.systemDataPath)) + for _, d := range dsts { + if !strings.HasPrefix(filepath.Clean(d), wantNs) { + t.Errorf("placement %q is not under the system namespace %q", d, wantNs) + } + if strings.HasPrefix(filepath.Clean(d), filepath.Clean(drive)+string(filepath.Separator)) { + t.Errorf("placement %q landed on the old drive — the destination did not move", d) + } + } +} + +// SCENARIO E — the drive app's placement destination is unchanged. +func TestR356_ScenarioE_PlaceDriveAppStillTargetsItsDrive(t *testing.T) { + m, prov, _, copies := placeFixture(t, true) + drive := prov.hdd["immich"] + var dsts []string + m.SetOffboxPlaceCopier(func(_, dst string) (int, error) { dsts = append(dsts, dst); return 1, nil }) + if err := m.PlaceOffsiteRestore(context.Background(), "immich"); err != nil { + t.Fatalf("a drive app must still be placeable: %v", err) + } + _ = copies + if len(dsts) == 0 { + t.Fatal("the drive app placed nothing") + } + for _, d := range dsts { + if !strings.HasPrefix(filepath.Clean(d), filepath.Clean(drive)) { + t.Errorf("placement %q left the app's own drive %q", d, drive) + } + } +} + +// SCENARIO E — the not-installed refusal on the place path, unchanged. +func TestR356_ScenarioE_PlaceUndeployedStillRefused(t *testing.T) { + m, prov, _, copies := placeFixture(t, true) + prov.deployed = map[string]bool{} + prov.hdd["immich"] = "" + err := m.PlaceOffsiteRestore(context.Background(), "immich") + if err == nil || !strings.Contains(err.Error(), "nincs telep") { + t.Fatalf("an undeployed app must still refuse with its own sentence, got %v", err) + } + if *copies != 0 { + t.Errorf("the copier must not run for an undeployed app, got %d", *copies) + } +} + +// SCENARIO D on the place path — installed but no resolvable data root. +func TestR356_ScenarioD_PlaceNoDataRootRefusesWithItsOwnReason(t *testing.T) { + m, prov, _, copies := placeFixture(t, true) + prov.hdd["immich"] = "" + m.systemDataPath = "" + err := m.PlaceOffsiteRestore(context.Background(), "immich") + if err == nil { + t.Fatal("an unresolvable destination must refuse") + } + if strings.Contains(err.Error(), "nincs telep") { + t.Errorf("a running app must not be told it is not installed; got: %v", err) + } + if *copies != 0 { + t.Errorf("nothing may be copied when the destination is unknown, got %d", *copies) + } +}