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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user