R-108: network storage may not host an app's data namespace (v0.187.0)

This is D5's precondition and it is now met.

An app's namespace root IS its backup root: namespaceRoot returns a non-system
drive path as-is, so the recovery unit lands at <HDD_PATH>/backups/primary/<stack>/.
On a NAS that sits inside the share, which FileBrowser binds WHOLE — share root,
:rslave, download:true.

The bind was NOT narrowed, and establishing why inverted the fix. The share-root
:rslave bind is load-bearing (a 2026-07-22 probe proved an in-container access
through it wakes the idle automount trigger), and scoping is undefinable anyway:
apps on a share store at <share>/<app>, there is no userdata/ layer, and creating
one would write Felhom convention onto a customer's own NAS, which R-67 forbids.
So the browsing surface cannot be narrowed and the backup tree must never be
placed under it. Operator ruling: refuse the placement, keep the browse bind.
Tier 2 already refuses network targets for this reason (F-6C-1).

Nothing stranded: zero apps on network storage across all six hub customers
including Peti. R-67's browse capability is byte-identical.

FIVE surfaces, not the four the register named — settings.RefuseAsAppNamespace is
the single predicate. The deploy POST is the real boundary (it accepts any
caller-supplied HDD_PATH; DeployStack validates only os.Stat). Surface 4,
handleStorageDecommission mode=migrate, guarded only its SOURCE, so a whole
namespace could be decommissioned ONTO a NAS — that one is not in the register.

Fails closed: /mnt/felhom-drives holds both kinds, Kind exists only on a
registered path, so an unregistered path under that root refuses.

Supersedes README's "NAS backup locality — decision A" (v0.118.0).

9 tests, all non-effect (nil stackMgr, so a guard that misses panics rather than
passing). 4 red-proofs, each mutation asserted to have landed.
Suite rc=0, 27 packages, 0 FAIL. vet rc=0. Template + emoji gates OK.
This commit is contained in:
2026-07-30 14:10:20 +02:00
parent b331f18424
commit 2f27a363d5
10 changed files with 693 additions and 84 deletions
@@ -320,6 +320,25 @@ func (s *Server) refuseNetworkLifecycle(w http.ResponseWriter, where string) boo
return false
}
// refuseAppNamespaceTarget blocks a placement that would put an app's data namespace on storage that
// cannot host one — today: network storage, and any path whose kind cannot be determined (R-108).
// Returns true when it has already written the refusal, so callers `return` immediately.
//
// DISTINCT from refuseNetworkLifecycle above, and both are needed. That one answers "may I run a DRIVE
// lifecycle op on this path" (a NAS has no device lifecycle) and is applied to the op's SUBJECT. This
// one answers "may an app's data live here" and is applied to a placement TARGET. The migrate handlers
// need both: the source must be a drive to be migrated off, and the target must be able to hold a
// namespace. Collapsing them into one predicate would make one of the two questions unaskable.
func (s *Server) refuseAppNamespaceTarget(w http.ResponseWriter, target string) bool {
refuse, why := s.settings.RefuseAsAppNamespace(target)
if !refuse {
return false
}
s.logger.Printf("[WARN] [web] placement refused: target cannot host an app namespace (R-108)")
writeDiskJSON(w, http.StatusBadRequest, false, why, nil)
return true
}
// ---- HTTP handlers (behind RequireAuth + CsrfProtect) -----------------------------------------
// storageWizardPageHandler renders the init/attach wizard page (the disk list + actions are driven
@@ -422,6 +441,13 @@ func (s *Server) handleStorageMigrateApp(w http.ResponseWriter, r *http.Request)
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
// R-108: the TARGET may not be network storage. Its whole-namespace sibling
// (handleStorageMigrate) has refused both endpoints since the network class was introduced; this
// per-app path never followed, which is the asymmetry the R-108 row was filed on. The refusal is
// BEFORE MigrateApp, so a refused call starts no job and mutates nothing.
if s.refuseAppNamespaceTarget(w, strings.TrimSpace(req.Target)) {
return
}
id, err := s.stackMgr.MigrateApp(r.Context(), strings.TrimSpace(req.App), strings.TrimSpace(req.Target))
if err != nil {
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
@@ -466,6 +492,14 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
writeDiskJSON(w, http.StatusBadRequest, false, "céltároló kötelező az áthelyezéshez", nil)
return
}
// R-108: the refuseNetworkLifecycle above guards `req.Where` — the SOURCE. The TARGET was
// never checked, so decommission-with-migrate could move an entire namespace ONTO a NAS. This
// surface is NOT in the R-108 row; it was found by enumerating the set (§3.2) rather than
// trusting the four the row named. Refused before MigrateAllAndDecommission, so nothing moves
// and the source is not marked decommissioned.
if s.refuseAppNamespaceTarget(w, strings.TrimSpace(req.Target)) {
return
}
// Start the migration; the done-hook (onMigrationDone) soft-marks + agent-decommissions the
// source once every app has moved and come up on the target. A VALIDATE refusal returns here.
id, err := s.stackMgr.MigrateAllAndDecommission(r.Context(), req.Where, strings.TrimSpace(req.Target))