R-356: the off-site restore refused every app that has no data drive
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:
2026-08-22 13:08:46 +02:00
parent 2da259af38
commit 08eb1a6e3a
9 changed files with 541 additions and 25 deletions
@@ -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)