R-356: the off-site restore refused every app that has no data drive
gates / gates (push) Failing after 12s
gates / gates (push) Failing after 12s
ReconstituteFromOffsite and PlaceOffsiteRestore both resolved the restore destination with the RAW HDD_PATH and read an empty answer as "the app is not installed". For 40 of the 53 catalogue apps that answer is correctly empty and permanent, so both actions refused forever for a running, healthy app — and told the customer to reinstall it "in the same place", which those apps never offer. Separate the two questions. "Installed?" is asked of ListDeployedStacks via a new Manager.isStackDeployed that fails CLOSED on a nil provider. "Where?" is answered by GetAppDrivePath — the same resolver CaptureRecoveryUnit wrote the snapshot with, so the restore aims at the place the backup came from. The 13 drive apps are unchanged: own drive, mismatch check, ack still required. A third refusal, with its own sentence, covers installed-but-no-resolvable-root. Fixtures that marked an app "installed" by giving it an HDD path now state deployment as its own fact. No assertion weakened.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user