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
+74
View File
@@ -1,5 +1,79 @@
## Changelog
### v0.187.0 — R-108: network storage may not host an app's data namespace (2026-07-30) — MinAgent 0.113.0 (unchanged)
**This is D5's precondition, and it is now met.** D5 moves app secrets into the local recovery unit so
Tier-1/Tier-2 restore stop needing the guest; that is safe only once no browsing surface can reach the
backup tree. One could.
**The chain, confirmed at source end to end.** `namespaceRoot(drivePath)` returns any non-system drive
path AS-IS (`internal/backup/backup.go:262`), so an app's namespace root IS its `HDD_PATH`. Its recovery
unit therefore lands at `<HDD_PATH>/backups/primary/<stack>/` (`appbackup.RecoveryUnitPath`). Put an app
on a NAS and that directory sits inside the share, which FileBrowser binds **whole** — share ROOT,
`:rslave`, `download: true`. Live on demo-hp, the asymmetry visible in one glance:
- /mnt/felhom-drives/nvme-1tb/userdata:/srv/nvme-1tb <- drive: userdata-SCOPED
- /mnt/felhom-drives/Felhom-Share:/srv/Felhom-Share:rslave <- share: ROOT
**Why the bind was NOT narrowed** (this was the real finding, and it inverted the fix). The share-root
`:rslave` bind is **load-bearing**, not an oversight: a Phase-0 probe (2026-07-22) proved an in-container
access through it wakes the idle automount trigger, so narrowing it breaks NAS access itself. And
scoping is not even definable — apps on a share store at `<share>/<app>`, there is no `userdata/` layer,
and creating one would write Felhom's directory 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. **Operator ruling 2026-07-30: refuse the placement, keep the browse bind.** Tier 2
already refuses network targets for this same class of reason (`F-6C-1`); this closes the PRIMARY
namespace, which was the last way a `backups/` tree could appear inside a share-root bind.
**Nothing is stranded.** Verified across all six hub customers including Peti: zero apps on network
storage. demo-hp's `Felhom-Share` holds only the customer's own files (no `backups/`). R-67's browse
capability is untouched — same bind, same `:rslave`, byte-identical.
**FIVE surfaces, not the four the register named.** `settings.RefuseAsAppNamespace` is the single
predicate all of them consult:
1. **the deploy POST** (`internal/api/router.go`) — **this is the boundary.** The R-108 row says "the
deploy dropdown has no `IsNetwork()` filter", which understates it: the dropdown is a UI list, and
this endpoint accepts whatever `HDD_PATH` a caller supplies, with `DeployStack` validating only that
it EXISTS (`os.Stat`, `internal/stacks/deploy.go`). Filtering the list alone would have left the
surface open.
2. **per-app migrate targets** (`internal/web/handlers.go`) — dropped from the offered list.
3. **`handleStorageMigrateApp`** — refused before `MigrateApp`, so no job starts.
4. **`handleStorageDecommission` mode=migrate, the TARGET** — **not in the register.** The existing
`refuseNetworkLifecycle` guards `req.Where`, the SOURCE; the target was unchecked, so a whole
namespace could be decommissioned ONTO a NAS. Found by enumerating the set rather than trusting the
four that were named.
5. **the FileBrowser bind** — deliberately unchanged, and now pinned by a test so it cannot drift.
**FAIL CLOSED, and the non-obvious part is why this is a function and not an `IsNetwork()` call:**
`/mnt/felhom-drives` holds BOTH kinds in-guest (`.../hdd_1` is a local drive, `.../Felhom-Share` is a
NAS), so a path prefix cannot classify — `Kind` is the only discriminator and it exists only on a
REGISTERED path. An unregistered path under that root is therefore un-classifiable, and un-classifiable
refuses. Every share is registered under that root by construction, so the network set is completely
covered without touching drives.
**UI (§5): marked, not hidden.** A registered NAS stays in the deploy dropdown, `disabled`, labelled
`(hálózati tárhely — alkalmazáshoz nem választható)`, and never pre-selected even when it is the
registry default. A share the customer registered themselves, silently missing from the list they
expect it in, reads as a bug and generates a support question; present-with-a-reason answers it in
place. Follows the existing `(nem elérhető)` disabled-option precedent.
Tests: 9 new, all asserting the **non-effect**. The refusal tests run against a Server with a
deliberately **nil `stackMgr`**, so a guard that fails to fire reaches the mutation and PANICS rather
than passing quietly. They assert no job id, no `started` flag, no `MigratedTo` written, and — for
decommission — that the source was NOT soft-marked. Fixtures are demo-hp's real two-class storage set
(both paths under the same mount root, which is the trap). Red-proofs: 4, each mutation asserted to
have landed first — drop either migrate guard → panic; break fail-closed → 3 tests; userdata-scope the
share → the R-67 regression guard fires, quoting the broken bind.
Suite rc=0, 27 packages, 0 FAIL; `go vet` rc=0; `template_id_gate.py` + `emoji_gate.py` both OK.
- `internal/settings/settings.go``RefuseAsAppNamespace` + the two Hungarian refusal reasons.
- `internal/api/router.go` — the deploy-POST refusal.
- `internal/web/storage_handlers.go``refuseAppNamespaceTarget`; wired into migrate-app + decommission.
- `internal/web/handlers.go` — migrate-target list filter; `DeployStoragePath.NotAllowed`.
- `internal/web/templates/deploy.html` — disabled option + reason; no pre-select of a disabled default.
### v0.186.0 — R-114 + R-112: tell the truth about the backup target, then show it (2026-07-29) — MinAgent 0.113.0 (unchanged)
Two defects E-2d found on a real box, fixed in this order deliberately: the message is corrected