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
+65
View File
@@ -1330,6 +1330,71 @@ func (s *Settings) IsNetworkStoragePath(path string) bool {
return false
}
// RefuseAsAppNamespace reports whether `path` must be REFUSED as an app's data namespace (its
// HDD_PATH), and why. It is the single predicate every placement surface consults — R-108.
//
// WHY AN APP NAMESPACE MAY NOT LIVE ON A NAS (operator ruling, 2026-07-30). An app's namespace root is
// also where its backups go: `namespaceRoot(drivePath)` returns a non-system drive path AS-IS, so the
// app's recovery unit lands at `<path>/backups/primary/<stack>/` (appbackup.RecoveryUnitPath). For a
// network share that directory would sit inside the share ROOT — which FileBrowser binds whole, with
// `download: true`, and MUST keep binding whole: the `:rslave` share-root bind is load-bearing for
// automount wake/idle propagation into the running container (R-67), and scoping it is impossible
// besides — apps on a share store at `<share>/<app>`, there is no `userdata/` layer, and creating one
// would write Felhom's convention onto a customer's own NAS, which R-67 forbids outright.
//
// So the browsing surface cannot be narrowed and the backup tree must therefore never be placed under
// it. Tier 2 already refuses network targets for exactly this class of reason (F-6C-1); this closes the
// PRIMARY namespace, which was the remaining way a `backups/` tree could appear inside a share-root
// bind. That is the precondition D5 was waiting on.
//
// FAIL CLOSED, and the two non-obvious cases are why this is a function and not an `IsNetwork()` call:
//
// - `NetworkMountRoot` holds BOTH kinds in-guest (`/mnt/felhom-drives/hdd_1` is a local drive,
// `/mnt/felhom-drives/Felhom-Share` is a NAS), so a path prefix CANNOT classify. `Kind` is the only
// discriminator, and it exists only on a REGISTERED path.
// - therefore an UNREGISTERED path under `NetworkMountRoot` is un-classifiable, and un-classifiable
// must refuse. Allowing it would be a fallback to "probably a drive" on the one surface that
// accepts an arbitrary caller-supplied path (the deploy POST validates only `os.Stat` existence).
// Every NAS share is registered under this root by construction (see NetworkMountRoot), so refusing
// the unregistered case makes the network set completely covered without touching drives.
//
// An empty path is ALLOWED: it means the app is system/SSD-resident and has no external namespace at
// all. A nil receiver refuses — we cannot consult the registry, so we cannot tell.
func (s *Settings) RefuseAsAppNamespace(path string) (bool, string) {
path = strings.TrimSpace(path)
if path == "" {
return false, "" // SSD-resident: no external namespace to place
}
if s == nil {
return true, refuseAppNamespaceUndeterminable
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
if sp.IsNetwork() {
return true, refuseAppNamespaceNetwork
}
return false, "" // a registered DRIVE — the supported case, unchanged
}
}
// Not registered. Under the shared mount root its kind is undeterminable → refuse (see above).
if path == NetworkMountRoot || strings.HasPrefix(path, NetworkMountRoot+"/") {
return true, refuseAppNamespaceUndeterminable
}
return false, ""
}
// Refusal reasons for RefuseAsAppNamespace. Hungarian, adult tone, no emoji — these reach the customer
// through the deploy/migrate error surfaces. They name the storage class and what to do instead, never
// an internal path or field name.
const (
refuseAppNamespaceNetwork = "Hálózati tárhelyen (NAS) nem futtatható alkalmazás adatkönyvtára — " +
"a NAS megosztás tallózásra és médiatárolásra használható. Válasszon csatlakoztatott meghajtót."
refuseAppNamespaceUndeterminable = "A megadott tárhely nem azonosítható regisztrált meghajtóként, " +
"ezért alkalmazás adatkönyvtáraként nem használható. Válasszon a listából csatlakoztatott meghajtót."
)
// IsStoragePathSchedulable returns whether a path belongs to a registered,
// schedulable (active) storage path. Returns false if the path is unknown,
// disconnected, decommissioned, or inactive.