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:
@@ -1,96 +1,109 @@
|
||||
# REPORT — R-114 + R-112: tell the truth about the backup target, then show it (v0.186.0, 2026-07-29)
|
||||
# REPORT — R-108: network storage may not host an app's data namespace (v0.187.0, 2026-07-30)
|
||||
|
||||
**Overwritten** per the standing rule. Controller **v0.185.1 → v0.186.0**. Session B of three
|
||||
(A → R-113 agent v0.114.0 shipped-unvalidated · **B → this** · C → one drill rebuild proving all three).
|
||||
`felhom-agent` untouched. **NOTHING HERE IS LIVE-VALIDATED** — see the bottom.
|
||||
**Overwritten** per the standing rule. Controller **v0.186.0 → v0.187.0**, MinAgent 0.113.0 (unchanged).
|
||||
|
||||
## Baselines
|
||||
**The §7 sentence, up front: D5's precondition IS now met.** No app namespace can be placed on network
|
||||
storage, therefore no `backups/` tree can exist inside FileBrowser's share-root bind, therefore moving
|
||||
app secrets into the local recovery unit no longer exposes them to a browsing surface. Nothing else was
|
||||
blocking D5 — the §10.1 sweep had already cleared every other read surface.
|
||||
|
||||
felhom-controller `cdaeb369726b` v0.185.1 · felhom-agent `b58d7bcf39a9` v0.114.0 (untouched) ·
|
||||
felhom.eu `338b2ccf86`. All clean and pushed at start.
|
||||
## 1. The §3.1 ruling: REFUSE the placement, KEEP the browse bind
|
||||
|
||||
**MinAgent decision: unchanged at 0.113.0.** R-114 reads `BackupTarget`, `MountPath`, `GuestPath`,
|
||||
`Role` — **none of which R-113 altered**; it changed `BoundUnderParent`, which this code does not
|
||||
read (`internal/agentapi/client.go:303-338`). Consequence, stated rather than discovered: **demo-hp,
|
||||
still on agent 0.113.0, is not held** and needs no move for this release.
|
||||
The task offered "scope the bind" or "refuse". **Scoping is not implementable**, and establishing that
|
||||
inverted the fix:
|
||||
|
||||
## Part 1 — R-114: the third state
|
||||
- The share-root `:rslave` bind is **load-bearing, not an oversight**. A Phase-0 probe (2026-07-22,
|
||||
recorded in the `buildFileBrowserPaths` doc comment) proved an in-container access through it wakes
|
||||
the idle automount trigger. Narrow it and NAS access itself breaks.
|
||||
- There is **no `userdata/` layer to scope to**. Apps on a share store at `<share>/<app>`
|
||||
(`deployedAppsOnPath` doc comment, `internal/web/netstorage_handlers.go`), not under `<share>/userdata/`.
|
||||
- Creating one is **forbidden**: *"Never a skeleton, never any write toward the NAS — Felhom conventions
|
||||
must not be written onto a customer's own NAS."*
|
||||
|
||||
`resolveBackupTargetState` had two outcomes — a disk claims the target (healthy), or nothing does
|
||||
(degraded, "the backup is on the system disk"). *Configured, and its drive is gone* had no branch, so
|
||||
it fell into the second and inherited its message **and** its offer. Observed live:
|
||||
`degraded:true, target:"felhom-backup"` + the system-disk copy (false) + `offer_path` naming the drive
|
||||
that had just vanished (`audits/E2D-fresh-vm-2026-07-29.md` §5.3).
|
||||
So the browsing surface cannot be narrowed, and the backup tree must never be placed under it. Tier 2
|
||||
already refuses network targets for this same class of reason (`F-6C-1`); R-108 closes the PRIMARY
|
||||
namespace, the last route by which a `backups/` tree could land inside a share-root bind.
|
||||
|
||||
- New `BackupTargetState.TargetAbsent`. `Degraded` keeps its meaning — *is there a problem* — so the
|
||||
wire contract is unchanged for every consumer; `TargetAbsent` answers *which* problem, because the
|
||||
two have opposite remedies.
|
||||
- Discriminator `targetIsConfiguredDrive`: a real storage id, versus the builtin `local` or an unset
|
||||
tier (both = never configured).
|
||||
- Copy routed through `degradedMessageFor`, so one place still decides what a customer reads.
|
||||
- **Offer suppressed on the branch itself**, not left to `firstOfferableDrive`'s `Disconnected` skip —
|
||||
that flag comes from the agent-side gate in another repo, and this state must be right without it.
|
||||
**Operator decision required and taken** (the ruling removes a capability going forward). Evidence
|
||||
supplied with the question: **zero apps on network storage across all six hub customers including Peti**
|
||||
— nothing is stranded, and no migration is owed. R-67's browse capability is byte-identical after.
|
||||
|
||||
## Part 2 — R-112: the state gets a consumer
|
||||
## 2. The exposure chain, confirmed at source
|
||||
|
||||
The endpoint was byte-correct and **nothing asked for it** — templates fetch 18 distinct
|
||||
`/api/storage/*` endpoints and these were the only two with zero references (§5.1).
|
||||
`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 is
|
||||
`<HDD_PATH>/backups/primary/<stack>/` (`internal/appbackup/paths.go`). On a NAS that sits inside the
|
||||
share, which FileBrowser binds whole with `download: true`.
|
||||
|
||||
Server-rendered on `/backups`, following the existing `SingleCopyWarning` banner at `backups.html:11`.
|
||||
`backupsHandler` → `backupTargetView` → template. **Not a 19th JS fetch**: the state is already
|
||||
resolved when the page is built, and a banner needing JavaScript to appear is one more thing that can
|
||||
silently not happen. `backupTargetView` returns **nil** for healthy and unknown, so a template typo
|
||||
cannot decorate a working box. The offer control POSTs to the existing assign endpoint behind the
|
||||
standard inline confirm, never auto-submits, and surfaces `restart_required` honestly.
|
||||
This was **documented, deliberate behaviour**, not an accident: `controller/README.md` carried *"NAS
|
||||
backup locality (v0.118.0, CAMPAIGN-3 Part 4 — decision A) — a NAS-resident app's tier-1 artifacts live
|
||||
on the NAS itself"*. R-108 supersedes decision A; the README block now says so.
|
||||
|
||||
**Placement:** the E-2 design doc names no surface — `07-backup-architecture.md` has no banner or
|
||||
degraded reference, and the only `E-2` string in the capability map is the unrelated `CAMPAIGN-6E-2`.
|
||||
So the choice is mine: the backups page already hosts a same-severity backup warning and the copy is
|
||||
about `rendszermentés`.
|
||||
## 3. §2 citation reconfirmation — all four hold, line numbers moved
|
||||
|
||||
## Files
|
||||
|
||||
`internal/web/backup_target_offer.go` (third state, view, tiers seam) · `internal/web/server.go`
|
||||
(`tiersFn`) · `internal/web/handlers.go` (one line wiring the view) ·
|
||||
`internal/web/templates/backups.html` (banner + offer + assign script) · two new test files ·
|
||||
`CHANGELOG.md` · `REUSE.md` · `controller/README.md`.
|
||||
|
||||
## Tests: 326 → 338 (+12) in `internal/web`. Suite green (27 packages), both template gates pass.
|
||||
|
||||
**Scenario E — the seam test, which is the one whose absence let E-2 ship.** It does *not* call the
|
||||
resolver and assert a string (that proves the resolver, which was never broken). It drives
|
||||
`backupsHandler` over `httptest` and asserts the **rendered HTML**, travelling
|
||||
handler → `backupTargetView` → `resolveBackupTargetState` → `degradedMessageFor` → the production
|
||||
`backups` template.
|
||||
|
||||
| Red-proof | Mutation | Failure text produced |
|
||||
| register citation | now at | verdict |
|
||||
|---|---|---|
|
||||
| 1 | delete the `targetIsConfiguredDrive` branch | `TargetAbsent is false for target "felhom-backup"…` **and** `offered "/mnt/felhom-drives/mentes2" while the configured target is absent — E-2d observed this exact payload offering the drive that had just vanished` |
|
||||
| 2 | drop the absent case from `degradedMessageFor` | `the customer is told the backup is on the SYSTEM DISK, which is false…` + all three copy fragments missing |
|
||||
| 3 | delete `data["BackupTarget"] = …` from the handler | `the absent-drive copy never reached the page…` + every render assertion in the never-configured case |
|
||||
| `handlers.go:2450-2460` local userdata-scoped | `:2464-2465` | **holds** |
|
||||
| `handlers.go:2432` network share-ROOT + download | bind `:2437`, `download: true` at `infra/infra.go:328` | **holds** |
|
||||
| `settings.go:904-914` no `IsNetwork()` filter | `GetSchedulableStoragePaths`, `:926` | **holds** |
|
||||
| `handlers.go:674-679` migrate targets unfiltered | `:673-678` | **holds** |
|
||||
| `storage_handlers.go:410-424` migrate-app unguarded | `:416-425` | **holds** |
|
||||
| `storage_handlers.go:397` sibling refuses | `handleStorageMigrate`, `:404` (both source AND target) | **holds — the precedent** |
|
||||
|
||||
All three reverted; files verified byte-identical after.
|
||||
## 4. §3.2 — the set is FIVE, not four
|
||||
|
||||
**Negative assertions:** healthy and unknown are checked for the absence of **all three** copy strings
|
||||
and of the banner element — not for the absence of an error.
|
||||
Surface 4 below is **not in the register**; it was found by enumerating rather than trusting the named set.
|
||||
|
||||
**A test caught its own imprecision:** the first Scenario-C assertion matched the bare id
|
||||
`backup-target-assign`, which also appears inside the page script that always renders, so it reported
|
||||
a control that was not there. Tightened to the control's own markup.
|
||||
1. **deploy POST** (`internal/api/router.go`) — **the actual boundary.** The register's "the dropdown has
|
||||
no filter" understates it: the dropdown is a UI list, this endpoint takes any caller-supplied
|
||||
`HDD_PATH`, and `DeployStack` validates only `os.Stat` existence. A list filter alone would have left
|
||||
the surface open.
|
||||
2. **per-app migrate target list** (`internal/web/handlers.go`) — dropped from the offer.
|
||||
3. **`handleStorageMigrateApp`** — refused before `MigrateApp`.
|
||||
4. **`handleStorageDecommission` mode=migrate TARGET** — `refuseNetworkLifecycle` guarded only
|
||||
`req.Where` (the SOURCE); a whole namespace could be decommissioned ONTO a NAS. **NEW.**
|
||||
5. **the FileBrowser bind** — deliberately unchanged, now pinned by a regression test.
|
||||
|
||||
## NOT LIVE-VALIDATED — awaiting Session C
|
||||
Also noted, not fixed (register already records it as a separate effect): `storageDriveList()`
|
||||
(`handler_export.go`) does not filter network paths, so a `.fab` bundle — plaintext secrets, optional
|
||||
password — can still be **exported onto** a NAS. That is a bundle destination, not a namespace
|
||||
placement, and it is outside R-108's scope. Filed as **R-126**.
|
||||
|
||||
- **Scenario C cannot occur on a healthy box.** The demo guest has a present target, so the
|
||||
absent-drive banner is unproven live by construction.
|
||||
- Untested live: the offer control's click → assign → `restart_required` surfacing.
|
||||
- The live check in this session proves only that the seam is wired and which render a healthy box
|
||||
produces — that is R-112's whole point, but it is not proof of R-114.
|
||||
## 5. Fail-closed, and why it needed a function
|
||||
|
||||
## Observations — filed, not fixed
|
||||
`/mnt/felhom-drives` holds **both** kinds in-guest (`.../nvme-1tb` is a drive, `.../Felhom-Share` is a
|
||||
NAS), so a path prefix cannot classify. `Kind` is the only discriminator and exists only on a REGISTERED
|
||||
path — therefore an unregistered path under that root is un-classifiable, and un-classifiable **refuses**.
|
||||
Every share is registered under that root by construction, so the network set is fully covered without
|
||||
touching drives. Empty `HDD_PATH` (SSD-resident) stays allowed; a nil registry refuses.
|
||||
|
||||
1. **The absent copy now lives in two repos** — here and hub `internal/notify/templates.go:93` — with
|
||||
nothing binding them but `TestAbsentCopyMatchesTheHubEmailWordForWord`, which only fails if *this*
|
||||
side drifts. Reword the hub and the test still passes while the banner and the email disagree.
|
||||
2. **`resolveBackupTargetState` bypassed the existing `disksFn` seam** and called the client directly,
|
||||
which is why its branching had no test at all before this session. Now routed through `fetchDisks`
|
||||
with a sibling `tiersFn` — worth checking whether other agent-reading paths do the same.
|
||||
## 6. Tests — 9 new, all non-effect; seams named (R-125)
|
||||
|
||||
Refusal tests run against a Server with a deliberately **nil `stackMgr`**: a guard that fails to fire
|
||||
reaches the mutation and **panics** rather than passing quietly. Asserted: 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.
|
||||
|
||||
**Seams injected:** the FileBrowser tests inject at `fbPathDeps` (`isMount`/`classify`/`ensureSkeleton`)
|
||||
and run the real `buildFileBrowserPaths`; the bind-string construction — what the assertion is about —
|
||||
is NOT injected. **Not covered by that span:** `RenderFileBrowserConfig` and the compose template
|
||||
downstream; closed by reading the live generated compose (§7). The handler tests inject nothing — real
|
||||
handler, real settings store, `httptest`.
|
||||
|
||||
**Red-proofs: 4**, each mutation asserted to have landed before the run: drop the migrate-app guard →
|
||||
panic; drop the decommission-target 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 (run separately from the commit); `go vet` rc=0;
|
||||
`template_id_gate.py` + `emoji_gate.py` OK.
|
||||
|
||||
## 7. Live evidence
|
||||
|
||||
See `felhom.eu/documentation/audits/R108-network-app-namespace-2026-07-30.md`.
|
||||
|
||||
## 8. Not done
|
||||
|
||||
- **D5 not implemented** — this unblocks it only.
|
||||
- **No data migrated** — nothing needed migrating (zero apps on network storage).
|
||||
- **The share-root bind unchanged** — deliberate; R-67's capability is preserved intact.
|
||||
- **R-126 filed, not fixed** (`.fab` export onto a NAS).
|
||||
|
||||
Reference in New Issue
Block a user