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
+19
View File
@@ -90,6 +90,12 @@ type DeployStoragePath struct {
settings.StoragePath
FreeHuman string // "234.5 GB"
FreePercent float64 // 67.5
// NotAllowed marks a path that CANNOT host an app's data namespace (R-108: network storage). The
// option is rendered PRESENT-but-disabled with NotAllowedNote rather than dropped: a NAS the
// customer registered themselves, silently absent from the list they expect it in, reads as a bug
// and generates a support question. Present with a reason answers the question in place.
NotAllowed bool
NotAllowedNote string // short parenthetical for the option label; "" when allowed
}
// StorageAppDetail holds info about an app using a specific storage path.
@@ -462,6 +468,12 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
var deployPaths []DeployStoragePath
for _, sp := range s.settings.GetSchedulableStoragePaths() {
dp := DeployStoragePath{StoragePath: sp}
// R-108: mark, do not hide. The server-side refusal in the deploy POST is the boundary; this is
// the honest UI over it, and it must not be mistaken for the boundary itself.
if refuse, _ := s.settings.RefuseAsAppNamespace(sp.Path); refuse {
dp.NotAllowed = true
dp.NotAllowedNote = "hálózati tárhely — alkalmazáshoz nem választható"
}
if di := system.GetDiskUsage(sp.Path); di != nil {
dp.FreeHuman = formatFreeSpace(di.AvailGB)
if di.TotalGB > 0 {
@@ -676,6 +688,13 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
if sp.Path == current || sp.Decommissioned || sp.Disconnected || !sp.Schedulable {
continue
}
// R-108: never OFFER network storage as a migrate target — an app namespace may not live
// there. Dropped rather than shown-disabled: unlike the deploy page this list has no
// explanatory surface, and a target that cannot be chosen is not a target. The refusal that
// MATTERS is server-side in handleStorageMigrateApp; this only keeps the UI honest.
if refuse, _ := s.settings.RefuseAsAppNamespace(sp.Path); refuse {
continue
}
targets = append(targets, sp)
}
data["MigrateTargets"] = targets