v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
This commit is contained in:
@@ -1,5 +1,84 @@
|
||||
## Changelog
|
||||
|
||||
### v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces (2026-07-26)
|
||||
|
||||
Spike: `felhom.eu/documentation/audits/SPIKE-catalog-data-paths-2026-07-26.md`.
|
||||
|
||||
**The drop-zone is now ONE canonical location on the system drive.** New `${IMPORT_PATH}` =
|
||||
`<system namespace root>/userdata/import`, injected at BOTH compose-env builders
|
||||
(`withUserdataPath` → `withPathVars`, `deploy.go` + `manager.go`) — the initial-deploy path missing
|
||||
`USERDATA_PATH` once bound a bogus root-owned dir at the container root, and `IMPORT_PATH` has the
|
||||
identical failure mode. It is derived from the SYSTEM drive, never from `HDD_PATH`, and has **no
|
||||
per-drive fallback**: an unresolvable root leaves the variable UNSET so compose fails loudly instead
|
||||
of quietly building a second, non-functional drop-zone. *Operator ruling, overriding the spike's
|
||||
Fork-1 recommendation:* each drop-zone app has exactly one ingest bind, so a per-drive `import/`
|
||||
would put a folder that LOOKS like a drop-zone on every drive while only one works — and since
|
||||
import paths are `class: excluded`, files stranded in a dead one are never backed up either.
|
||||
|
||||
**Third `BindRoot` + the whole-block regression it prevents.** `RootImport` / `${IMPORT_PATH}` in
|
||||
`composeVarRoots`, an `Import []BindSpec` list in `BackupSpec`, and `ValidateBackupSpec` /
|
||||
`ClassifyBinds` extended. This is load-bearing: `ValidateBackupSpec` rejects an entry matching no
|
||||
compose bind and the rejection is WHOLE-BLOCK, so moving paperless's ingest bind while leaving
|
||||
`userdata: import/paperless` in place would have discarded the entire block — taking
|
||||
`hdd: appdata/paperless/media class: mandatory` with it and silently degrading the customer's
|
||||
document originals to legacy handling. `TestScenarioB_*` is the gate.
|
||||
|
||||
**Exhaustive-root audit — `resolveAbs` was the sharp one.** An import bind resolved against `hddPath`
|
||||
would name a directory on the WRONG DRIVE. `resolveAbs`, `structuralGuard`, `ComputeCaptureSet` and
|
||||
`ComputeFabBuckets` now take `importRoot` explicitly (compile-forced at all 4 call sites), and an
|
||||
unresolvable root is refused LOUDLY into `Skipped` (`reasonNoImportRoot`) rather than joined onto "".
|
||||
`GetImportRoot()` added to both provider interfaces + both adapters. `fabplan`/`tier2DestRel`/
|
||||
`export.go`/`appbackup_bridge.go` audited and recorded in REPORT.md.
|
||||
|
||||
**Catalog-derived skeleton, deterministic by construction.** `UserdataSkeleton()` →
|
||||
`UserdataSkeletonCarry()` (the v0.171.0 list verbatim, retained forever) + `BuildUserdataSkeleton()`,
|
||||
which merges it with `DeriveUserdataDirs(stacksDir)` and **sorts**. The carry-list makes zero-removals
|
||||
true by construction — `documents` is implied by no catalog app yet exists on both demo boxes — and
|
||||
doubles as the fresh-box floor. The sort is not tidiness: the spike measured the naive map-order
|
||||
derivation at **20 distinct outputs from 20 identical runs**, and `fbNeedsRecreate` force-recreates on
|
||||
any byte difference across ~14 `SyncFileBrowserMounts` call sites — a fleet-wide FileBrowser restart
|
||||
loop. `TestScenarioC_SkeletonDeterminism` pins 20/20. The catalog sync is deliberately **still not**
|
||||
wired to `SyncFileBrowserMounts`. The canonical import root is excluded from per-app migration
|
||||
(`appDataSkipSet`) so it never moves with an app.
|
||||
|
||||
**One authoritative compose parser.** `ParseComposeUserdataMounts` is now a thin resolver over
|
||||
`ParseComposeClassifiableBinds`. The classifier won because it is the richer of the two byte-identical
|
||||
scanners (it keeps the root and the `:ro` flag). One deliberate behaviour drop, recorded not hidden:
|
||||
the old textual replace also accepted a LITERAL absolute path under `userdataPath`; no catalog
|
||||
template has ever used that form and such a compose would be pinned to one machine's drive layout.
|
||||
The deploy belt now handles both roots, gated differently — the drive-absent gate applies to the app's
|
||||
data drive and must NOT suppress a system-drive import dir.
|
||||
|
||||
**Surfaces.** FileBrowser gains a separate `/srv/beolvasas` bind + a „Beolvasás" sidebar source
|
||||
(separate, not nested — a nested source is indexed twice). New app-page block **„Hova tegyem a
|
||||
fájlokat?"** for DEPLOYED apps declaring `data_paths`, with a deep link built from the shipped
|
||||
Quantum router template, `url.PathEscape` per segment (**never `QueryEscape`** — it encodes space as
|
||||
`+`, a literal plus in a path), the system-drive free space on import rows, and a **class-driven**
|
||||
consequence line so the UI can never promise a backup the engines do not make. Copy does not promise
|
||||
one click: a cold deep link goes through the FileBrowser login.
|
||||
|
||||
**`data_paths:` annotation** (`stacks.Metadata.DataPaths`) — role + Hungarian label over paths that
|
||||
must ALREADY exist as compose binds; it can never declare one. Fork-3 asymmetry, deliberate: a
|
||||
malformed PATH is a whole-block reject (data handling; reuses `ValidateBackupSpec`'s refusal set via
|
||||
the extracted `appbackup.ValidateRelPath` — no second validator), an unknown ROLE fails OPEN with one
|
||||
WARN (presentation; the `Lifecycle` precedent). Catalog: paperless-ngx, calibre-web, romm.
|
||||
|
||||
**System-owned import share.** `SMBShare.System`; a `beolvasas` share auto-created when sharing is
|
||||
ENABLED (never before — deploying an app must not put SMB on the household LAN), `Offsite: false`
|
||||
because the data is `class: excluded`. Deletion refused **server-side at both the handler and the
|
||||
store**, and the button omitted in the template — three checks proving different things (the v0.70.1
|
||||
ghost-delete lesson: a render gate is not enforcement, a handler test is not reachability). The share
|
||||
is written directly rather than through `sharingResolvePath`: that guard validates CUSTOMER-supplied
|
||||
picker paths, and the system drive is deliberately not a registered StoragePath.
|
||||
|
||||
**A latent 500 caught on the way:** the sharing template's row struct was function-local, so adding
|
||||
`{{if .System}}` would have failed at render for every share. `ShareRow` is now package-level and the
|
||||
render test constructs the exact type the handler passes.
|
||||
|
||||
Tests 915 → 949, all green. Red-proofs recorded in REPORT.md for Scenario B (classification),
|
||||
C (determinism) and E (server-side share refusal). No destructive filesystem call was added anywhere
|
||||
in this arc. **MinAgent unchanged.**
|
||||
|
||||
### v0.171.0 — Disk-health card: device-model label (pairs with agent v0.95.0) (2026-07-25)
|
||||
|
||||
`agentapi.SmartSummary` gains `ModelName` (mirrors the agent v0.95.0 `model_name`); the "Lemezek
|
||||
|
||||
+31
-1
@@ -7,7 +7,37 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-07-24 (v0.169.0 — disk-health card + degradation notification "Lemezek állapota")
|
||||
Last updated: 2026-07-26 (v0.172.0 — R-75 canonical import root + catalog-derived skeleton)
|
||||
|
||||
> **2026-07-26 — v0.172.0 (R-75).** Spike `felhom.eu/documentation/audits/SPIKE-catalog-data-paths-2026-07-26.md`;
|
||||
> feature doc `felhom.eu/documentation/controller/import-and-data-paths.md`.
|
||||
>
|
||||
> **RULING — the import root is CANONICAL on the system drive, overriding the spike's Fork-1
|
||||
> recommendation of per-drive roots.** The spike weighed sidebar clutter and per-app link ambiguity and
|
||||
> concluded per-drive; the operator overruled it on an argument the spike missed: each drop-zone app has
|
||||
> exactly ONE ingest bind, so on a two-drive box every import folder except the app's own would look like
|
||||
> a drop-zone and silently do nothing — and because `import/*` is `class: excluded`, files stranded there
|
||||
> are never backed up either. A canonical root is the only shape with no dead drop-zone. Recorded as a
|
||||
> deliberate deviation, not an oversight.
|
||||
>
|
||||
> **Phase-0 probe changed the shape of Part 6.** The system drive is NOT a registered `StoragePath` on
|
||||
> either demo box (`/mnt/felhom-drives/hdd_1` on demo-felhom; `nvme-1tb` + `Felhom-Share` on demo-hp),
|
||||
> so `sharingResolvePath` REFUSES `<sysroot>/userdata/import` — verified against the real guard with a
|
||||
> passing control. Registering the drive was rejected (it would make the 50 GB volume holding the
|
||||
> recovery units a customer-visible drive, deploy target and wipe candidate, and `SharingDeniedRoots`
|
||||
> would then deny the namespace-consistent shape anyway). **Chosen: leave it unregistered and have the
|
||||
> controller write the `beolvasas` share directly** — the picker guard validates CUSTOMER-supplied paths,
|
||||
> a controller-generated constant is a different trust class. No guard was weakened.
|
||||
>
|
||||
> Also note: `withUserdataPath` computes `USERDATA_PATH` as `<hdd>/userdata`, NOT
|
||||
> `NamespaceRoot(hdd)/userdata`. For an app on the system drive those disagree
|
||||
> (`/mnt/sys_drive/userdata` vs the `felhom-data` namespace). Latent — no app with a userdata bind has
|
||||
> ever been deployed there — but it is a real inconsistency, left untouched here.
|
||||
>
|
||||
> The other three forks followed the spike unchanged: all-apps skeleton / deployed-only in the UI;
|
||||
> unknown role fails OPEN while a malformed path whole-block rejects; drop-zone copy driven by the
|
||||
> derived backup class.
|
||||
|
||||
|
||||
> **2026-07-24 — v0.169.0 (disk-health card + degradation alert).** Consumes the agent's new `smart`
|
||||
> field (agent v0.94.0; MinAgent floor unchanged — feature-detect by presence). **Rulings:** (1) ONE
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
| `PrimaryBackupPath` / `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` | controller/internal/appbackup/paths.go | `(nsRoot[, stackName]) string` | All backup dir layout | Take the NAMESPACE ROOT, not a bare drive path |
|
||||
| `AppDBDumpPath` / `AppVolumeDumpPath` / `AppDataDir` | controller/internal/appbackup/paths.go | `(nsRoot, stackName) string` | Per-app dump/data dirs | Same nsRoot contract. `AppDataDir`'s final segment is the app's real appdata dir NAME — NOT always the stack name (paperless-ngx → `paperless`); resolve via `AppDataDirNames` first (F-S2/F-S3) |
|
||||
| `AppDataDirNames` / `AppDataBindsPresent` | controller/internal/appbackup/paths.go | `(hddPath, stackName string, hddMounts []string) []string` / `(hddPath, hddMounts) bool` | Resolve the real `appdata/<name>` dir(s) from compose `${HDD_PATH}` binds (F-S2/F-S3) | `hddMounts` = ParseComposeHDDMounts shape. Deduped+sorted; falls back to `[stackName]` when no appdata bind. Tier-2 (`backup.Manager.tier2AppDataName`) refuses N>1; migrate (`stacks.Manager.ResolveAppDataDirNames`) loops N. `BindsPresent` drives the WARN-on-missing-declared-dir |
|
||||
| `UserdataDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention | Linux-only chown via build-tag twin userdata_linux.go |
|
||||
| `UserdataDir` / `ImportDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(nsRoot)` / `(nsRoot, dirs []string)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention. **R-75:** `ImportDir` is the CANONICAL drop-zone (`<nsRoot>/userdata/import`) and callers MUST resolve it against the SYSTEM namespace, never an app's HDD_PATH — use `stacks.Manager.GetImportRoot()`. `EnsureUserdataSkeleton` now takes the dir set: build it with `BuildUserdataSkeleton(DeriveUserdataDirs(stacksDir))`, or via `Manager.EnsureUserdataSkeleton` / `web.Server.ensureUserdataSkeleton`. | Linux-only chown via build-tag twin userdata_linux.go. **The set MUST stay sorted** — `fbNeedsRecreate` force-recreates FileBrowser on any byte diff and the naive map-order derivation measured 20/20 distinct (SPIKE P6). `UserdataSkeletonCarry()` is the old hardcoded list, retained forever so derivation can only ADD (zero removals). |
|
||||
| `BuildUserdataSkeleton` / `UserdataSkeletonCarry` / `DeriveUserdataDirs` | appbackup/userdata.go, stacks/skeleton_derive.go | `([]string)` / `()` / `(stacksDir)` | catalog-derived userdata skeleton (R-75) | Derives `${USERDATA_PATH}` binds only — `${IMPORT_PATH}` is NOT part of a drive skeleton (one root, system drive, `Manager.EnsureImportRoot`). Do NOT wire the catalog sync to `SyncFileBrowserMounts`. |
|
||||
| `appbackup.ValidateRelPath` / `ValidRoot` | controller/internal/appbackup/classify.go | `(root, path)` / `(root)` | THE single path-safety refusal set for every `${VAR}`-relative catalog path | Shared by `backup:` and `data_paths:`. **Do not write a second path validator.** |
|
||||
| `stacks.ValidateDataPaths` | controller/internal/stacks/datapaths.go | `(entries, binds, appName, logger)` | `data_paths:` annotation validation | ASYMMETRIC on purpose (Fork-3): malformed PATH ⇒ whole-block reject (data handling, `backup:` precedent); unknown ROLE ⇒ fails OPEN, one WARN (presentation, `Lifecycle` precedent). |
|
||||
| `web.fileBrowserLink` / `importFolderLink` | controller/internal/web/filebrowser_link.go | `(domain, sourceName, relPath)` | FileBrowser Quantum deep link | Template read out of the shipped router (SPIKE P2). **`url.PathEscape` per segment — NEVER `QueryEscape`** (space→`+` is a literal plus in a path). Let `html/template` do the attribute escaping; do not pre-escape. |
|
||||
| `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) |
|
||||
| `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/<n>` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert |
|
||||
| `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) |
|
||||
|
||||
+22
-2
@@ -393,8 +393,18 @@ Each app can define rich metadata in `.felhom.yml`:
|
||||
surfaces it on `/apps/{slug}` as a "Kezdeti belépési adatok" card (masked password + reveal/copy),
|
||||
labelled as the *initial* credential (stays valid only until the customer changes it in-app). Hidden
|
||||
when the container is down / file missing / parse fails. Reuse for any future self-seeding app.
|
||||
- `backup` (v0.132.0): the **referential-coupling classification** block (Task 2). Two optional lists,
|
||||
`userdata:` (relative to `${USERDATA_PATH}`) and `hdd:` (relative to `${HDD_PATH}`), each of
|
||||
- `data_paths` (v0.172.0, R-75): the **customer-facing folder annotation** — `{path, root, role, label}`
|
||||
where `root ∈ {import, userdata, hdd}` and `role ∈ {import, library, export}`. It ANNOTATES paths that
|
||||
must already exist as compose binds and can never declare one (so no new filesystem-write primitive
|
||||
comes from catalog data). Validation is deliberately ASYMMETRIC: a malformed **path** is a whole-block
|
||||
reject (reusing `appbackup.ValidateRelPath`, the same refusal set as `backup:`), an unknown **role**
|
||||
fails OPEN with one WARN (the `Lifecycle` precedent — presentation, not data handling). Rendered on
|
||||
`/apps/{slug}` as „Hova tegyem a fájlokat?" for DEPLOYED apps only, each row a FileBrowser deep link
|
||||
plus a **class-driven** consequence line. Full contract:
|
||||
`felhom.eu/documentation/controller/import-and-data-paths.md`.
|
||||
- `backup` (v0.132.0; **three** lists since v0.172.0): the **referential-coupling classification** block
|
||||
(Task 2). Optional lists `userdata:` (relative to `${USERDATA_PATH}`), `hdd:` (relative to
|
||||
`${HDD_PATH}`) and `import:` (relative to `${IMPORT_PATH}`, R-75), each of
|
||||
`{path, class}` where `class ∈ {mandatory, optional, excluded}` (COUPLED / DECOUPLED-precious /
|
||||
DECOUPLED-bulk). `LoadMetadata` validates the block against the app's compose binds and **rejects the
|
||||
WHOLE block** (→ nil + one `[ERROR]`, app behaves as legacy) on any defect. Semantics
|
||||
@@ -501,6 +511,16 @@ The `/apps/{slug}` page renders hero section, screenshots, setup guide, and opti
|
||||
|
||||
#### Base-infrastructure bring-up (`stacks/infra.go` + `internal/infra/`, v0.41.0)
|
||||
|
||||
**Canonical import root (v0.172.0, R-75).** `${IMPORT_PATH}` = `<system namespace root>/userdata/import`
|
||||
— ONE drop-zone per box, on the always-available system drive, never per data drive (each drop-zone app
|
||||
has exactly one ingest bind, so a per-drive `import/` would put a dead lookalike on every other drive,
|
||||
and `import/*` is `class: excluded` so files stranded there are unbacked too). Injected at BOTH
|
||||
compose-env builders; **no per-drive fallback** — unresolvable leaves it unset so compose fails loudly.
|
||||
The system drive is deliberately NOT a registered `StoragePath`, so the FileBrowser bind
|
||||
(`/srv/beolvasas`, sidebar „Beolvasás"), the skeleton and the system-owned `beolvasas` SMB share each
|
||||
reach it explicitly. The **userdata skeleton is catalog-derived** (`DeriveUserdataDirs` +
|
||||
`UserdataSkeletonCarry`, sorted — the sort is load-bearing, see REUSE.md) and can only ever ADD.
|
||||
|
||||
The controller stands up its own base stack — **traefik** (reverse proxy), **cloudflared** (external tunnel), **filebrowser** — instead of relying on the bare-metal `scripts/docker-setup.sh` (which a Proxmox-provisioned guest never runs). `internal/infra` renders the compose + config files from `controller.yaml` via embedded `text/template`s (lifted from `docker-setup.sh`); image tags are **pinned constants there** (`TraefikImage`/`CloudflaredImage`/`FileBrowserImage`) and the web FileBrowser sync path delegates to the same renderers, so the pinned versions can never diverge.
|
||||
|
||||
`Manager.EnsureBaseStack()` creates the `traefik-public` network, then deploys traefik → cloudflared → filebrowser under `${stacks_dir}/<name>`. It is:
|
||||
|
||||
@@ -1328,6 +1328,11 @@ func (a *stackAdapter) GetDockerVolumes(name string) []string {
|
||||
return backup.ResolveDockerVolumeNames(s.ComposePath)
|
||||
}
|
||||
|
||||
// GetImportRoot delegates to the stack manager's canonical drop-zone root (R-75). Both the backup
|
||||
// and the export provider need it: ${IMPORT_PATH} binds live on the SYSTEM drive and can NOT be
|
||||
// resolved from an app's HDD_PATH.
|
||||
func (a *stackAdapter) GetImportRoot() string { return a.mgr.GetImportRoot() }
|
||||
|
||||
func (a *stackAdapter) GetStackHDDPath(name string) string {
|
||||
s, ok := a.mgr.GetStack(name)
|
||||
if !ok {
|
||||
@@ -1543,6 +1548,11 @@ func (a *exportAdapter) GetStackHDDMounts(name string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetImportRoot delegates to the stack manager's canonical drop-zone root (R-75). Both the backup
|
||||
// and the export provider need it: ${IMPORT_PATH} binds live on the SYSTEM drive and can NOT be
|
||||
// resolved from an app's HDD_PATH.
|
||||
func (a *exportAdapter) GetImportRoot() string { return a.mgr.GetImportRoot() }
|
||||
|
||||
func (a *exportAdapter) GetStackHDDPath(name string) string {
|
||||
s, ok := a.mgr.GetStack(name)
|
||||
if !ok {
|
||||
|
||||
@@ -27,6 +27,7 @@ func (p *snapshotsStubProvider) GetStackComposePath(name string) (string, bool)
|
||||
func (p *snapshotsStubProvider) ListDeployedStacks() []backup.StackSummary { return nil }
|
||||
func (p *snapshotsStubProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *snapshotsStubProvider) GetStackHDDPath(string) string { return p.hdd }
|
||||
func (p *snapshotsStubProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *snapshotsStubProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *snapshotsStubProvider) StopStack(string) error { return nil }
|
||||
func (p *snapshotsStubProvider) StartStack(string) error { return nil }
|
||||
|
||||
@@ -19,7 +19,11 @@ type StackDataProvider interface {
|
||||
GetStackComposePath(name string) (composePath string, ok bool)
|
||||
ListDeployedStacks() []StackSummary
|
||||
GetStackHDDMounts(name string) []string
|
||||
GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD)
|
||||
GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD)
|
||||
// GetImportRoot returns the CANONICAL drop-zone root (R-75): <system namespace root>/userdata/import.
|
||||
// It is app-INDEPENDENT and lives on the SYSTEM drive, so ${IMPORT_PATH} binds cannot be resolved
|
||||
// from GetStackHDDPath. Empty when unresolvable — structuralGuard refuses such binds loudly.
|
||||
GetImportRoot() string
|
||||
GetDockerVolumes(name string) []string // full Docker volume names (project-prefixed)
|
||||
StopStack(name string) error
|
||||
StartStack(name string) error
|
||||
|
||||
@@ -59,6 +59,8 @@ const (
|
||||
reasonEscape = "path escapes the drive root"
|
||||
reasonBareRoot = "bare drive-root bind would capture the backups tree"
|
||||
reasonReserved = "path inside the reserved backups zone"
|
||||
// reasonNoImportRoot: a ${IMPORT_PATH} bind with no resolvable system namespace root (R-75).
|
||||
reasonNoImportRoot = "canonical import root unresolvable (system_data_path unconfigured)"
|
||||
)
|
||||
|
||||
// ComputeCaptureSet resolves an app's classified binds into the tier-filtered absolute capture set.
|
||||
@@ -71,16 +73,17 @@ const (
|
||||
// so the engines' no-block branch stays byte-identical to today (the SQ5 cost-regression guard).
|
||||
//
|
||||
// Resolution: RootHDD → path.Join(hddPath, relPath); RootUserdata → path.Join(hddPath, "userdata",
|
||||
// relPath). Guards run AFTER the tier filter, so Skipped means exactly "would have been captured by
|
||||
// this tier, refused for structural safety".
|
||||
func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier CaptureTier, hddPath string) CaptureSet {
|
||||
// relPath); RootImport → path.Join(importRoot, relPath) — the SYSTEM drive, never hddPath (R-75).
|
||||
// Guards run AFTER the tier filter, so Skipped means exactly "would have been captured by this tier,
|
||||
// refused for structural safety".
|
||||
func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier CaptureTier, hddPath, importRoot string) CaptureSet {
|
||||
if !hasClassification {
|
||||
return CaptureSet{HasClassification: false}
|
||||
}
|
||||
cs := CaptureSet{HasClassification: true}
|
||||
|
||||
// Stages 1–3: tier filter → structural guards → equal-Abs collapse (shared with ComputeFabBuckets).
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(c BindClass) bool { return tierKeeps(tier, c) })
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, importRoot, func(c BindClass) bool { return tierKeeps(tier, c) })
|
||||
cs.Skipped = skipped
|
||||
|
||||
// Stage 4: containment dedup — drop any path whose ancestor is already present (keep the ancestor).
|
||||
@@ -107,18 +110,18 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
|
||||
// collapse (mandatory > optional > excluded; ties by smaller Root/RelPath). It does NOT apply
|
||||
// containment dedup — the caller decides (ComputeCaptureSet does; ComputeFabBuckets must not, so a
|
||||
// mandatory child inside an excluded parent stays independently addressable).
|
||||
func resolveGuardCollapse(binds []ClassifiedBind, hddPath string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
|
||||
func resolveGuardCollapse(binds []ClassifiedBind, hddPath, importRoot string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
|
||||
var resolved []CapturePath
|
||||
for _, b := range binds {
|
||||
if !keep(b.Class) {
|
||||
continue
|
||||
}
|
||||
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
|
||||
if reason, bad := structuralGuard(b.Root, b.RelPath, importRoot); bad {
|
||||
skipped = append(skipped, SkippedPath{Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason})
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, CapturePath{
|
||||
Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
|
||||
Abs: resolveAbs(hddPath, importRoot, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
|
||||
})
|
||||
}
|
||||
byAbs := make(map[string]CapturePath, len(resolved))
|
||||
@@ -153,12 +156,12 @@ type FabBuckets struct {
|
||||
// selection UI + plan. Same resolution + structural guards + equal-Abs collapse as ComputeCaptureSet
|
||||
// (via resolveGuardCollapse), bucketed by class, no cross-bucket containment dedup. Each bucket is
|
||||
// Abs-sorted (deterministic).
|
||||
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath string) FabBuckets {
|
||||
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath, importRoot string) FabBuckets {
|
||||
if !hasClassification {
|
||||
return FabBuckets{HasClassification: false}
|
||||
}
|
||||
fb := FabBuckets{HasClassification: true}
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(BindClass) bool { return true })
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, importRoot, func(BindClass) bool { return true })
|
||||
fb.Skipped = skipped
|
||||
for _, cp := range uniq {
|
||||
switch cp.Class {
|
||||
@@ -201,10 +204,19 @@ func tierKeeps(tier CaptureTier, class BindClass) bool {
|
||||
// there (ParseComposeClassifiableBinds path.Cleans; ValidateBackupSpec vets only SPEC entries), so an
|
||||
// unlisted writable "${HDD_PATH}/../x" bind reaches here classed mandatory — this guard is
|
||||
// load-bearing security, not defence-in-depth.
|
||||
func structuralGuard(root BindRoot, relPath string) (reason string, bad bool) {
|
||||
func structuralGuard(root BindRoot, relPath, importRoot string) (reason string, bad bool) {
|
||||
if relPathEscapes(relPath) {
|
||||
return reasonEscape, true
|
||||
}
|
||||
// RootImport (R-75) resolves against the SYSTEM drive, not hddPath. If that root is unresolvable
|
||||
// (system_data_path unconfigured) the bind cannot be placed at all — refuse it LOUDLY into Skipped
|
||||
// rather than let resolveAbs join onto "" and produce a relative, wrong-drive path. The other two
|
||||
// roots cannot hit this: hddPath is checked by their own callers.
|
||||
if root == RootImport && importRoot == "" {
|
||||
return reasonNoImportRoot, true
|
||||
}
|
||||
// A bare ${IMPORT_PATH} bind is allowed: it resolves to <sysNS>/userdata/import, which nests no
|
||||
// backups/ tree (backups live at <sysNS>/backups, a sibling of userdata).
|
||||
if root == RootHDD {
|
||||
if relPath == "" {
|
||||
return reasonBareRoot, true // bare ${HDD_PATH} would nest <hddPath>/backups into the capture
|
||||
@@ -233,11 +245,21 @@ func relPathEscapes(relPath string) bool {
|
||||
}
|
||||
|
||||
// resolveAbs maps a guarded (root, relPath) to its in-container absolute path via slash algebra.
|
||||
func resolveAbs(hddPath string, root BindRoot, relPath string) string {
|
||||
if root == RootUserdata {
|
||||
//
|
||||
// RootImport is the one root that does NOT resolve against hddPath: the canonical drop-zone lives on
|
||||
// the SYSTEM drive (R-75), so importRoot is supplied separately by the caller. Resolving it against
|
||||
// hddPath would silently name a directory on the WRONG DRIVE — a .fab opt-in would then capture (or
|
||||
// on restore, write) somewhere that merely looks plausible. An empty importRoot is the unresolvable
|
||||
// case and is refused upstream by structuralGuard, never silently joined.
|
||||
func resolveAbs(hddPath, importRoot string, root BindRoot, relPath string) string {
|
||||
switch root {
|
||||
case RootUserdata:
|
||||
return path.Join(hddPath, "userdata", relPath)
|
||||
case RootImport:
|
||||
return path.Join(importRoot, relPath)
|
||||
default:
|
||||
return path.Join(hddPath, relPath)
|
||||
}
|
||||
return path.Join(hddPath, relPath)
|
||||
}
|
||||
|
||||
// strongerCapture picks the winner of an equal-Abs collision: mandatory beats optional; on equal
|
||||
|
||||
@@ -38,12 +38,12 @@ func TestComputeCaptureSet_PerTierSplit(t *testing.T) {
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, Class: ClassOptional, Origin: OriginExplicit},
|
||||
}
|
||||
|
||||
off := ComputeCaptureSet(binds, true, TierOffsite, drv)
|
||||
off := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
|
||||
if got, want := absList(off), []string{hdd("appdata/immich")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("offsite Paths = %v, want %v (mandatory only — the :ro optional must NOT ship offsite)", got, want)
|
||||
}
|
||||
|
||||
sec := ComputeCaptureSet(binds, true, TierSecondary, drv)
|
||||
sec := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
|
||||
want := []string{hdd("appdata/immich"), udat("media/photos")}
|
||||
if got := absList(sec); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("secondary Paths = %v, want %v (sorted)", got, want)
|
||||
@@ -72,7 +72,7 @@ func TestComputeCaptureSet_LegacyInert(t *testing.T) {
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/sonarr"}, Origin: OriginLegacy},
|
||||
}
|
||||
for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
|
||||
cs := ComputeCaptureSet(binds, false, tier, drv)
|
||||
cs := ComputeCaptureSet(binds, false, tier, drv, "")
|
||||
if cs.HasClassification {
|
||||
t.Errorf("%s: HasClassification=true for a legacy app", tier)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestComputeCaptureSet_ExcludedInvisible(t *testing.T) {
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "import/paperless"}, Class: ClassExcluded, Origin: OriginExplicit},
|
||||
}
|
||||
for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
|
||||
cs := ComputeCaptureSet(binds, true, tier, drv)
|
||||
cs := ComputeCaptureSet(binds, true, tier, drv, "")
|
||||
if got, want := absList(cs), []string{hdd("appdata/paperless/media")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s Paths = %v, want %v (excluded filtered)", tier, got, want)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestComputeCaptureSet_StructuralGuards(t *testing.T) {
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "backups/primary/x"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d3 reserved zone
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d4 bare userdata — ALLOWED
|
||||
}
|
||||
cs := ComputeCaptureSet(binds, true, TierOffsite, drv)
|
||||
cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
|
||||
|
||||
// Paths: ONLY d4's userdata root — no escaped root, no backups/ anywhere.
|
||||
if got, want := absList(cs), []string{udat("")}; !reflect.DeepEqual(got, want) {
|
||||
@@ -156,7 +156,7 @@ func TestComputeCaptureSet_LegitDotDotName(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/a..b"}, Class: ClassMandatory, Origin: OriginExplicit},
|
||||
}
|
||||
cs := ComputeCaptureSet(binds, true, TierOffsite, drv)
|
||||
cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
|
||||
if got, want := absList(cs), []string{hdd("appdata/a..b")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Paths = %v, want %v (a..b is a legit name, not traversal)", got, want)
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func TestComputeCaptureSet_ContainmentAndCollision(t *testing.T) {
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional, Origin: OriginExplicit}, // Abs collides with next
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory, Origin: OriginExplicit}, // same Abs, mandatory
|
||||
}
|
||||
cs := ComputeCaptureSet(binds, true, TierSecondary, drv)
|
||||
cs := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
|
||||
|
||||
want := []string{hdd("appdata/paperless"), udat("media")}
|
||||
if got := absList(cs); !reflect.DeepEqual(got, want) {
|
||||
@@ -186,7 +186,7 @@ func TestComputeCaptureSet_ContainmentAndCollision(t *testing.T) {
|
||||
}
|
||||
|
||||
// determinism: recompute and compare full struct
|
||||
cs2 := ComputeCaptureSet(binds, true, TierSecondary, drv)
|
||||
cs2 := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
|
||||
if !reflect.DeepEqual(cs, cs2) {
|
||||
t.Error("ComputeCaptureSet is non-deterministic across runs")
|
||||
}
|
||||
|
||||
@@ -34,12 +34,23 @@ type BindRoot string
|
||||
const (
|
||||
RootUserdata BindRoot = "userdata" // relative to ${USERDATA_PATH}
|
||||
RootHDD BindRoot = "hdd" // relative to ${HDD_PATH}
|
||||
// RootImport is relative to ${IMPORT_PATH} — the CANONICAL drop-zone root (R-75). Unlike the
|
||||
// other two it does NOT resolve against the app's own drive: it lives on the system drive's
|
||||
// namespace, so every app's ingest folder is in one place. Resolvers therefore need the import
|
||||
// root passed in separately; they cannot derive it from hddPath.
|
||||
RootImport BindRoot = "import"
|
||||
)
|
||||
|
||||
// BackupSpec is the .felhom.yml `backup:` block. Paths are forward-slash, relative, path.Clean'd.
|
||||
type BackupSpec struct {
|
||||
Userdata []BindSpec `yaml:"userdata,omitempty" json:"userdata,omitempty"`
|
||||
HDD []BindSpec `yaml:"hdd,omitempty" json:"hdd,omitempty"`
|
||||
// Import classifies ${IMPORT_PATH}-relative binds (R-75). An app whose ingest bind moved from
|
||||
// ${USERDATA_PATH}/import/<app> to ${IMPORT_PATH}/<app> MUST move its backup entry here in the
|
||||
// same change: ValidateBackupSpec rejects an entry matching no compose bind, and the rejection is
|
||||
// WHOLE-BLOCK, so a stale `userdata: import/<app>` would discard the app's OTHER classifications
|
||||
// (e.g. an hdd appdata path classed mandatory) and silently degrade it to legacy.
|
||||
Import []BindSpec `yaml:"import,omitempty" json:"import,omitempty"`
|
||||
}
|
||||
|
||||
// BindSpec is one classified entry in a BackupSpec.
|
||||
@@ -86,6 +97,42 @@ func validClass(c BindClass) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateRelPath is THE path-safety refusal set for every ${VAR}-relative catalog path — the
|
||||
// `backup:` block and `data_paths:` both run through it, so there is exactly ONE definition of what
|
||||
// a safe relative path is. Refuses: empty, backslash, absolute, non-path.Clean'd, and any leading
|
||||
// ".." escape. It deliberately does NOT check "matches a compose bind" — that rule needs the bind
|
||||
// list and differs per caller (whole-block reject for backup:, per-entry for data_paths:).
|
||||
func ValidateRelPath(root BindRoot, p string) error {
|
||||
where := fmt.Sprintf("%s[%q]", root, p)
|
||||
if p == "" {
|
||||
return fmt.Errorf("%s: empty path", where)
|
||||
}
|
||||
if strings.ContainsRune(p, '\\') {
|
||||
return fmt.Errorf("%s: backslash in path (paths are forward-slash relative)", where)
|
||||
}
|
||||
if path.IsAbs(p) {
|
||||
return fmt.Errorf("%s: absolute path (must be relative to the %s root)", where, root)
|
||||
}
|
||||
if p != path.Clean(p) {
|
||||
return fmt.Errorf("%s: non-clean path (want %q)", where, path.Clean(p))
|
||||
}
|
||||
// path.Clean has run — ".." can only survive as a leading "../" segment.
|
||||
if p == ".." || strings.HasPrefix(p, "../") {
|
||||
return fmt.Errorf("%s: path escapes the root (..)", where)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidRoot reports whether r is one of the three known bind roots.
|
||||
func ValidRoot(r BindRoot) bool {
|
||||
switch r {
|
||||
case RootUserdata, RootHDD, RootImport:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateBackupSpec checks a parsed backup block against the app's actual compose binds and returns
|
||||
// the FIRST defect (whole-block semantics — the caller rejects the ENTIRE block on any error, so the
|
||||
// app degrades to legacy rather than partially classifying). A nil spec is vacuously valid (legacy).
|
||||
@@ -113,21 +160,8 @@ func ValidateBackupSpec(spec *BackupSpec, binds []ComposeBind) error {
|
||||
if !validClass(e.Class) {
|
||||
return fmt.Errorf("%s: invalid class %q (want mandatory|optional|excluded)", where, e.Class)
|
||||
}
|
||||
if e.Path == "" {
|
||||
return fmt.Errorf("%s: empty path", where)
|
||||
}
|
||||
if strings.ContainsRune(e.Path, '\\') {
|
||||
return fmt.Errorf("%s: backslash in path (paths are forward-slash relative)", where)
|
||||
}
|
||||
if path.IsAbs(e.Path) {
|
||||
return fmt.Errorf("%s: absolute path (must be relative to the %s root)", where, root)
|
||||
}
|
||||
if e.Path != path.Clean(e.Path) {
|
||||
return fmt.Errorf("%s: non-clean path (want %q)", where, path.Clean(e.Path))
|
||||
}
|
||||
// path.Clean has run — ".." can only survive as a leading "../" segment.
|
||||
if e.Path == ".." || strings.HasPrefix(e.Path, "../") {
|
||||
return fmt.Errorf("%s: path escapes the root (..)", where)
|
||||
if err := ValidateRelPath(root, e.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
key := string(root) + "\x00" + e.Path
|
||||
if seen[key] {
|
||||
@@ -143,7 +177,10 @@ func ValidateBackupSpec(spec *BackupSpec, binds []ComposeBind) error {
|
||||
if err := check(RootUserdata, spec.Userdata); err != nil {
|
||||
return err
|
||||
}
|
||||
return check(RootHDD, spec.HDD)
|
||||
if err := check(RootHDD, spec.HDD); err != nil {
|
||||
return err
|
||||
}
|
||||
return check(RootImport, spec.Import)
|
||||
}
|
||||
|
||||
// ClassifyBinds resolves every compose bind to a class + origin, applying the two-level default. The
|
||||
@@ -181,6 +218,7 @@ func ClassifyBinds(spec *BackupSpec, binds []ComposeBind) (classified []Classifi
|
||||
}
|
||||
add(RootUserdata, spec.Userdata)
|
||||
add(RootHDD, spec.HDD)
|
||||
add(RootImport, spec.Import)
|
||||
|
||||
for _, b := range binds {
|
||||
cb := ClassifiedBind{ComposeBind: b}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestComputeFabBuckets_Classified(t *testing.T) {
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/movies"}, Class: ClassExcluded},
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/app"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
fb := ComputeFabBuckets(binds, true, drv, "")
|
||||
if !fb.HasClassification {
|
||||
t.Fatal("HasClassification must be true")
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestComputeFabBuckets_Classified(t *testing.T) {
|
||||
// legacy (no block) → empty buckets (the full-root capture stays out of the classified plan).
|
||||
func TestComputeFabBuckets_LegacyEmpty(t *testing.T) {
|
||||
binds := []ClassifiedBind{{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/tv"}, Origin: OriginLegacy}}
|
||||
fb := ComputeFabBuckets(binds, false, drv)
|
||||
fb := ComputeFabBuckets(binds, false, drv, "")
|
||||
if fb.HasClassification || fb.Mandatory != nil || fb.Optional != nil || fb.Excluded != nil {
|
||||
t.Errorf("legacy app must yield empty buckets, got %+v", fb)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func TestComputeFabBuckets_GuardsAllClasses(t *testing.T) {
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassExcluded},
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/ok"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
fb := ComputeFabBuckets(binds, true, drv, "")
|
||||
for _, b := range [][]CapturePath{fb.Mandatory, fb.Optional, fb.Excluded} {
|
||||
for _, p := range b {
|
||||
if p.RelPath == "../evil" {
|
||||
@@ -71,7 +71,7 @@ func TestComputeFabBuckets_NoCrossBucketContainment(t *testing.T) {
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassExcluded},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/books"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
fb := ComputeFabBuckets(binds, true, drv, "")
|
||||
if got, want := bucketAbs(fb.Mandatory), []string{udat("media/books")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mandatory child must survive independently: Mandatory = %v, want %v", got, want)
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func TestComputeFabBuckets_EqualAbsMandatoryWins(t *testing.T) {
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
fb := ComputeFabBuckets(binds, true, drv, "")
|
||||
if got, want := bucketAbs(fb.Mandatory), []string{udat("media")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("collapsed path must land in Mandatory, got Mandatory=%v", got)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package appbackup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-75 Scenario C — DETERMINISM. This is the P6 gate.
|
||||
//
|
||||
// The spike measured the naive map-order derivation producing 20 DISTINCT outputs from 20 identical
|
||||
// runs. fbNeedsRecreate force-recreates FileBrowser on ANY byte difference in the generated config,
|
||||
// and SyncFileBrowserMounts has ~14 call sites — so a non-deterministic skeleton is a fleet-wide
|
||||
// FileBrowser restart loop, the v0.151-class bug. 20 identical generations or this fails.
|
||||
func TestScenarioC_SkeletonDeterminism(t *testing.T) {
|
||||
// Deliberately UNSORTED input, with duplicates and a deep path, so the function has real work to
|
||||
// normalise. A sort applied only to the input would not save a map-ordered implementation.
|
||||
derived := []string{
|
||||
"media/podcasts", "roms", "media/books", "downloads", "media",
|
||||
"media/photos", "media/books", "a/b/c/d",
|
||||
}
|
||||
const n = 20
|
||||
first := BuildUserdataSkeleton(derived)
|
||||
for i := 1; i < n; i++ {
|
||||
got := BuildUserdataSkeleton(derived)
|
||||
if !slices.Equal(got, first) {
|
||||
t.Fatalf("generation %d/%d differs — a non-deterministic skeleton force-recreates FileBrowser on every sync pass\n first: %v\n got: %v",
|
||||
i+1, n, first, got)
|
||||
}
|
||||
}
|
||||
if !slices.IsSorted(first) {
|
||||
t.Errorf("skeleton must be sorted, got %v", first)
|
||||
}
|
||||
// Ancestor expansion: a deep derived path implies its whole chain.
|
||||
for _, want := range []string{"a", "a/b", "a/b/c", "a/b/c/d"} {
|
||||
if !slices.Contains(first, want) {
|
||||
t.Errorf("ancestor chain incomplete: %q missing from %v", want, first)
|
||||
}
|
||||
}
|
||||
// Dedup: "media/books" appeared twice in the input and "media" both derived and as an ancestor.
|
||||
for _, d := range []string{"media", "media/books"} {
|
||||
if c := countOf(first, d); c != 1 {
|
||||
t.Errorf("%q appears %d times, want exactly 1", d, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func countOf(xs []string, want string) int {
|
||||
n := 0
|
||||
for _, x := range xs {
|
||||
if x == want {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// R-75 Scenario D — ZERO REMOVALS, proven by construction.
|
||||
//
|
||||
// The derived set drops `documents` (implied by no catalog app) and, after the R-75 move, the two
|
||||
// import/* entries. The carry-list is what keeps them. This asserts the merged set is a strict
|
||||
// SUPERSET of the historical hardcoded skeleton for any derived input — including the empty one, the
|
||||
// fresh-box case where the catalog has not synced yet.
|
||||
func TestScenarioD_SkeletonNeverDropsACarriedDir(t *testing.T) {
|
||||
for _, derived := range [][]string{
|
||||
nil, // fresh box, catalog not yet synced
|
||||
{"media/podcasts"}, // the one genuinely new entry
|
||||
{"roms", "downloads", "media/photos"}, // a partial catalog
|
||||
} {
|
||||
got := BuildUserdataSkeleton(derived)
|
||||
for _, carried := range UserdataSkeletonCarry() {
|
||||
if !slices.Contains(got, carried) {
|
||||
t.Errorf("derived=%v: carried dir %q was DROPPED — zero-removals violated", derived, carried)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the new entry really is added when the catalog implies it.
|
||||
if !slices.Contains(BuildUserdataSkeleton([]string{"media/podcasts"}), "media/podcasts") {
|
||||
t.Error("media/podcasts must be added when the catalog implies it")
|
||||
}
|
||||
// `documents` is the specific entry the spike flagged: in the carry-list, in no catalog app.
|
||||
if !slices.Contains(BuildUserdataSkeleton([]string{"media/podcasts"}), "documents") {
|
||||
t.Error("`documents` must survive — it exists on both demo boxes and may hold customer files")
|
||||
}
|
||||
}
|
||||
|
||||
// A traversal or absolute entry reaching the skeleton would make EnsureUserdataSkeleton create a
|
||||
// directory outside the userdata root. The derived set comes from a compose parser, so this is a
|
||||
// guard on untrusted-ish catalog input, not defence in depth.
|
||||
func TestSkeletonRefusesEscapes(t *testing.T) {
|
||||
got := BuildUserdataSkeleton([]string{"../escape", "..", "", "/abs/path", "ok/dir"})
|
||||
for _, bad := range []string{"../escape", "..", "", "/abs/path"} {
|
||||
if slices.Contains(got, bad) {
|
||||
t.Errorf("escape entry %q must not reach the skeleton: %v", bad, got)
|
||||
}
|
||||
}
|
||||
for _, d := range got {
|
||||
if filepath.IsAbs(d) || d == ".." || len(d) > 3 && d[:3] == "../" {
|
||||
t.Errorf("unsafe skeleton entry %q", d)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(got, "ok/dir") {
|
||||
t.Error("a legitimate entry alongside bad ones must still be kept")
|
||||
}
|
||||
// "/abs/path" is not dropped outright — it is normalised to a relative path and kept, which is
|
||||
// safe (it lands under the userdata root). Pin that so the behaviour is a decision, not a guess.
|
||||
if !slices.Contains(got, "abs/path") {
|
||||
t.Errorf("an absolute entry should be normalised to relative, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureUserdataSkeleton creates every dir it is given and NOTHING ELSE, and never removes.
|
||||
func TestEnsureUserdataSkeletonCreatesOnly(t *testing.T) {
|
||||
ns := t.TempDir()
|
||||
// A pre-existing customer dir that no catalog app implies and the carry-list does not contain.
|
||||
stray := filepath.Join(UserdataDir(ns), "sajat-mappa")
|
||||
if err := os.MkdirAll(stray, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dirs := BuildUserdataSkeleton([]string{"media/podcasts"})
|
||||
if err := EnsureUserdataSkeleton(ns, dirs); err != nil {
|
||||
// chown to gid 1000 fails for a non-root test user; the dirs are still created.
|
||||
t.Logf("EnsureUserdataSkeleton returned %v (expected when not running as root)", err)
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if fi, err := os.Stat(filepath.Join(UserdataDir(ns), d)); err != nil || !fi.IsDir() {
|
||||
t.Errorf("skeleton dir %q not created: %v", d, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(stray); err != nil {
|
||||
t.Errorf("a pre-existing customer dir was removed — zero-removals violated: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package appbackup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Customer-facing userdata layout + the shared-storage ownership convention (v0.66.0).
|
||||
@@ -28,9 +31,41 @@ func UserdataDir(nsRoot string) string {
|
||||
return filepath.Join(nsRoot, "userdata")
|
||||
}
|
||||
|
||||
// UserdataSkeleton is the standard subtree created on every storage path (relative to UserdataDir).
|
||||
// ImportDirName is the single import (drop-zone) subtree name under a userdata root.
|
||||
const ImportDirName = "import"
|
||||
|
||||
// ImportDir returns the CANONICAL drop-zone root under a namespace root (R-75).
|
||||
//
|
||||
// Unlike every other userdata dir, this one is drive-INDEPENDENT: the caller resolves it against the
|
||||
// SYSTEM drive's namespace root, never against the app's own HDD_PATH, so a multi-drive box has
|
||||
// exactly ONE import tree. That is the whole point. Each drop-zone app has exactly one ingest bind,
|
||||
// so a per-drive import/ would put a folder that LOOKS like a drop-zone on every drive while only
|
||||
// one of them does anything — and because import paths are `class: excluded`, files stranded in a
|
||||
// dead one are never backed up either.
|
||||
//
|
||||
// It deliberately stays INSIDE the userdata tree, so the 2775/setgid/GID-1000 convention, the
|
||||
// FileBrowser mount and the ownership rules all apply to it unchanged.
|
||||
func ImportDir(nsRoot string) string {
|
||||
return filepath.Join(UserdataDir(nsRoot), ImportDirName)
|
||||
}
|
||||
|
||||
// UserdataSkeletonCarry is the explicit NON-DERIVED carry-list: every entry the v0.171.0 hardcoded
|
||||
// skeleton created, retained verbatim and forever.
|
||||
//
|
||||
// It exists so the catalog-derived skeleton (R-75) can only ever ADD. That makes the zero-removals
|
||||
// invariant true BY CONSTRUCTION rather than by review, and it is not hypothetical:
|
||||
//
|
||||
// - `documents` is implied by NO catalog app (SPIKE P0(a)) yet exists on both demo boxes and is
|
||||
// customer-visible — it may hold customer files. Derivation alone would drop it.
|
||||
// - `import/paperless` and `import/calibre` moved to the canonical system-drive root in R-75, so
|
||||
// derivation no longer implies them under a data drive either. The pre-existing ones stay put;
|
||||
// nothing in this arc deletes a directory.
|
||||
//
|
||||
// It doubles as the fresh-box floor: on a box whose catalog has not synced yet the derived set is
|
||||
// empty, and the customer still gets the full standard tree instead of a nearly-empty one.
|
||||
//
|
||||
// ASCII, no spaces (flows through ${} interpolation, shell, and the rsync merge walk).
|
||||
func UserdataSkeleton() []string {
|
||||
func UserdataSkeletonCarry() []string {
|
||||
return []string{
|
||||
"media", "media/movies", "media/tv", "media/music", "media/audiobooks",
|
||||
"media/books", "media/comics", "media/photos",
|
||||
@@ -41,6 +76,41 @@ func UserdataSkeleton() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// BuildUserdataSkeleton merges the catalog-derived dirs with the carry-list into the final, SORTED
|
||||
// set. Each entry is expanded to its ancestor chain ("media/podcasts" implies "media"), deduped, and
|
||||
// sorted.
|
||||
//
|
||||
// SORTING IS A HARD REQUIREMENT, not tidiness. The FileBrowser config is regenerated from this set
|
||||
// and fbNeedsRecreate force-recreates the container on ANY byte difference. Go randomises map
|
||||
// iteration, and the spike measured the naive map-order derivation producing 20 DISTINCT outputs from
|
||||
// 20 identical runs (SPIKE P6) — which across SyncFileBrowserMounts' ~14 call sites is a fleet-wide
|
||||
// FileBrowser restart loop. TestSkeletonDeterminism pins this.
|
||||
func BuildUserdataSkeleton(derived []string) []string {
|
||||
set := make(map[string]bool, len(derived)+16)
|
||||
addChain := func(rel string) {
|
||||
rel = path.Clean(strings.TrimPrefix(filepath.ToSlash(rel), "/"))
|
||||
if rel == "" || rel == "." || rel == ".." || strings.HasPrefix(rel, "../") {
|
||||
return // never let a traversal or an empty entry become a directory to create
|
||||
}
|
||||
parts := strings.Split(rel, "/")
|
||||
for i := range parts {
|
||||
set[strings.Join(parts[:i+1], "/")] = true
|
||||
}
|
||||
}
|
||||
for _, d := range UserdataSkeletonCarry() {
|
||||
addChain(d)
|
||||
}
|
||||
for _, d := range derived {
|
||||
addChain(d)
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for d := range set { // map order is RANDOM — the sort below is what makes this deterministic
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// EnsureDirOwned creates path (idempotent) and enforces the convention: mode 2775 via an explicit
|
||||
// Chmod incl. setgid (MkdirAll cannot) + group = gid. Setting an arbitrary group needs CAP_CHOWN —
|
||||
// the in-guest controller runs as root, so this succeeds in production. Returns the first hard error.
|
||||
@@ -60,7 +130,11 @@ func EnsureUserdataDir(path string) error { return EnsureDirOwned(path, SharedCo
|
||||
// EnsureUserdataSkeleton creates the full userdata tree under a namespace root with the convention.
|
||||
// It creates ALL dirs even if one errors (so a single chown/chmod hiccup doesn't truncate the tree),
|
||||
// returning the first error seen for the caller to log.
|
||||
func EnsureUserdataSkeleton(nsRoot string) error {
|
||||
//
|
||||
// dirs is the merged, sorted set from BuildUserdataSkeleton. This function only ever CREATES: there
|
||||
// is no removal path here or anywhere in R-75, so a directory the current catalog no longer implies
|
||||
// simply stays where it is (Scenario D).
|
||||
func EnsureUserdataSkeleton(nsRoot string, dirs []string) error {
|
||||
base := UserdataDir(nsRoot)
|
||||
var firstErr error
|
||||
rec := func(e error) {
|
||||
@@ -69,7 +143,7 @@ func EnsureUserdataSkeleton(nsRoot string) error {
|
||||
}
|
||||
}
|
||||
rec(EnsureUserdataDir(base))
|
||||
for _, sub := range UserdataSkeleton() {
|
||||
for _, sub := range dirs {
|
||||
rec(EnsureUserdataDir(filepath.Join(base, sub)))
|
||||
}
|
||||
return firstErr
|
||||
|
||||
@@ -13,10 +13,12 @@ func TestSharedContentGID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserdataSkeleton_List asserts the locked skeleton subdir set.
|
||||
// TestUserdataSkeleton_List asserts the locked skeleton subdir set. R-75 renamed the hardcoded list
|
||||
// to UserdataSkeletonCarry (it is now the non-derived carry-list); the asserted set is UNCHANGED,
|
||||
// which is exactly the zero-removals promise.
|
||||
func TestUserdataSkeleton_List(t *testing.T) {
|
||||
got := map[string]bool{}
|
||||
for _, s := range UserdataSkeleton() {
|
||||
for _, s := range UserdataSkeletonCarry() {
|
||||
got[s] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
@@ -41,9 +43,10 @@ func TestUserdataDir(t *testing.T) {
|
||||
// is ignored — dirs + setgid still land). Runs cross-platform.
|
||||
func TestEnsureUserdataSkeleton_Structure(t *testing.T) {
|
||||
ns := t.TempDir()
|
||||
_ = EnsureUserdataSkeleton(ns) // ignore chown error on a non-root CI host
|
||||
dirs := BuildUserdataSkeleton(nil) // no catalog derived → the carry-list floor
|
||||
_ = EnsureUserdataSkeleton(ns, dirs) // ignore chown error on a non-root CI host
|
||||
base := UserdataDir(ns)
|
||||
for _, sub := range append([]string{""}, UserdataSkeleton()...) {
|
||||
for _, sub := range append([]string{""}, dirs...) {
|
||||
p := filepath.Join(base, sub)
|
||||
if fi, err := os.Stat(p); err != nil || !fi.IsDir() {
|
||||
t.Errorf("skeleton dir missing: %s (%v)", p, err)
|
||||
|
||||
@@ -23,6 +23,7 @@ type hddProvider struct {
|
||||
func (p *hddProvider) GetStackNeedsHDD(string) bool { return true }
|
||||
func (p *hddProvider) GetStackHDDMounts(string) []string { return p.mounts }
|
||||
func (p *hddProvider) GetStackHDDPath(string) string { return p.hddPath }
|
||||
func (p *hddProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *hddProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||
return p.binds, p.hasBinds
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (e *Exporter) computeFabPlan(req ExportRequest, mounts []string) fabPlan {
|
||||
return fabPlan{} // legacy: byte-identical v0.130.0 capture
|
||||
}
|
||||
hddPath := filepath.Clean(e.provider.GetStackHDDPath(req.StackName))
|
||||
fb := appbackup.ComputeFabBuckets(binds, has, hddPath)
|
||||
fb := appbackup.ComputeFabBuckets(binds, has, hddPath, e.provider.GetImportRoot())
|
||||
|
||||
deselect := sliceSet(req.DeselectOptional)
|
||||
optIn := sliceSet(req.OptInExcluded)
|
||||
@@ -117,7 +117,7 @@ func (e *Exporter) fabEstimateSplit(stackName string, est *ExportEstimate, volum
|
||||
return
|
||||
}
|
||||
hddPath := filepath.Clean(e.provider.GetStackHDDPath(stackName))
|
||||
fb := appbackup.ComputeFabBuckets(binds, has, hddPath)
|
||||
fb := appbackup.ComputeFabBuckets(binds, has, hddPath, e.provider.GetImportRoot())
|
||||
est.HasClassification = true
|
||||
|
||||
toItems := func(cps []appbackup.CapturePath) ([]FabItem, int64) {
|
||||
|
||||
@@ -23,6 +23,7 @@ type fabProv struct {
|
||||
}
|
||||
|
||||
func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath }
|
||||
func (p *fabProv) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
|
||||
func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||
return p.binds, p.has
|
||||
|
||||
@@ -17,6 +17,9 @@ type ExportStackProvider interface {
|
||||
GetStackHDDMounts(name string) []string
|
||||
// GetStackHDDPath returns the raw HDD_PATH env var from app.yaml.
|
||||
GetStackHDDPath(name string) string
|
||||
// GetImportRoot returns the CANONICAL drop-zone root (R-75), on the SYSTEM drive. ${IMPORT_PATH}
|
||||
// binds resolve against THIS, never against GetStackHDDPath. Empty when unresolvable.
|
||||
GetImportRoot() string
|
||||
// GetStackClassifiedBinds returns the app's backup-classified compose binds + whether it carries a
|
||||
// (valid) backup block (Task 2). Drives the `.fab` class-scoped export plan (Task 4); a legacy app
|
||||
// (false) exports the v0.130.0 full-root capture unchanged.
|
||||
|
||||
@@ -37,6 +37,7 @@ func (p *rtProvider) GetStackComposePath(string) (string, bool) {
|
||||
}
|
||||
func (p *rtProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *rtProvider) GetStackHDDPath(string) string { return "" }
|
||||
func (p *rtProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *rtProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func (p *offbox3aProvider) GetStackComposePath(string) (string, bool) { return "
|
||||
func (p *offbox3aProvider) ListDeployedStacks() []StackSummary { return nil }
|
||||
func (p *offbox3aProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *offbox3aProvider) GetStackHDDPath(n string) string { return p.hdd[n] }
|
||||
func (p *offbox3aProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *offbox3aProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *offbox3aProvider) StopStack(string) error { return nil }
|
||||
func (p *offbox3aProvider) StartStack(string) error { return nil }
|
||||
|
||||
@@ -46,7 +46,7 @@ func (m *Manager) offboxCaptureSet(stack string) (extra []string, warns []string
|
||||
return nil, []string{fmt.Sprintf("Figyelmeztetés: a(z) %s nincs telepítve — csak a mentési egység került a távoli mentésbe.", stack)}
|
||||
}
|
||||
nsRoot := m.namespaceRoot(hdd)
|
||||
cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, nsRoot)
|
||||
cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, nsRoot, m.stackProvider.GetImportRoot())
|
||||
|
||||
var gaps []string
|
||||
// Structurally-refused MANDATORY paths (traversal / bare drive-root / reserved backups/ zone) are
|
||||
|
||||
@@ -37,6 +37,7 @@ func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) {
|
||||
func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { return nil }
|
||||
func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd }
|
||||
func (f *fakeRecoveryProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (f *fakeRecoveryProvider) StopStack(string) error {
|
||||
f.stopped = true
|
||||
|
||||
@@ -43,7 +43,7 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns
|
||||
// Classified path.
|
||||
if m.stackProvider != nil {
|
||||
if binds, has := m.stackProvider.GetStackClassifiedBinds(stack); has {
|
||||
cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, nsRoot)
|
||||
cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, nsRoot, m.stackProvider.GetImportRoot())
|
||||
var gaps []string
|
||||
for _, sk := range cs.Skipped {
|
||||
if sk.Class == appbackup.ClassMandatory {
|
||||
|
||||
@@ -27,6 +27,7 @@ func (f *t2rFakeProvider) GetStackComposePath(string) (string, bool) { return ""
|
||||
func (f *t2rFakeProvider) ListDeployedStacks() []StackSummary { return nil }
|
||||
func (f *t2rFakeProvider) GetStackHDDMounts(string) []string { return f.mounts }
|
||||
func (f *t2rFakeProvider) GetStackHDDPath(string) string { return f.hdd }
|
||||
func (f *t2rFakeProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (f *t2rFakeProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (f *t2rFakeProvider) StopStack(name string) error {
|
||||
f.stopped = append(f.stopped, name)
|
||||
|
||||
@@ -25,6 +25,7 @@ func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", f
|
||||
func (p *t2v2Provider) ListDeployedStacks() []StackSummary { return nil }
|
||||
func (p *t2v2Provider) GetStackHDDMounts(n string) []string { return p.mounts[n] }
|
||||
func (p *t2v2Provider) GetStackHDDPath(string) string { return p.hdd }
|
||||
func (p *t2v2Provider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *t2v2Provider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *t2v2Provider) StopStack(string) error { return nil }
|
||||
func (p *t2v2Provider) StartStack(string) error { return nil }
|
||||
|
||||
@@ -25,6 +25,7 @@ func (f *volDumpFakeProvider) GetStackComposePath(string) (string, bool) { retur
|
||||
func (f *volDumpFakeProvider) ListDeployedStacks() []StackSummary { return f.stacks }
|
||||
func (f *volDumpFakeProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (f *volDumpFakeProvider) GetStackHDDPath(name string) string { return f.hdd[name] }
|
||||
func (f *volDumpFakeProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (f *volDumpFakeProvider) GetDockerVolumes(name string) []string { return f.volumes[name] }
|
||||
func (f *volDumpFakeProvider) StopStack(name string) error {
|
||||
f.stopped = append(f.stopped, name)
|
||||
|
||||
@@ -25,6 +25,15 @@ const (
|
||||
TraefikImage = "traefik:v3.6.7"
|
||||
CloudflaredImage = "cloudflare/cloudflared:2026.6.0"
|
||||
FileBrowserImage = "gtstef/filebrowser:1.3.3-stable"
|
||||
// FileBrowserImportMount is the in-container mount point NAME for the canonical drop-zone
|
||||
// (R-75): the bind lands at /srv/<this>. ASCII and space-free on purpose — it appears in a
|
||||
// container path, in the generated compose, and (percent-encoded) in the deep-link URL.
|
||||
FileBrowserImportMount = "beolvasas"
|
||||
// FileBrowserImportLabel is the Hungarian SIDEBAR name of that source. It is the display name and
|
||||
// it IS the URL identity: FileBrowser Quantum keys sources by name (SPIKE P1) and the deep-link
|
||||
// template is /files/{encodeURIComponent(name)}/... (SPIKE P2). Accents round-trip correctly —
|
||||
// the spike verified an accented, spaced and ampersand'd source name end to end.
|
||||
FileBrowserImportLabel = "Beolvasás"
|
||||
// SambaImage is our own pinned LAN-sharing image (R-7 slice 1). Built by
|
||||
// controller/scripts/build-samba-image.sh from controller/infra-images/samba/. NEVER :latest.
|
||||
SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.1.0"
|
||||
@@ -256,12 +265,20 @@ http:
|
||||
// RenderFileBrowserConfig returns a FileBrowser Quantum config.yaml with one source per registered
|
||||
// storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported
|
||||
// verbatim from internal/web/handlers.go.
|
||||
func RenderFileBrowserConfig(paths []settings.StoragePath) string {
|
||||
func RenderFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
|
||||
var sources string
|
||||
if len(paths) == 0 {
|
||||
// The canonical drop-zone (R-75) is FIRST and is NOT a registered storage path — it is a separate
|
||||
// bind of <system namespace>/userdata/import. Separate rather than nested inside a drive source:
|
||||
// the spike proved a nested source works but gets indexed TWICE (once as its own root, once as a
|
||||
// child of the parent drive), which buys nothing over a separate bind.
|
||||
if importSource {
|
||||
sources += fmt.Sprintf(" - path: %q\n name: %q\n config:\n defaultEnabled: true\n",
|
||||
"/srv/"+FileBrowserImportMount, FileBrowserImportLabel)
|
||||
}
|
||||
if len(paths) == 0 && !importSource {
|
||||
sources = ` - path: "/srv"
|
||||
`
|
||||
} else {
|
||||
} else if len(paths) > 0 {
|
||||
for _, sp := range paths {
|
||||
mountName := filepath.Base(sp.Path)
|
||||
label := sp.Label
|
||||
|
||||
@@ -46,7 +46,7 @@ func allRendered(t *testing.T) []string {
|
||||
}
|
||||
out = append(out, RenderFileBrowserCompose("example.com", nil))
|
||||
out = append(out, RenderFileBrowserCompose("example.com", []string{" - /mnt/hdd_1:/srv/hdd_1"}))
|
||||
out = append(out, RenderFileBrowserConfig(nil))
|
||||
out = append(out, RenderFileBrowserConfig(nil, false))
|
||||
out = append(out, RenderServersTransports())
|
||||
return out
|
||||
}
|
||||
@@ -224,13 +224,13 @@ func TestFileBrowserRender(t *testing.T) {
|
||||
}
|
||||
|
||||
// Default config (no storage paths) → a single /srv source.
|
||||
def := RenderFileBrowserConfig(nil)
|
||||
def := RenderFileBrowserConfig(nil, false)
|
||||
if !strings.Contains(def, `- path: "/srv"`) {
|
||||
t.Errorf("empty config must default to a /srv source: %q", def)
|
||||
}
|
||||
|
||||
// With paths → a named per-drive source.
|
||||
withPaths := RenderFileBrowserConfig([]settings.StoragePath{{Path: "/mnt/hdd_1", Label: "Media"}})
|
||||
withPaths := RenderFileBrowserConfig([]settings.StoragePath{{Path: "/mnt/hdd_1", Label: "Media"}}, false)
|
||||
if !strings.Contains(withPaths, `- path: "/srv/hdd_1"`) || !strings.Contains(withPaths, `name: "Media"`) {
|
||||
t.Errorf("storage path not wired into filebrowser config: %q", withPaths)
|
||||
}
|
||||
|
||||
@@ -30,8 +30,19 @@ type SMBShare struct {
|
||||
ReadOnly bool `json:"read_only,omitempty"` // smb.conf `read only = yes` + a :ro compose bind
|
||||
Offsite bool `json:"offsite"` // [R4] true (default) → backup class mandatory; false → optional (tier-2 only)
|
||||
CreatedAt string `json:"created_at"` // RFC3339
|
||||
// System marks a controller-OWNED share the customer may not delete (R-75). Today that is the
|
||||
// canonical drop-zone („beolvasas"), which is auto-created whenever sharing is enabled and whose
|
||||
// absence would silently break the documented „drop a file in Beolvasás over the network" flow.
|
||||
// Its Path is a controller-generated constant derived from config — never customer input — which
|
||||
// is why it does NOT go through sharingResolvePath (that guard exists to validate the paths a
|
||||
// CUSTOMER picks, a different trust class).
|
||||
System bool `json:"system,omitempty"`
|
||||
}
|
||||
|
||||
// SystemImportShareName is the fixed name of the canonical drop-zone share (R-75). ASCII and
|
||||
// nbNameRe-safe on purpose: it is a NetBIOS share name and appears in \\SERVER\<name>.
|
||||
const SystemImportShareName = "beolvasas"
|
||||
|
||||
// nbNameRe matches a NetBIOS-safe name: 1–15 chars, letters/digits/hyphen/underscore, not starting
|
||||
// or ending with a hyphen. Deliberately stricter than SMB share-name rules (slice 1 keeps the flat
|
||||
// name and the share name in the same safe space; slice 2 may relax share names).
|
||||
@@ -172,9 +183,19 @@ func (s *Settings) AddSMBShare(share SMBShare) error {
|
||||
}
|
||||
|
||||
// RemoveSMBShare deletes a share by name (case-insensitive). Config-only: never touches the folder.
|
||||
//
|
||||
// A System share is REFUSED here, at the store layer, so every caller inherits the rule — the web
|
||||
// handler has its own check too, and that duplication is deliberate: a handler test that POSTs
|
||||
// directly proves nothing about UI reachability, and a hidden button proves nothing about
|
||||
// enforcement (the v0.70.1 lesson). They are separate concerns.
|
||||
func (s *Settings) RemoveSMBShare(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, ex := range s.SMBShares {
|
||||
if strings.EqualFold(ex.Name, name) && ex.System {
|
||||
return fmt.Errorf("a(z) „%s” megosztás a rendszer része, nem törölhető", ex.Name)
|
||||
}
|
||||
}
|
||||
var kept []SMBShare
|
||||
found := false
|
||||
for _, ex := range s.SMBShares {
|
||||
|
||||
@@ -47,12 +47,12 @@ backup:
|
||||
wantPhotos := path.Join(hddPath, "userdata", "media/photos")
|
||||
|
||||
// Secondary: mandatory + optional, sorted by Abs.
|
||||
sec := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierSecondary, hddPath)
|
||||
sec := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierSecondary, hddPath, "")
|
||||
if got, want := absSet(sec), []string{wantWiretest, wantPhotos}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("secondary Paths = %v, want %v", got, want)
|
||||
}
|
||||
// Offsite: mandatory only — the explicit-optional :ro library must NOT appear.
|
||||
off := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierOffsite, hddPath)
|
||||
off := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierOffsite, hddPath, "")
|
||||
if got, want := absSet(off), []string{wantWiretest}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("offsite Paths = %v, want %v (optional :ro must not ship offsite)", got, want)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ var composeVarRoots = []struct {
|
||||
}{
|
||||
{"${USERDATA_PATH}", appbackup.RootUserdata},
|
||||
{"${HDD_PATH}", appbackup.RootHDD},
|
||||
// ${IMPORT_PATH} (R-75) is the canonical drop-zone root on the SYSTEM drive. It must be listed
|
||||
// BEFORE any prefix of it could match — it shares no prefix with the other two, so order is not
|
||||
// load-bearing here, but the classifier works in ${VAR}-relative space and never resolves envs,
|
||||
// so this entry is what makes an ingest bind classifiable at all.
|
||||
{"${IMPORT_PATH}", appbackup.RootImport},
|
||||
}
|
||||
|
||||
// ParseComposeClassifiableBinds extracts the ${HDD_PATH}/${USERDATA_PATH}-relative host binds from a
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
// `data_paths:` — the customer-facing folder ANNOTATION (R-75).
|
||||
//
|
||||
// It annotates paths that ALREADY EXIST as compose binds; it can never declare a new one. That is
|
||||
// what keeps it cheap: no new filesystem-write primitive is introduced from catalog data, because
|
||||
// every path it names is a path the deploy belt already creates.
|
||||
|
||||
// DataPathRole is the presentation role of an annotated folder.
|
||||
type DataPathRole string
|
||||
|
||||
const (
|
||||
RoleImport DataPathRole = "import" // a drop-zone: the app consumes and usually DELETES from it
|
||||
RoleLibrary DataPathRole = "library" // where the customer's files live
|
||||
RoleExport DataPathRole = "export" // the app writes results here for the customer to collect
|
||||
)
|
||||
|
||||
// DataPath is one entry of the `data_paths:` block.
|
||||
type DataPath struct {
|
||||
Path string `yaml:"path" json:"path"` // relative to Root, must match a compose bind
|
||||
Root appbackup.BindRoot `yaml:"root" json:"root"` // import | userdata | hdd
|
||||
Role DataPathRole `yaml:"role" json:"role"` // import | library | export
|
||||
Label string `yaml:"label" json:"label"` // Hungarian, customer-facing
|
||||
}
|
||||
|
||||
// validRole reports whether r is a role this controller knows how to render.
|
||||
func validRole(r DataPathRole) bool {
|
||||
switch r {
|
||||
case RoleImport, RoleLibrary, RoleExport:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateDataPaths applies the Fork-3 ruling, and the ASYMMETRY is deliberate — a decision, not an
|
||||
// inconsistency:
|
||||
//
|
||||
// - A MALFORMED PATH is a whole-block reject (returns an error; the caller drops the entire block
|
||||
// and the app renders no folder card). Paths govern DATA HANDLING: a path that is absolute,
|
||||
// escapes the root, or matches no compose bind would point the customer at a directory that is
|
||||
// not the app's, so nothing from that block can be trusted. This follows the `backup:` precedent
|
||||
// and reuses its refusal set (appbackup.ValidateRelPath) rather than defining a second one.
|
||||
//
|
||||
// - An UNKNOWN ROLE fails OPEN (that one entry is dropped with a WARN; everything else renders).
|
||||
// Roles govern PRESENTATION only, and this follows the Lifecycle precedent (metadata.go): a typo
|
||||
// in a catalog push must never brick a template. The cost of an unknown role is one missing UI
|
||||
// affordance, never a mishandled file.
|
||||
//
|
||||
// Returns the surviving entries. An error means the caller must discard the WHOLE block.
|
||||
func ValidateDataPaths(entries []DataPath, binds []appbackup.ComposeBind, appName string, logger *log.Logger) ([]DataPath, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
present := make(map[appbackup.BindRoot]map[string]bool)
|
||||
for _, b := range binds {
|
||||
if present[b.Root] == nil {
|
||||
present[b.Root] = make(map[string]bool)
|
||||
}
|
||||
present[b.Root][b.RelPath] = true
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
out := make([]DataPath, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
// --- path rules: whole-block reject ---
|
||||
if !appbackup.ValidRoot(e.Root) {
|
||||
return nil, fmt.Errorf("data_paths[%q]: unknown root %q (want import|userdata|hdd)", e.Path, e.Root)
|
||||
}
|
||||
if err := appbackup.ValidateRelPath(e.Root, e.Path); err != nil {
|
||||
return nil, fmt.Errorf("data_paths: %w", err)
|
||||
}
|
||||
key := string(e.Root) + "\x00" + e.Path
|
||||
if seen[key] {
|
||||
return nil, fmt.Errorf("data_paths[%s/%s]: duplicate entry", e.Root, e.Path)
|
||||
}
|
||||
seen[key] = true
|
||||
if !present[e.Root][e.Path] {
|
||||
return nil, fmt.Errorf("data_paths[%s/%s]: matches no compose bind — data_paths ANNOTATES existing binds, it cannot declare new ones", e.Root, e.Path)
|
||||
}
|
||||
// --- role: fail OPEN ---
|
||||
if !validRole(e.Role) {
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [stacks] %s: unknown data_paths role %q for %s/%s — entry not surfaced (known: %s, %s, %s)",
|
||||
appName, e.Role, e.Root, e.Path, RoleImport, RoleLibrary, RoleExport)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
func dpBinds() []appbackup.ComposeBind {
|
||||
return []appbackup.ComposeBind{
|
||||
{Root: appbackup.RootImport, RelPath: "paperless"},
|
||||
{Root: appbackup.RootUserdata, RelPath: "media/books"},
|
||||
{Root: appbackup.RootHDD, RelPath: "appdata/paperless/media"},
|
||||
}
|
||||
}
|
||||
|
||||
func quietLogger() *log.Logger { return log.New(io.Discard, "", 0) }
|
||||
|
||||
// Fork-3 ruling, half 1: a MALFORMED PATH is a WHOLE-BLOCK reject. Paths govern data handling.
|
||||
func TestDataPaths_MalformedPathWholeBlockRejects(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
entry DataPath
|
||||
}{
|
||||
{"absolute", DataPath{Path: "/etc/passwd", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"traversal", DataPath{Path: "../../etc", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"backslash", DataPath{Path: `a\b`, Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"non-clean", DataPath{Path: "a//b", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"empty", DataPath{Path: "", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"matches no compose bind", DataPath{Path: "nincs-ilyen", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"unknown root", DataPath{Path: "paperless", Root: appbackup.BindRoot("bogus"), Role: RoleImport, Label: "x"}},
|
||||
} {
|
||||
// A VALID entry sits alongside it — the reject must take the whole block, not just the bad one.
|
||||
entries := []DataPath{
|
||||
{Path: "media/books", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "ok"},
|
||||
tc.entry,
|
||||
}
|
||||
kept, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
|
||||
if err == nil {
|
||||
t.Errorf("%s: must be rejected, got kept=%v", tc.name, kept)
|
||||
}
|
||||
if kept != nil {
|
||||
t.Errorf("%s: a whole-block reject must keep NOTHING, got %v", tc.name, kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fork-3 ruling, half 2: an UNKNOWN ROLE fails OPEN. Roles govern presentation only, so a typo in a
|
||||
// catalog push must never brick the template (the Lifecycle precedent).
|
||||
func TestDataPaths_UnknownRoleFailsOpen(t *testing.T) {
|
||||
entries := []DataPath{
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: DataPathRole("beolvasas"), Label: "typo role"},
|
||||
{Path: "media/books", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "E-könyvtár"},
|
||||
}
|
||||
kept, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("an unknown ROLE must not reject the block: %v", err)
|
||||
}
|
||||
if len(kept) != 1 || kept[0].Path != "media/books" {
|
||||
t.Errorf("expected only the valid entry to survive, got %v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
// The happy path across all three roots and roles.
|
||||
func TestDataPaths_AllRootsAndRoles(t *testing.T) {
|
||||
entries := []DataPath{
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: RoleImport, Label: "Beolvasandó dokumentumok"},
|
||||
{Path: "media/books", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "E-könyvtár"},
|
||||
{Path: "appdata/paperless/media", Root: appbackup.RootHDD, Role: RoleExport, Label: "Export"},
|
||||
}
|
||||
kept, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("valid block rejected: %v", err)
|
||||
}
|
||||
if len(kept) != 3 {
|
||||
t.Errorf("expected all 3 entries kept, got %d: %v", len(kept), kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataPaths_DuplicateRejects(t *testing.T) {
|
||||
entries := []DataPath{
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: RoleImport, Label: "a"},
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: RoleImport, Label: "b"},
|
||||
}
|
||||
if _, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger()); err == nil {
|
||||
t.Error("a duplicate (root, path) must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// data_paths ANNOTATES; it must never be able to declare a path that is not already a compose bind.
|
||||
// This is the property that keeps the design from introducing a new filesystem-write primitive.
|
||||
func TestDataPaths_CannotDeclareNewPaths(t *testing.T) {
|
||||
entries := []DataPath{
|
||||
{Path: "uj-mappa", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "new"},
|
||||
}
|
||||
_, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
|
||||
if err == nil {
|
||||
t.Fatal("data_paths must not be able to name a path with no compose bind behind it")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end through LoadMetadata: the catalog shape parses, validates, and survives.
|
||||
func TestLoadMetadata_DataPathsRoundTrip(t *testing.T) {
|
||||
compose := `services:
|
||||
calibre-web:
|
||||
image: x
|
||||
volumes:
|
||||
- ${IMPORT_PATH}/calibre:/cwa-book-ingest
|
||||
- ${USERDATA_PATH}/media/books:/calibre-library
|
||||
`
|
||||
meta := `display_name: Calibre-Web
|
||||
slug: calibre-web
|
||||
backup:
|
||||
userdata:
|
||||
- path: media/books
|
||||
class: mandatory
|
||||
import:
|
||||
- path: calibre
|
||||
class: excluded
|
||||
data_paths:
|
||||
- path: calibre
|
||||
root: import
|
||||
role: import
|
||||
label: "Beolvasandó e-könyvek"
|
||||
- path: media/books
|
||||
root: userdata
|
||||
role: library
|
||||
label: "E-könyvtár"
|
||||
`
|
||||
dir := writeApp(t, compose, meta)
|
||||
m := LoadMetadata(dir)
|
||||
if len(m.DataPaths) != 2 {
|
||||
t.Fatalf("expected 2 data_paths, got %d: %+v", len(m.DataPaths), m.DataPaths)
|
||||
}
|
||||
if m.Backup == nil {
|
||||
t.Error("the backup block must survive alongside data_paths")
|
||||
}
|
||||
if m.DataPaths[0].Label != "Beolvasandó e-könyvek" || m.DataPaths[0].Role != RoleImport {
|
||||
t.Errorf("first entry wrong: %+v", m.DataPaths[0])
|
||||
}
|
||||
_ = filepath.Clean
|
||||
}
|
||||
|
||||
// A bad data_paths block must NOT take the backup block down with it — they are validated
|
||||
// independently at the same choke point.
|
||||
func TestLoadMetadata_BadDataPathsKeepsBackupBlock(t *testing.T) {
|
||||
compose := `services:
|
||||
app:
|
||||
image: x
|
||||
volumes:
|
||||
- ${USERDATA_PATH}/media/books:/lib
|
||||
`
|
||||
meta := `display_name: X
|
||||
slug: x
|
||||
backup:
|
||||
userdata:
|
||||
- path: media/books
|
||||
class: mandatory
|
||||
data_paths:
|
||||
- path: /absolute/nope
|
||||
root: userdata
|
||||
role: library
|
||||
label: "bad"
|
||||
`
|
||||
dir := writeApp(t, compose, meta)
|
||||
m := LoadMetadata(dir)
|
||||
if m.DataPaths != nil {
|
||||
t.Errorf("malformed data_paths must be dropped, got %+v", m.DataPaths)
|
||||
}
|
||||
if m.Backup == nil {
|
||||
t.Error("a bad data_paths block must not reject the backup block — they are independent")
|
||||
}
|
||||
}
|
||||
@@ -513,49 +513,32 @@ func buildPathInfo(path string) HDDPath {
|
||||
|
||||
// ParseComposeUserdataMounts reads a docker-compose.yml and extracts the host bind-source paths that
|
||||
// reference ${USERDATA_PATH} (resolved to userdataPath) — the dirs the deploy belt must pre-create
|
||||
// with the userdata convention. Same scanner shape as ParseComposeHDDMounts.
|
||||
// with the userdata convention.
|
||||
//
|
||||
// R-75: this is now a thin RESOLVER over ParseComposeClassifiableBinds, which is the ONE authoritative
|
||||
// compose-bind scanner. The two used to be byte-for-byte duplicate scanners (SPIKE §3) differing only
|
||||
// in what they threw away, so a fix to one silently skipped the other; the classifier won because it
|
||||
// is the richer of the two (it keeps the root and the :ro flag, both of which this function discards
|
||||
// but the classification and derivation paths need).
|
||||
//
|
||||
// ONE deliberate behaviour drop, recorded rather than hidden: the old textual
|
||||
// strings.ReplaceAll("${USERDATA_PATH}", …) + containment check also accepted a bind written as a
|
||||
// LITERAL absolute path that happened to fall under userdataPath. The classifier matches the ${VAR}
|
||||
// reference only. No catalog template has ever used the literal form (verified across all 53 in
|
||||
// SPIKE §2 — every host token is a ${VAR}, a named volume, or the docker socket), and such a compose
|
||||
// would be pinned to one machine's drive layout, so the capability was dead.
|
||||
func ParseComposeUserdataMounts(composePath, userdataPath string) []string {
|
||||
if userdataPath == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
var out []string
|
||||
for _, b := range ParseComposeClassifiableBinds(composePath) {
|
||||
if b.Root != appbackup.RootUserdata {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.Join(userdataPath, filepath.FromSlash(b.RelPath)))
|
||||
}
|
||||
var mounts []string
|
||||
seen := make(map[string]bool)
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
inVolumes := false
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "volumes:") {
|
||||
inVolumes = true
|
||||
continue
|
||||
}
|
||||
if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
|
||||
inVolumes = false
|
||||
}
|
||||
if !inVolumes || !strings.HasPrefix(line, "- ") {
|
||||
continue
|
||||
}
|
||||
mountStr := strings.Trim(strings.TrimPrefix(line, "- "), "\"'")
|
||||
parts := strings.SplitN(mountStr, ":", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
hostPath := strings.ReplaceAll(parts[0], "${USERDATA_PATH}", userdataPath)
|
||||
cleanPath := filepath.Clean(hostPath)
|
||||
cleanUD := filepath.Clean(userdataPath)
|
||||
// must be userdataPath itself or a subpath (clean before check — traversal-safe)
|
||||
if cleanPath != cleanUD && !strings.HasPrefix(cleanPath, cleanUD+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
if !seen[cleanPath] {
|
||||
seen[cleanPath] = true
|
||||
mounts = append(mounts, cleanPath)
|
||||
}
|
||||
}
|
||||
return mounts
|
||||
return out
|
||||
}
|
||||
|
||||
// ExportDataMounts returns the host directories a .fab export must capture for an app: the
|
||||
|
||||
@@ -558,18 +558,29 @@ func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...
|
||||
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
|
||||
cmdEnv = withUserdataPath(cmdEnv, env["HDD_PATH"])
|
||||
cmdEnv = withPathVars(cmdEnv, env["HDD_PATH"], m.GetImportRoot())
|
||||
return m.composeExecCustomEnv(dir, cmdEnv, args...)
|
||||
}
|
||||
|
||||
// withUserdataPath appends USERDATA_PATH=<hdd>/userdata to a "K=V" env slice when hdd is non-empty.
|
||||
// withPathVars appends the two derived path variables to a "K=V" env slice:
|
||||
//
|
||||
// USERDATA_PATH=<hdd>/userdata — per-app, on the app's OWN drive (when hdd is non-empty)
|
||||
// IMPORT_PATH=<importRoot> — CANONICAL, on the system drive (when importRoot is non-empty)
|
||||
//
|
||||
// Shared by BOTH compose-env builders (stackEnv for start/redeploy, composeExecWithEnv for the initial
|
||||
// deploy) so ${USERDATA_PATH} always resolves — the initial-deploy path missing it bound a bogus
|
||||
// root-owned dir at the container root.
|
||||
func withUserdataPath(cmdEnv []string, hdd string) []string {
|
||||
// deploy) so the variables always resolve — the initial-deploy path missing USERDATA_PATH bound a
|
||||
// bogus root-owned dir at the container root, and IMPORT_PATH has the identical failure mode.
|
||||
//
|
||||
// An unresolvable importRoot is left UNSET on purpose (the caller logs it): compose then fails loudly
|
||||
// on an unresolved ${IMPORT_PATH} rather than silently falling back to a per-drive path, which would
|
||||
// recreate the dead-drop-zone shape R-75 exists to remove.
|
||||
func withPathVars(cmdEnv []string, hdd, importRoot string) []string {
|
||||
if hdd != "" {
|
||||
cmdEnv = append(cmdEnv, "USERDATA_PATH="+appbackup.UserdataDir(hdd))
|
||||
}
|
||||
if importRoot != "" {
|
||||
cmdEnv = append(cmdEnv, "IMPORT_PATH="+importRoot)
|
||||
}
|
||||
return cmdEnv
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
// R-75 Scenario B — THE regression gate for moving an ingest bind to ${IMPORT_PATH}.
|
||||
//
|
||||
// ValidateBackupSpec rejects an entry whose (Root, RelPath) matches NO compose bind, and the
|
||||
// rejection is WHOLE-BLOCK: LoadMetadata sets Backup back to nil and the app degrades to LEGACY.
|
||||
// So moving paperless's consume bind to ${IMPORT_PATH} while its backup block still said
|
||||
// `userdata: import/paperless` would discard the ENTIRE block — taking
|
||||
// `hdd: appdata/paperless/media class: mandatory` with it. The customer's document originals would
|
||||
// silently fall back to legacy handling. That is the collateral this test exists to catch.
|
||||
//
|
||||
// The fixtures below are copied VERBATIM from app-catalog-felhom.eu templates/paperless-ngx/ (the
|
||||
// bind lines and the whole backup block). If the catalog changes them, this test must be updated in
|
||||
// the same train — that coupling is the point, and Part-8 leg 1 re-checks it against the live
|
||||
// catalog on a real box.
|
||||
|
||||
const importPaperlessCompose = `services:
|
||||
paperless-webserver:
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4
|
||||
volumes:
|
||||
- paperless_data:/usr/src/paperless/data
|
||||
- ${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media
|
||||
- ${HDD_PATH}/appdata/paperless/export:/usr/src/paperless/export
|
||||
- ${IMPORT_PATH}/paperless:/usr/src/paperless/consume
|
||||
volumes:
|
||||
paperless_data:
|
||||
`
|
||||
|
||||
const importPaperlessMeta = `display_name: Paperless-ngx
|
||||
slug: paperless-ngx
|
||||
category: documents
|
||||
backup:
|
||||
hdd:
|
||||
- path: appdata/paperless/media
|
||||
class: mandatory
|
||||
- path: appdata/paperless/export
|
||||
class: excluded
|
||||
import:
|
||||
- path: paperless
|
||||
class: excluded
|
||||
`
|
||||
|
||||
// writeApp lays out a stack dir with a compose file and a .felhom.yml.
|
||||
func writeApp(t *testing.T, compose, meta string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestScenarioB_ImportMoveKeepsClassification(t *testing.T) {
|
||||
dir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
|
||||
|
||||
m := LoadMetadata(dir)
|
||||
if m.Backup == nil {
|
||||
t.Fatal("backup block was WHOLE-BLOCK REJECTED (degraded to legacy) — the import move lost the app's classification")
|
||||
}
|
||||
binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml"))
|
||||
classified, has := appbackup.ClassifyBinds(m.Backup, binds)
|
||||
if !has {
|
||||
t.Fatal("hasClassification=false — the app degraded to legacy")
|
||||
}
|
||||
|
||||
type key struct {
|
||||
root appbackup.BindRoot
|
||||
rel string
|
||||
}
|
||||
got := map[key]appbackup.ClassifiedBind{}
|
||||
for _, c := range classified {
|
||||
got[key{c.Root, c.RelPath}] = c
|
||||
}
|
||||
|
||||
want := []struct {
|
||||
root appbackup.BindRoot
|
||||
rel string
|
||||
class appbackup.BindClass
|
||||
origin appbackup.ClassOrigin
|
||||
why string
|
||||
}{
|
||||
{appbackup.RootImport, "paperless", appbackup.ClassExcluded, appbackup.OriginExplicit,
|
||||
"the moved ingest bind must classify under the import root"},
|
||||
{appbackup.RootHDD, "appdata/paperless/media", appbackup.ClassMandatory, appbackup.OriginExplicit,
|
||||
"THE COLLATERAL: a whole-block reject would silently drop this to legacy"},
|
||||
{appbackup.RootHDD, "appdata/paperless/export", appbackup.ClassExcluded, appbackup.OriginExplicit,
|
||||
"second hdd entry must survive too"},
|
||||
}
|
||||
for _, w := range want {
|
||||
c, ok := got[key{w.root, w.rel}]
|
||||
if !ok {
|
||||
t.Errorf("%s/%s: bind missing entirely — %s", w.root, w.rel, w.why)
|
||||
continue
|
||||
}
|
||||
if c.Class != w.class {
|
||||
t.Errorf("%s/%s: class = %q, want %q — %s", w.root, w.rel, c.Class, w.class, w.why)
|
||||
}
|
||||
if c.Origin != w.origin {
|
||||
t.Errorf("%s/%s: origin = %q, want %q — %s", w.root, w.rel, c.Origin, w.origin, w.why)
|
||||
}
|
||||
}
|
||||
// The explicit WRONG outcome from the scenario: nothing may report legacy.
|
||||
for _, c := range classified {
|
||||
if c.Origin == appbackup.OriginLegacy {
|
||||
t.Errorf("%s/%s reported origin=legacy — the block was rejected", c.Root, c.RelPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The companion in the other direction: a STALE `userdata: import/paperless` entry against the new
|
||||
// ${IMPORT_PATH} compose must be REFUSED, and refused WHOLE-BLOCK. This is the state the catalog
|
||||
// would be in if Part 2 moved the compose bind but forgot the backup block — it proves the trap is
|
||||
// real rather than hypothetical, and that the guard catches it rather than silently mis-classifying.
|
||||
func TestScenarioB_StaleUserdataEntryIsWholeBlockRejected(t *testing.T) {
|
||||
staleMeta := `display_name: Paperless-ngx
|
||||
slug: paperless-ngx
|
||||
backup:
|
||||
hdd:
|
||||
- path: appdata/paperless/media
|
||||
class: mandatory
|
||||
userdata:
|
||||
- path: import/paperless
|
||||
class: excluded
|
||||
`
|
||||
dir := writeApp(t, importPaperlessCompose, staleMeta)
|
||||
if m := LoadMetadata(dir); m.Backup != nil {
|
||||
t.Error("a stale userdata entry matching no compose bind must be whole-block rejected")
|
||||
}
|
||||
|
||||
// And prove the consequence the scenario names, so the reject is not mistaken for harmless:
|
||||
// with the block gone, the mandatory hdd path loses its class and goes legacy.
|
||||
m := LoadMetadata(dir)
|
||||
binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml"))
|
||||
classified, has := appbackup.ClassifyBinds(m.Backup, binds)
|
||||
if has {
|
||||
t.Fatal("precondition: block should be nil here")
|
||||
}
|
||||
for _, c := range classified {
|
||||
if c.Root == appbackup.RootHDD && c.RelPath == "appdata/paperless/media" {
|
||||
if c.Origin != appbackup.OriginLegacy || c.Class != "" {
|
||||
t.Errorf("expected the collateral to be legacy/unclassed, got class=%q origin=%q", c.Class, c.Origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calibre-web carries BOTH an import bind and a userdata library bind — the multi-root case.
|
||||
func TestScenarioB_CalibreBothRoots(t *testing.T) {
|
||||
compose := `services:
|
||||
calibre-web:
|
||||
image: crocodilestick/calibre-web-automated:v4.0.6
|
||||
volumes:
|
||||
- calibre_web_config:/config
|
||||
- ${IMPORT_PATH}/calibre:/cwa-book-ingest
|
||||
- ${USERDATA_PATH}/media/books:/calibre-library
|
||||
volumes:
|
||||
calibre_web_config:
|
||||
`
|
||||
meta := `display_name: Calibre-Web
|
||||
slug: calibre-web
|
||||
backup:
|
||||
userdata:
|
||||
- path: media/books
|
||||
class: mandatory
|
||||
import:
|
||||
- path: calibre
|
||||
class: excluded
|
||||
`
|
||||
dir := writeApp(t, compose, meta)
|
||||
m := LoadMetadata(dir)
|
||||
if m.Backup == nil {
|
||||
t.Fatal("calibre-web backup block was whole-block rejected")
|
||||
}
|
||||
classified, has := appbackup.ClassifyBinds(m.Backup, ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml")))
|
||||
if !has {
|
||||
t.Fatal("calibre-web degraded to legacy")
|
||||
}
|
||||
seen := map[string]appbackup.ClassifiedBind{}
|
||||
for _, c := range classified {
|
||||
seen[string(c.Root)+"/"+c.RelPath] = c
|
||||
}
|
||||
if c := seen["import/calibre"]; c.Class != appbackup.ClassExcluded || c.Origin != appbackup.OriginExplicit {
|
||||
t.Errorf("import/calibre: class=%q origin=%q, want excluded/explicit", c.Class, c.Origin)
|
||||
}
|
||||
if c := seen["userdata/media/books"]; c.Class != appbackup.ClassMandatory || c.Origin != appbackup.OriginExplicit {
|
||||
t.Errorf("userdata/media/books: class=%q origin=%q, want mandatory/explicit", c.Class, c.Origin)
|
||||
}
|
||||
}
|
||||
|
||||
// The import root resolves against the SYSTEM drive, never the app's own drive. Two apps on two
|
||||
// different drives must resolve their ingest folders to the SAME parent — the canonical property.
|
||||
func TestImportBindResolvesToSystemDrive(t *testing.T) {
|
||||
const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
|
||||
binds := []appbackup.ClassifiedBind{
|
||||
{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootImport, RelPath: "paperless"},
|
||||
Class: appbackup.ClassExcluded, Origin: appbackup.OriginExplicit},
|
||||
}
|
||||
for _, hdd := range []string{"/mnt/felhom-drives/hdd_1", "/mnt/felhom-drives/nvme-1tb"} {
|
||||
fb := appbackup.ComputeFabBuckets(binds, true, hdd, importRoot)
|
||||
if len(fb.Excluded) != 1 {
|
||||
t.Fatalf("hdd=%s: expected 1 excluded bucket entry, got %d (skipped=%v)", hdd, len(fb.Excluded), fb.Skipped)
|
||||
}
|
||||
if got, want := fb.Excluded[0].Abs, importRoot+"/paperless"; got != want {
|
||||
t.Errorf("hdd=%s: import bind resolved to %q, want %q — it must NOT follow the app's drive", hdd, got, want)
|
||||
}
|
||||
}
|
||||
// Unresolvable import root ⇒ refused LOUDLY into Skipped, never joined onto "".
|
||||
fb := appbackup.ComputeFabBuckets(binds, true, "/mnt/felhom-drives/hdd_1", "")
|
||||
if len(fb.Excluded) != 0 {
|
||||
t.Errorf("an unresolvable import root must not resolve: %+v", fb.Excluded)
|
||||
}
|
||||
if len(fb.Skipped) != 1 {
|
||||
t.Fatalf("expected the bind in Skipped, got %+v", fb.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// R-75 Scenario A — the deploy belt puts the drop-zone on the SYSTEM drive and NOWHERE ELSE.
|
||||
// The wrong outcome this guards is a second, non-functional import/<app> appearing on the data
|
||||
// drive: it would look exactly like a drop-zone, silently do nothing, and (import being
|
||||
// class: excluded) never be backed up either.
|
||||
func TestScenarioA_BeltCreatesImportOnSystemDriveOnly(t *testing.T) {
|
||||
m := newMigManager(t, "")
|
||||
stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
|
||||
|
||||
dataDrive := t.TempDir() // stands in for /mnt/felhom-drives/hdd_1
|
||||
sysNS := t.TempDir() // stands in for /mnt/sys_drive/felhom-data
|
||||
userdataPath := appbackup.UserdataDir(dataDrive)
|
||||
importPath := appbackup.ImportDir(sysNS)
|
||||
m.isMountPoint = func(string) bool { return true } // the data drive is attached
|
||||
|
||||
m.ensureUserdataMounts(stackDir, []string{
|
||||
"HDD_PATH=" + dataDrive,
|
||||
"USERDATA_PATH=" + userdataPath,
|
||||
"IMPORT_PATH=" + importPath,
|
||||
})
|
||||
|
||||
// (1) the drop-zone exists on the SYSTEM drive
|
||||
want := filepath.Join(importPath, "paperless")
|
||||
fi, err := os.Stat(want)
|
||||
if err != nil || !fi.IsDir() {
|
||||
t.Fatalf("belt did not create the canonical drop-zone %s (%v)", want, err)
|
||||
}
|
||||
// (2) with the userdata convention: setgid + group-rwx
|
||||
if perm := fi.Mode().Perm(); perm != 0o775 || fi.Mode()&os.ModeSetgid == 0 {
|
||||
t.Errorf("drop-zone mode = %v, want setgid + 0775 (2775)", fi.Mode())
|
||||
}
|
||||
// (3) and NOT on the data drive — the wrong outcome named in the scenario
|
||||
if _, err := os.Stat(filepath.Join(userdataPath, "import")); err == nil {
|
||||
t.Errorf("a second drop-zone was created on the data drive at %s — exactly the dead drop-zone R-75 removes",
|
||||
filepath.Join(userdataPath, "import"))
|
||||
}
|
||||
}
|
||||
|
||||
// A detached data drive must not stop the system-drive drop-zone from being created: the two roots
|
||||
// are on different devices and the drive-absent gate is about the data drive only.
|
||||
func TestScenarioA_ImportBeltNotGatedByDetachedDataDrive(t *testing.T) {
|
||||
m := newMigManager(t, "")
|
||||
stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
|
||||
dataDrive := t.TempDir()
|
||||
sysNS := t.TempDir()
|
||||
importPath := appbackup.ImportDir(sysNS)
|
||||
m.isMountPoint = func(string) bool { return false } // drive DETACHED
|
||||
|
||||
m.ensureUserdataMounts(stackDir, []string{
|
||||
"HDD_PATH=" + dataDrive,
|
||||
"USERDATA_PATH=" + appbackup.UserdataDir(dataDrive),
|
||||
"IMPORT_PATH=" + importPath,
|
||||
})
|
||||
|
||||
if _, err := os.Stat(filepath.Join(importPath, "paperless")); err != nil {
|
||||
t.Errorf("the system-drive drop-zone must be created even when the DATA drive is detached: %v", err)
|
||||
}
|
||||
// the userdata half stays correctly gated (nothing written onto the rootfs)
|
||||
if _, err := os.Stat(appbackup.UserdataDir(dataDrive)); err == nil {
|
||||
t.Error("the drive-absent gate must still suppress userdata creation on a detached drive")
|
||||
}
|
||||
}
|
||||
|
||||
// R-75: the canonical drop-zone must never migrate with an app. Migrating an app OFF the system
|
||||
// drive would otherwise drag <sysNS>/userdata/import onto the destination data drive — a second,
|
||||
// non-functional, unbacked drop-zone.
|
||||
func TestImportRootExcludedFromMigration(t *testing.T) {
|
||||
m := newMigManager(t, "")
|
||||
sysNS := appbackup.NamespaceRoot(m.cfg.Paths.SystemDataPath, false)
|
||||
importRoot := appbackup.ImportDir(sysNS)
|
||||
|
||||
// App migrating OFF the system drive: source namespace IS the system namespace.
|
||||
offSystem := m.appDataSkipSet(&MigrationJob{SourceNS: sysNS, Apps: []string{"paperless-ngx"}})
|
||||
if !offSystem[filepath.Clean(importRoot)] {
|
||||
t.Errorf("import root %q must be pruned from a migration off the system drive; skip set = %v",
|
||||
importRoot, offSystem)
|
||||
}
|
||||
|
||||
// App migrating OFF a data drive: no import root there, nothing extra to prune.
|
||||
dataNS := "/mnt/felhom-drives/hdd_1"
|
||||
offData := m.appDataSkipSet(&MigrationJob{SourceNS: dataNS, Apps: []string{"paperless-ngx"}})
|
||||
if offData[filepath.Clean(importRoot)] {
|
||||
t.Error("a data-drive migration must not carry a system-drive skip entry")
|
||||
}
|
||||
}
|
||||
|
||||
// pathUnder must be segment-wise: a sibling sharing a name prefix is NOT contained.
|
||||
func TestPathUnderIsSegmentWise(t *testing.T) {
|
||||
root := filepath.Clean("/mnt/sys_drive")
|
||||
if !pathUnder(root, root) {
|
||||
t.Error("a path must be under itself")
|
||||
}
|
||||
if !pathUnder(filepath.Join(root, "felhom-data", "userdata"), root) {
|
||||
t.Error("a descendant must be under the root")
|
||||
}
|
||||
if pathUnder(filepath.Clean("/mnt/sys_drive-evil/x"), root) {
|
||||
t.Error("a name-prefix sibling must NOT be under the root")
|
||||
}
|
||||
}
|
||||
@@ -150,10 +150,12 @@ func (m *Manager) ensureFileBrowser(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
// Initial render: no storage mounts yet (web.SyncFileBrowserMounts fills them in on the first
|
||||
// storage-path change and owns all later regeneration).
|
||||
// Initial render: no storage mounts yet, and no import source either (web.SyncFileBrowserMounts
|
||||
// fills both in on the first storage-path change and owns all later regeneration). Rendering the
|
||||
// import source here without its bind would produce a source with no path behind it — a broken
|
||||
// sidebar entry — so both halves are deliberately deferred to the same place.
|
||||
compose := infra.RenderFileBrowserCompose(m.cfg.Customer.Domain, nil)
|
||||
config := infra.RenderFileBrowserConfig(nil)
|
||||
config := infra.RenderFileBrowserConfig(nil, false)
|
||||
if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil {
|
||||
return fmt.Errorf("write docker-compose.yml: %w", err)
|
||||
}
|
||||
|
||||
@@ -179,9 +179,63 @@ func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureUserdataMounts is the deploy belt: pre-create every ${USERDATA_PATH}/... bind source the
|
||||
// stack declares with the userdata convention, so Docker never auto-creates one as guest-root.
|
||||
// GetImportRoot returns the CANONICAL drop-zone root (R-75): <system namespace root>/userdata/import.
|
||||
//
|
||||
// It is resolved from the SYSTEM drive, never from the app's HDD_PATH, so every app's drop-zone lands
|
||||
// in one place regardless of which drive the app was deployed to. The system drive holds a felhom-data
|
||||
// SUBDIR (it is not itself the namespace root — that is the inGuestDrive=false case), which is why
|
||||
// NamespaceRoot is applied rather than using the configured path directly.
|
||||
//
|
||||
// Returns "" when the system data path is unconfigured. Callers must NOT substitute a per-drive
|
||||
// fallback: that would put a folder that looks like a drop-zone on every drive while only one works.
|
||||
// withPathVars leaves IMPORT_PATH unset instead, so compose fails loudly on ${IMPORT_PATH}.
|
||||
//
|
||||
// NOTE: the system drive is deliberately NOT a registered StoragePath (verified on both demo boxes,
|
||||
// 2026-07-26), so this root is invisible to the storage UI, to buildFileBrowserPaths' per-path loop
|
||||
// and to sharingResolvePath's owning-root check. Everything that must reach it does so explicitly —
|
||||
// see EnsureImportRoot, the FileBrowser import bind, and the System SMB share.
|
||||
func (m *Manager) GetImportRoot() string {
|
||||
sys := m.cfg.Paths.SystemDataPath
|
||||
if sys == "" {
|
||||
m.logger.Printf("[ERROR] [stacks] IMPORT_PATH unresolvable: paths.system_data_path is empty — a drop-zone bind will fail to resolve rather than silently land on a data drive")
|
||||
return ""
|
||||
}
|
||||
return appbackup.ImportDir(appbackup.NamespaceRoot(sys, false))
|
||||
}
|
||||
|
||||
// ensureUserdataMounts is the deploy belt: pre-create every ${USERDATA_PATH}/... and ${IMPORT_PATH}/...
|
||||
// bind source the stack declares with the userdata convention, so Docker never auto-creates one as
|
||||
// guest-root.
|
||||
//
|
||||
// The two roots are gated DIFFERENTLY and that is load-bearing. ${USERDATA_PATH} is on the app's own
|
||||
// data drive and is subject to the drive-absent gate; ${IMPORT_PATH} (R-75) is on the SYSTEM drive,
|
||||
// which is always present, so gating it on a detached data drive would refuse to create a directory
|
||||
// that has nothing to do with that drive.
|
||||
func (m *Manager) ensureUserdataMounts(stackDir string, env []string) {
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
binds := ParseComposeClassifiableBinds(composePath)
|
||||
|
||||
// --- import binds: system drive, never drive-gated ---
|
||||
if importPath := envLookup(env, "IMPORT_PATH"); importPath != "" {
|
||||
for _, b := range binds {
|
||||
if b.Root != appbackup.RootImport {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(importPath, filepath.FromSlash(b.RelPath))
|
||||
if err := appbackup.EnsureUserdataDir(src); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] import belt: ensure %s: %v", src, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, b := range binds {
|
||||
if b.Root == appbackup.RootImport {
|
||||
m.logger.Printf("[ERROR] [stacks] import belt: stack declares a ${IMPORT_PATH} bind but IMPORT_PATH is unset — compose will fail rather than bind a wrong-drive path")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- userdata binds: the app's own drive, drive-absent gated ---
|
||||
userdataPath := envLookup(env, "USERDATA_PATH")
|
||||
if userdataPath == "" {
|
||||
return
|
||||
@@ -195,8 +249,11 @@ func (m *Manager) ensureUserdataMounts(stackDir string, env []string) {
|
||||
m.logger.Printf("[INFO] [stacks] userdata belt: drive %s not mounted — skipping ensure (held by drive gate)", hdd)
|
||||
return
|
||||
}
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
for _, src := range ParseComposeUserdataMounts(composePath, userdataPath) {
|
||||
for _, b := range binds {
|
||||
if b.Root != appbackup.RootUserdata {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(userdataPath, filepath.FromSlash(b.RelPath))
|
||||
if err := appbackup.EnsureUserdataDir(src); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] userdata belt: ensure %s: %v", src, err)
|
||||
}
|
||||
@@ -1071,7 +1128,8 @@ func (m *Manager) stackEnv(stackDir string) []string {
|
||||
// Inject USERDATA_PATH = <namespace root>/userdata alongside HDD_PATH (v0.66.0). HDD_PATH IS
|
||||
// the namespace root (the chosen StoragePath: a Model-A user drive's mount, or the SSD's
|
||||
// felhom-data dir), so the catalog's ${USERDATA_PATH}/... mounts resolve under userdata/.
|
||||
env = withUserdataPath(env, appCfg.Env["HDD_PATH"])
|
||||
// IMPORT_PATH (R-75) rides along but is derived from the SYSTEM drive, never from HDD_PATH.
|
||||
env = withPathVars(env, appCfg.Env["HDD_PATH"], m.GetImportRoot())
|
||||
}
|
||||
|
||||
// App-email relay env (appended LAST so it wins over any app.yaml default). Returns nil unless
|
||||
|
||||
@@ -51,6 +51,11 @@ type Metadata struct {
|
||||
// block (sets this back to nil + logs one ERROR) on any validation defect, so a bad catalog push
|
||||
// degrades to legacy loudly rather than partially classifying. Consumed by Task 3/4 — INERT today.
|
||||
Backup *appbackup.BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"`
|
||||
// DataPaths is the customer-facing folder ANNOTATION (R-75): role + Hungarian label over paths
|
||||
// that must ALREADY exist as compose binds. It never declares a path. Validated in LoadMetadata
|
||||
// with the Fork-3 asymmetry — a malformed PATH rejects the whole block, an unknown ROLE drops
|
||||
// just that entry (see ValidateDataPaths).
|
||||
DataPaths []DataPath `yaml:"data_paths,omitempty" json:"data_paths,omitempty"`
|
||||
}
|
||||
|
||||
// SMTPMapping renames the generic relay settings (host / port / security / from / from-name)
|
||||
@@ -328,15 +333,32 @@ func LoadMetadata(stackDir string) Metadata {
|
||||
// block exists) the WHOLE block is rejected — meta.Backup = nil, one ERROR — so the app degrades
|
||||
// to legacy (today's behavior) rather than partially classifying. INERT: nothing consumes
|
||||
// meta.Backup yet (Task 3/4).
|
||||
if meta.Backup != nil {
|
||||
if meta.Backup != nil || len(meta.DataPaths) > 0 {
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
binds := ParseComposeClassifiableBinds(composePath)
|
||||
if _, err := os.Stat(composePath); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, err)
|
||||
meta.Backup = nil
|
||||
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
|
||||
meta.Backup = nil
|
||||
_, composeErr := os.Stat(composePath)
|
||||
if meta.Backup != nil {
|
||||
if composeErr != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
|
||||
meta.Backup = nil
|
||||
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
|
||||
meta.Backup = nil
|
||||
}
|
||||
}
|
||||
// data_paths (R-75) rides the same choke point, with its own asymmetric rules: a malformed
|
||||
// path drops the WHOLE block (nothing from it can be trusted), an unknown role drops only
|
||||
// that entry (presentation, not data handling).
|
||||
if len(meta.DataPaths) > 0 {
|
||||
if composeErr != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml data_paths rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
|
||||
meta.DataPaths = nil
|
||||
} else if kept, err := ValidateDataPaths(meta.DataPaths, binds, dirName, log.Default()); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml data_paths rejected in %s: %v", stackDir, err)
|
||||
meta.DataPaths = nil
|
||||
} else {
|
||||
meta.DataPaths = kept
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -652,7 +652,8 @@ func (m *Manager) migCleanupAllowed(j *MigrationJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// appDataSkipSet returns the source appdata dirs (rsync'd separately) to skip in the merge walk.
|
||||
// appDataSkipSet returns the source dirs to prune from the merge walk: the app appdata dirs (rsync'd
|
||||
// separately) plus the canonical import root (R-75, never migrates).
|
||||
func (m *Manager) appDataSkipSet(j *MigrationJob) map[string]bool {
|
||||
skip := map[string]bool{}
|
||||
for _, app := range j.Apps {
|
||||
@@ -660,9 +661,25 @@ func (m *Manager) appDataSkipSet(j *MigrationJob) map[string]bool {
|
||||
skip[filepath.Clean(appbackup.AppDataDir(j.SourceNS, name))] = true
|
||||
}
|
||||
}
|
||||
// R-75: the CANONICAL drop-zone lives ONCE, on the system drive, and must never move with an app.
|
||||
// This fires when an app is migrated OFF the system drive: the merge walk would otherwise see
|
||||
// <sysNS>/userdata/import in the source namespace and copy the whole box's drop-zone onto the
|
||||
// destination data drive — creating exactly the second, non-functional drop-zone this arc exists
|
||||
// to remove (and, import being class: excluded, an unbacked one). Migrations off a DATA drive are
|
||||
// unaffected: a data drive has no import root to match.
|
||||
if root := m.GetImportRoot(); root != "" && pathUnder(root, j.SourceNS) {
|
||||
skip[filepath.Clean(root)] = true
|
||||
}
|
||||
return skip
|
||||
}
|
||||
|
||||
// pathUnder reports whether p is root or lives beneath it. Segment-wise, so a sibling directory
|
||||
// sharing a name prefix can never match.
|
||||
func pathUnder(p, root string) bool {
|
||||
cp, cr := filepath.Clean(p), filepath.Clean(root)
|
||||
return cp == cr || strings.HasPrefix(cp, cr+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// RecoverMigration resumes a crashed migration on startup (no-op if none or terminal).
|
||||
func (m *Manager) RecoverMigration(ctx context.Context) {
|
||||
j, err := m.loadJournal()
|
||||
|
||||
@@ -55,8 +55,8 @@ func TestSambaClassifiedBinds_TierMembership(t *testing.T) {
|
||||
}
|
||||
|
||||
// Tier membership through the real helper.
|
||||
offsite := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, storageRoot)
|
||||
secondary := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, storageRoot)
|
||||
offsite := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, storageRoot, "")
|
||||
secondary := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, storageRoot, "")
|
||||
|
||||
if !hasRel(offsite, "shares/dokumentumok") {
|
||||
t.Errorf("mandatory share must be in the OFFSITE set: %+v", offsite.Paths)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
// Catalog-derived userdata skeleton (R-75).
|
||||
//
|
||||
// Before this, the customer-facing tree was a hardcoded Go list, so a new catalog app with a folder
|
||||
// needed a controller release. The set is now DERIVED from the catalog the controller has already
|
||||
// synced to disk — using the same authoritative parser the classifier uses — merged with the
|
||||
// carry-list (appbackup.UserdataSkeletonCarry) so it can only ever ADD.
|
||||
//
|
||||
// Scope note: this derives ${USERDATA_PATH} binds only. ${IMPORT_PATH} binds are NOT part of a
|
||||
// drive's skeleton — the canonical drop-zone lives once, on the system drive, and is ensured by
|
||||
// EnsureImportRoot instead.
|
||||
|
||||
// DeriveUserdataDirs returns the ${USERDATA_PATH}-relative dirs implied by every template in
|
||||
// stacksDir. ALL catalog apps count, not just deployed ones (Fork-2 ruling): the skeleton is
|
||||
// storage-path-scoped and idempotent, and the all-apps set is barely larger than the historical
|
||||
// hardcoded one (SPIKE P0(a): 14 vs 14, differing by one entry each way). Deployed-only filtering
|
||||
// belongs in the UI, where an empty folder would actually confuse someone.
|
||||
//
|
||||
// Unreadable dirs / missing composes are skipped silently — a partially-synced catalog must degrade
|
||||
// to "fewer derived dirs", never to an error that blocks the skeleton (the carry-list is the floor).
|
||||
// The result is sorted; BuildUserdataSkeleton sorts again after merging, so both layers are pinned.
|
||||
func DeriveUserdataDirs(stacksDir string) []string {
|
||||
entries, err := os.ReadDir(stacksDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
composePath := filepath.Join(stacksDir, e.Name(), "docker-compose.yml")
|
||||
for _, b := range ParseComposeClassifiableBinds(composePath) {
|
||||
if b.Root != appbackup.RootUserdata || b.RelPath == "" {
|
||||
continue
|
||||
}
|
||||
set[b.RelPath] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for d := range set {
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// UserdataSkeletonDirs is the merged, sorted set to create on a storage path: catalog-derived plus
|
||||
// the carry-list. This is THE function every skeleton caller should use.
|
||||
func (m *Manager) UserdataSkeletonDirs() []string {
|
||||
return appbackup.BuildUserdataSkeleton(DeriveUserdataDirs(m.cfg.Paths.StacksDir))
|
||||
}
|
||||
|
||||
// EnsureUserdataSkeleton applies the merged skeleton to a storage path's namespace root. Signature
|
||||
// kept as func(string) error so it drops straight into fbPathDeps.ensureSkeleton.
|
||||
func (m *Manager) EnsureUserdataSkeleton(nsRoot string) error {
|
||||
return appbackup.EnsureUserdataSkeleton(nsRoot, m.UserdataSkeletonDirs())
|
||||
}
|
||||
|
||||
// EnsureImportRoot creates the CANONICAL drop-zone root on the system drive with the userdata
|
||||
// convention, so it exists (and is browsable + shareable) even before any drop-zone app is deployed.
|
||||
// Idempotent; a no-op when the import root is unresolvable.
|
||||
//
|
||||
// It deliberately does NOT pre-create per-app subfolders: those are the deploy belt's job, so an app
|
||||
// the customer never installed does not litter the drop-zone with an empty folder.
|
||||
func (m *Manager) EnsureImportRoot() error {
|
||||
root := m.GetImportRoot()
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
return appbackup.EnsureUserdataDir(root)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package stacks
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
@@ -72,11 +74,12 @@ func TestEnsureUserdataMounts_CreatesBeltDirs(t *testing.T) {
|
||||
_ = appbackup.SharedContentGID // keep import referenced cross-platform
|
||||
}
|
||||
|
||||
// TestWithUserdataPath: the shared injector adds USERDATA_PATH=<hdd>/userdata when HDD_PATH is set, and
|
||||
// TestWithPathVars: the shared injector adds USERDATA_PATH=<hdd>/userdata when HDD_PATH is set, and
|
||||
// adds nothing when it's empty. Regression for the initial-deploy bug where ${USERDATA_PATH} resolved
|
||||
// to "" and bound a bogus root-owned dir at the container root.
|
||||
func TestWithUserdataPath(t *testing.T) {
|
||||
got := withUserdataPath([]string{"DOMAIN=x"}, "/mnt/felhom-usb")
|
||||
func TestWithPathVars(t *testing.T) {
|
||||
const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
|
||||
got := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-usb", importRoot)
|
||||
want := "USERDATA_PATH=" + appbackup.UserdataDir("/mnt/felhom-usb")
|
||||
found := false
|
||||
for _, e := range got {
|
||||
@@ -88,9 +91,39 @@ func TestWithUserdataPath(t *testing.T) {
|
||||
t.Errorf("USERDATA_PATH not injected: got %v, want %q", got, want)
|
||||
}
|
||||
// companion: empty HDD_PATH → no USERDATA_PATH at all
|
||||
for _, e := range withUserdataPath([]string{"DOMAIN=x"}, "") {
|
||||
if len(e) >= 13 && e[:13] == "USERDATA_PATH" {
|
||||
for _, e := range withPathVars([]string{"DOMAIN=x"}, "", importRoot) {
|
||||
if strings.HasPrefix(e, "USERDATA_PATH") {
|
||||
t.Errorf("USERDATA_PATH must NOT be set when HDD_PATH is empty: %q", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithPathVars_ImportPath pins the R-75 half. IMPORT_PATH has the SAME failure mode
|
||||
// USERDATA_PATH had: a site that forgets it resolves ${IMPORT_PATH} to "" and binds a bogus
|
||||
// root-owned dir at the container root. And the unresolvable case must leave the variable UNSET —
|
||||
// never fall back to a per-drive path, which would recreate the dead-drop-zone shape R-75 removes.
|
||||
func TestWithPathVars_ImportPath(t *testing.T) {
|
||||
const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
|
||||
|
||||
got := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/hdd_1", importRoot)
|
||||
if !slices.Contains(got, "IMPORT_PATH="+importRoot) {
|
||||
t.Errorf("IMPORT_PATH not injected: got %v", got)
|
||||
}
|
||||
// It is CANONICAL: it must not be derived from HDD_PATH. A second app on a different drive gets
|
||||
// the identical value — that is the whole point of the canonical root.
|
||||
other := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/nvme-1tb", importRoot)
|
||||
if !slices.Contains(other, "IMPORT_PATH="+importRoot) {
|
||||
t.Errorf("IMPORT_PATH must not vary with HDD_PATH: got %v", other)
|
||||
}
|
||||
for _, e := range got {
|
||||
if strings.HasPrefix(e, "IMPORT_PATH=") && strings.Contains(e, "felhom-drives") {
|
||||
t.Errorf("IMPORT_PATH must never point at a data drive: %q", e)
|
||||
}
|
||||
}
|
||||
// Unresolvable → UNSET (compose then fails loudly on ${IMPORT_PATH}).
|
||||
for _, e := range withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/hdd_1", "") {
|
||||
if strings.HasPrefix(e, "IMPORT_PATH") {
|
||||
t.Errorf("IMPORT_PATH must NOT be set when the import root is unresolvable: %q", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func (p *blockProvider) GetStackComposePath(string) (string, bool) { return "",
|
||||
func (p *blockProvider) ListDeployedStacks() []backup.StackSummary { return nil }
|
||||
func (p *blockProvider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *blockProvider) GetStackHDDPath(string) string { return p.hdd }
|
||||
func (p *blockProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *blockProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *blockProvider) StopStack(string) error {
|
||||
atomic.AddInt32(&p.stops, 1)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
// „Hova tegyem a fájlokat?" — the app-page folder card (R-75).
|
||||
//
|
||||
// Rendered only for DEPLOYED apps that declare data_paths (Fork-2: all catalog apps get the
|
||||
// skeleton, but only deployed apps get a UI affordance — an empty folder for an app nobody installed
|
||||
// is the thing that would actually confuse someone).
|
||||
|
||||
// DataPathCard is one folder row on the app page.
|
||||
type DataPathCard struct {
|
||||
Label string // the catalog's Hungarian label
|
||||
Link string // FileBrowser deep link
|
||||
Consequence string // class-DRIVEN copy (never hand-written per app)
|
||||
IsImport bool // drives the free-space line
|
||||
FreeSpace string // system-drive headroom, import rows only ("" when unreadable)
|
||||
}
|
||||
|
||||
// consequenceFor maps a folder's DERIVED BACKUP CLASS to the sentence the customer reads (Fork-4).
|
||||
//
|
||||
// It is driven by the class, not by the role and not by a per-app string, so the promise the UI makes
|
||||
// can never drift from what the backup engines actually do. `excluded` means the tier filter drops it
|
||||
// at EVERY tier — so a drop-zone must say, in the customer's own language, that the folder is
|
||||
// temporary and unbacked. Saying anything softer would be a false promise about their files.
|
||||
func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole) string {
|
||||
switch class {
|
||||
case appbackup.ClassExcluded:
|
||||
if role == stacks.RoleImport {
|
||||
return "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — " +
|
||||
"ez a mappa átmeneti, és nem készül róla biztonsági mentés."
|
||||
}
|
||||
return "Ez a mappa átmeneti, és nem készül róla biztonsági mentés."
|
||||
case appbackup.ClassMandatory, appbackup.ClassOptional:
|
||||
return "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla."
|
||||
default:
|
||||
// No classification (legacy app, or a bind with no backup block). Say nothing rather than
|
||||
// guess — an unverified backup promise is worse than no sentence at all.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// buildDataPathCards turns an app's validated data_paths into rendered rows.
|
||||
//
|
||||
// classOf resolves a (root, relpath) to its backup class; missing ⇒ empty class ⇒ no consequence
|
||||
// line. A row whose deep link cannot be built (no domain) is dropped rather than rendered dead.
|
||||
func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard {
|
||||
if st == nil || !st.Deployed || len(st.Meta.DataPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
domain := s.cfg.Customer.Domain
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
classOf := map[string]appbackup.BindClass{}
|
||||
if binds, has := s.stackMgr.ClassifiedBinds(st.Name); has {
|
||||
for _, b := range binds {
|
||||
classOf[string(b.Root)+"\x00"+b.RelPath] = b.Class
|
||||
}
|
||||
}
|
||||
|
||||
var freeSpace string
|
||||
if s.stackMgr != nil {
|
||||
if root := s.stackMgr.GetImportRoot(); root != "" {
|
||||
if du := system.GetDiskUsage(root); du != nil {
|
||||
freeSpace = fmt.Sprintf("%.1f GB szabad", du.AvailGB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cards := make([]DataPathCard, 0, len(st.Meta.DataPaths))
|
||||
for _, dp := range st.Meta.DataPaths {
|
||||
var link string
|
||||
switch dp.Root {
|
||||
case appbackup.RootImport:
|
||||
link = importFolderLink(domain, dp.Path)
|
||||
case appbackup.RootUserdata:
|
||||
// A userdata folder lives on the app's OWN drive, so its FileBrowser source is that
|
||||
// drive's sidebar entry. Resolve it from the app's HDD_PATH; skip the row if we cannot.
|
||||
src := s.fbSourceNameForApp(st.Name)
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
link = fileBrowserLink(domain, src, dp.Path)
|
||||
default:
|
||||
// hdd: app-internal (appdata/) — NOT customer-browsable, FileBrowser does not mount it.
|
||||
// Surfacing a link here would 404. Skipped deliberately; the catalog should not annotate
|
||||
// an hdd path with a customer-facing role.
|
||||
continue
|
||||
}
|
||||
isImport := dp.Role == stacks.RoleImport
|
||||
card := DataPathCard{
|
||||
Label: dp.Label,
|
||||
Link: link,
|
||||
Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role),
|
||||
IsImport: isImport,
|
||||
}
|
||||
if isImport {
|
||||
// The system SSD filling is a different severity from a data drive filling — it can take
|
||||
// the whole guest down — and the customer has no other signal that the drop-zone is not
|
||||
// bottomless.
|
||||
card.FreeSpace = freeSpace
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
// fbSourceNameForApp returns the FileBrowser sidebar source name for the drive an app is deployed
|
||||
// on: the storage path's label when it has one, else the mount basename — exactly what
|
||||
// RenderFileBrowserConfig emits, so the deep link and the sidebar can never disagree.
|
||||
func (s *Server) fbSourceNameForApp(stackName string) string {
|
||||
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
|
||||
if appCfg == nil {
|
||||
return ""
|
||||
}
|
||||
hdd := appCfg.Env["HDD_PATH"]
|
||||
if hdd == "" {
|
||||
return ""
|
||||
}
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path != hdd || sp.Decommissioned {
|
||||
continue
|
||||
}
|
||||
if sp.Label != "" {
|
||||
return sp.Label
|
||||
}
|
||||
return filepath.Base(sp.Path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// appInfoWithCards overlays the R-75 folder card onto the shared app_info fixture. It reuses
|
||||
// appInfoData (lifecycle_test.go) deliberately — that helper passes a *Metadata for a reason
|
||||
// documented there (value vs pointer receivers at render time); do not fork it.
|
||||
func appInfoWithCards(st stacks.Stack, cards []DataPathCard) map[string]interface{} {
|
||||
d := appInfoData(st)
|
||||
if len(cards) > 0 {
|
||||
d["DataPathCards"] = cards
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// SEAM-WIRING RULE: a conditional affordance ships with a render test PER BRANCH of its gate.
|
||||
// Branch 1 — cards present: the block renders, with the label, the deep link and the copy.
|
||||
func TestAppInfo_DataPathCardRenders(t *testing.T) {
|
||||
st := stacks.Stack{Name: "paperless-ngx", Deployed: true, State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{Slug: "paperless-ngx", DisplayName: "Paperless-ngx"}}
|
||||
cards := []DataPathCard{{
|
||||
Label: "Beolvasandó dokumentumok",
|
||||
Link: importFolderLink("demo-felhom.eu", "paperless"),
|
||||
Consequence: consequenceFor(appbackup.ClassExcluded, stacks.RoleImport),
|
||||
IsImport: true,
|
||||
FreeSpace: "44.8 GB szabad",
|
||||
}}
|
||||
html := renderBackupPage(t, "app_info", appInfoWithCards(st, cards))
|
||||
|
||||
if !strings.Contains(html, "Hova tegyem a fájlokat?") {
|
||||
t.Error("the folder card heading must render")
|
||||
}
|
||||
if !strings.Contains(html, "Beolvasandó dokumentumok") {
|
||||
t.Error("the catalog label must render")
|
||||
}
|
||||
// The deep link must reach the page INTACT — this is the assertion the brief asks for, and it is
|
||||
// what would have caught the v0.150.0 class of bug (a key rendered where another belonged).
|
||||
if !strings.Contains(html, "https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless") {
|
||||
t.Errorf("the FileBrowser deep link must render intact; body:\n%s", excerpt(html, "datapath"))
|
||||
}
|
||||
if !strings.Contains(html, "nem készül róla biztonsági mentés") {
|
||||
t.Error("an excluded drop-zone must say it is unbacked")
|
||||
}
|
||||
if !strings.Contains(html, "44.8 GB szabad") {
|
||||
t.Error("the system-drive free space must render on an import row")
|
||||
}
|
||||
// The copy must not promise a single click — a cold deep link goes through the FileBrowser login.
|
||||
if !strings.Contains(html, "be kell jelentkezned") {
|
||||
t.Error("the card must warn that a FileBrowser sign-in may be needed")
|
||||
}
|
||||
}
|
||||
|
||||
// Branch 2 — no cards: nothing renders, and in particular no empty heading.
|
||||
func TestAppInfo_NoDataPathCardsRendersNothing(t *testing.T) {
|
||||
st := stacks.Stack{Name: "docmost", Deployed: true, State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{Slug: "docmost", DisplayName: "Docmost"}}
|
||||
html := renderBackupPage(t, "app_info", appInfoWithCards(st, nil))
|
||||
if strings.Contains(html, "Hova tegyem a fájlokat?") {
|
||||
t.Error("an app with no data_paths must not render the folder card")
|
||||
}
|
||||
if strings.Contains(html, "datapath-row") {
|
||||
t.Error("no folder rows must render")
|
||||
}
|
||||
}
|
||||
|
||||
// Fork-4: the consequence line is CLASS-driven, so the UI can never promise a backup the engines do
|
||||
// not make. `excluded` is dropped at every tier — it must say so.
|
||||
func TestConsequenceIsClassDriven(t *testing.T) {
|
||||
imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport)
|
||||
if !strings.Contains(imp, "törli innen") || !strings.Contains(imp, "nem készül róla biztonsági mentés") {
|
||||
t.Errorf("an excluded import folder must say it is temporary AND unbacked: %q", imp)
|
||||
}
|
||||
for _, cls := range []appbackup.BindClass{appbackup.ClassMandatory, appbackup.ClassOptional} {
|
||||
lib := consequenceFor(cls, stacks.RoleLibrary)
|
||||
if !strings.Contains(lib, "Biztonsági mentés készül") {
|
||||
t.Errorf("class %q must promise a backup: %q", cls, lib)
|
||||
}
|
||||
if strings.Contains(lib, "nem készül") {
|
||||
t.Errorf("class %q must NOT say unbacked: %q", cls, lib)
|
||||
}
|
||||
}
|
||||
// An unclassified bind says NOTHING rather than guessing — an unverified backup promise about a
|
||||
// customer's files is worse than no sentence.
|
||||
if got := consequenceFor("", stacks.RoleLibrary); got != "" {
|
||||
t.Errorf("an unclassified path must produce no promise, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A card is only built for a DEPLOYED app (Fork-2: deployed-only in the UI).
|
||||
func TestBuildDataPathCards_UndeployedYieldsNothing(t *testing.T) {
|
||||
s := testServer(t)
|
||||
st := &stacks.Stack{Name: "paperless-ngx", Deployed: false,
|
||||
Meta: stacks.Metadata{Slug: "paperless-ngx", DataPaths: []stacks.DataPath{
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: stacks.RoleImport, Label: "x"},
|
||||
}}}
|
||||
if got := s.buildDataPathCards(st); got != nil {
|
||||
t.Errorf("an undeployed app must get no folder card, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func excerpt(html, needle string) string {
|
||||
i := strings.Index(html, needle)
|
||||
if i < 0 {
|
||||
return "(needle not found)"
|
||||
}
|
||||
end := i + 400
|
||||
if end > len(html) {
|
||||
end = len(html)
|
||||
}
|
||||
return html[i:end]
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func (p *fabWebProvider) GetStackHDDMounts(string) []string {
|
||||
return []string{appbackup.UserdataDir(p.hddPath)}
|
||||
}
|
||||
func (p *fabWebProvider) GetStackHDDPath(string) string { return p.hddPath }
|
||||
func (p *fabWebProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
||||
func (p *fabWebProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||
return p.binds, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
)
|
||||
|
||||
// FileBrowser Quantum deep links (R-75).
|
||||
//
|
||||
// The template is taken VERBATIM from the shipped frontend router (SPIKE P2, read out of the
|
||||
// bundle), not guessed:
|
||||
//
|
||||
// function Ms(n,t,…){ … let a=q2(t), s=`/files/${encodeURIComponent(n)}${a}` … }
|
||||
// q2 = … .map(r=>encodeURIComponent(r)).join("/") // per-SEGMENT encoding
|
||||
// function H2(n){ t=i.split("/")[2]; … } // source is the 3rd path segment
|
||||
//
|
||||
// So a link is constructible server-side from (sourceName, relPath) alone — no internal id, no
|
||||
// index, no client-side state.
|
||||
//
|
||||
// A cold link (no FileBrowser session) is NOT lost: the router guard redirects to
|
||||
// /login?redirect=<fullPath> and the login handler navigates back to it. The customer may still have
|
||||
// to sign in, which is why the UI copy must not promise one click.
|
||||
|
||||
// fbSourceRoot is the FileBrowser origin for a customer domain. FileBrowser is published at
|
||||
// files.<domain> by the base-infra traefik labels.
|
||||
func fbSourceRoot(domain string) string {
|
||||
return "https://files." + domain
|
||||
}
|
||||
|
||||
// fileBrowserLink builds a deep link into a named FileBrowser source at relPath.
|
||||
//
|
||||
// ENCODING TRAP, measured in SPIKE P2 — use url.PathEscape, NEVER url.QueryEscape:
|
||||
//
|
||||
// "Média & könyvtár" PathEscape=M%C3%A9dia%20&%20k%C3%B6nyvt%C3%A1r QueryEscape=M%C3%A9dia+%26+k%C3%B6nyvt%C3%A1r
|
||||
// "a+b" PathEscape=a+b QueryEscape=a%2Bb
|
||||
//
|
||||
// QueryEscape encodes a space as "+", which inside a PATH segment means a literal plus and breaks
|
||||
// the link. PathEscape leaves "&" unescaped, which is correct here: "&" is a legal path sub-delim,
|
||||
// and html/template escapes it to "&" in the href attribute, which the browser decodes back to
|
||||
// "&". The two escapings compose — so do NOT pre-escape for HTML here.
|
||||
//
|
||||
// Source names cannot contain "/" (they come from filepath.Base or the ASCII import constant), which
|
||||
// is what keeps the router's split("/")[2] round-trip intact.
|
||||
func fileBrowserLink(domain, sourceName, relPath string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fbSourceRoot(domain))
|
||||
b.WriteString("/files/")
|
||||
b.WriteString(url.PathEscape(sourceName))
|
||||
for _, seg := range strings.Split(strings.Trim(relPath, "/"), "/") {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString("/")
|
||||
b.WriteString(url.PathEscape(seg))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// importFolderLink is the deep link to a drop-zone app's folder inside the canonical import source.
|
||||
func importFolderLink(domain, appDir string) string {
|
||||
return fileBrowserLink(domain, infra.FileBrowserImportLabel, appDir)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
)
|
||||
|
||||
// The deep-link template, pinned against the shipped FileBrowser Quantum router (SPIKE P2).
|
||||
func TestFileBrowserLink_Template(t *testing.T) {
|
||||
const dom = "demo-felhom.eu"
|
||||
for _, tc := range []struct{ name, source, rel, want string }{
|
||||
{"ascii source, one segment", "hdd_1", "media/movies",
|
||||
"https://files.demo-felhom.eu/files/hdd_1/media/movies"},
|
||||
{"accented source (the import label)", infra.FileBrowserImportLabel, "paperless",
|
||||
"https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless"},
|
||||
{"source root, empty relpath", infra.FileBrowserImportLabel, "",
|
||||
"https://files.demo-felhom.eu/files/Beolvas%C3%A1s"},
|
||||
{"leading/trailing slashes are ignored", "hdd_1", "/media/books/",
|
||||
"https://files.demo-felhom.eu/files/hdd_1/media/books"},
|
||||
{"accented path segment", "hdd_1", "media/könyvek",
|
||||
"https://files.demo-felhom.eu/files/hdd_1/media/k%C3%B6nyvek"},
|
||||
} {
|
||||
if got := fileBrowserLink(dom, tc.source, tc.rel); got != tc.want {
|
||||
t.Errorf("%s:\n got %q\n want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE trap, measured in SPIKE P2. QueryEscape turns a space into "+", which in a path segment is a
|
||||
// literal plus and lands the customer on a folder that does not exist. This test fails if anyone
|
||||
// swaps the escaper.
|
||||
func TestFileBrowserLink_UsesPathEscapeNotQueryEscape(t *testing.T) {
|
||||
const spaced = "Média & könyvtár"
|
||||
got := fileBrowserLink("x.eu", spaced, "a b")
|
||||
|
||||
if strings.Contains(got, "+") {
|
||||
t.Errorf("link contains '+' — QueryEscape was used somewhere; a '+' in a path segment is a literal plus, not a space: %q", got)
|
||||
}
|
||||
if want := url.PathEscape(spaced); !strings.Contains(got, want) {
|
||||
t.Errorf("source not PathEscape'd: got %q, want it to contain %q", got, want)
|
||||
}
|
||||
// Guard the exact divergence the spike measured, so the two escapers can never be confused here.
|
||||
if url.PathEscape(spaced) == url.QueryEscape(spaced) {
|
||||
t.Fatal("fixture no longer distinguishes the two escapers — pick a name where they differ")
|
||||
}
|
||||
if strings.Contains(got, url.QueryEscape(spaced)) {
|
||||
t.Errorf("link used QueryEscape: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The link is embedded in HTML. Percent-encoding and attribute-escaping must COMPOSE: PathEscape
|
||||
// leaves "&" bare, html/template turns it into "&", and the browser decodes it back to "&".
|
||||
// Anything that double-encodes (a hand-rolled HTML escape before the template) breaks the path.
|
||||
func TestFileBrowserLink_ComposesWithHTMLEscaping(t *testing.T) {
|
||||
link := fileBrowserLink("x.eu", "Média & könyvtár", "docs")
|
||||
var sb strings.Builder
|
||||
tmpl := template.Must(template.New("a").Parse(`<a href="{{.}}">x</a>`))
|
||||
if err := tmpl.Execute(&sb, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := sb.String()
|
||||
if !strings.Contains(out, "&") {
|
||||
t.Errorf("html/template should escape the bare & in the href: %q", out)
|
||||
}
|
||||
// It must NOT have been percent-encoded a second time (%2526 etc. would be double-encoding).
|
||||
if strings.Contains(out, "%25") {
|
||||
t.Errorf("double percent-encoding detected — do not pre-escape before the template: %q", out)
|
||||
}
|
||||
// And no raw quote/angle escaped into the attribute.
|
||||
if strings.Contains(out, `href=""`) {
|
||||
t.Errorf("html/template refused the URL (would render an empty href): %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// importFolderLink targets the CANONICAL source, so a drop-zone link is identical regardless of
|
||||
// which drive the app itself sits on.
|
||||
func TestImportFolderLink_IsCanonical(t *testing.T) {
|
||||
a := importFolderLink("demo-felhom.eu", "paperless")
|
||||
b := importFolderLink("demo-felhom.eu", "calibre")
|
||||
for _, l := range []string{a, b} {
|
||||
if !strings.Contains(l, "/files/"+url.PathEscape(infra.FileBrowserImportLabel)+"/") {
|
||||
t.Errorf("import link must go through the canonical source: %q", l)
|
||||
}
|
||||
if strings.Contains(l, "hdd_1") || strings.Contains(l, "felhom-drives") {
|
||||
t.Errorf("import link must never name a data drive: %q", l)
|
||||
}
|
||||
}
|
||||
if a == b {
|
||||
t.Error("different apps must get different folders")
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func TestFileBrowserNetworkShareIncluded(t *testing.T) {
|
||||
t.Errorf("skeleton calls = %v, want exactly [%s] — a skeleton toward the NAS writes Felhom convention dirs onto the customer's own NAS", calls, drive.Path)
|
||||
}
|
||||
// Config has both sources, share named by its display label.
|
||||
cfg := infra.RenderFileBrowserConfig(cfgPaths)
|
||||
cfg := infra.RenderFileBrowserConfig(cfgPaths, false)
|
||||
for _, m := range []string{`- path: "/srv/hdd_1"`, `- path: "/srv/Felhom-Share"`, `name: "Felhom Share"`} {
|
||||
if !strings.Contains(cfg, m) {
|
||||
t.Errorf("config missing %q:\n%s", m, cfg)
|
||||
@@ -104,7 +104,7 @@ func TestFileBrowserNetworkStubExcluded(t *testing.T) {
|
||||
if len(mounts) != 1 || strings.Contains(mounts[0], "Felhom-Share") {
|
||||
t.Errorf("stub share leaked into mounts: %v", mounts)
|
||||
}
|
||||
cfg := infra.RenderFileBrowserConfig(cfgPaths)
|
||||
cfg := infra.RenderFileBrowserConfig(cfgPaths, false)
|
||||
if strings.Contains(cfg, "Felhom-Share") {
|
||||
t.Errorf("stub share leaked into the source list:\n%s", cfg)
|
||||
}
|
||||
@@ -150,8 +150,8 @@ func TestFileBrowserNetworkRemoval(t *testing.T) {
|
||||
|
||||
oldCompose := infra.RenderFileBrowserCompose("example.hu", withMounts)
|
||||
newCompose := infra.RenderFileBrowserCompose("example.hu", withoutMounts)
|
||||
oldCfg := infra.RenderFileBrowserConfig(withCfg)
|
||||
newCfg := infra.RenderFileBrowserConfig(withoutCfg)
|
||||
oldCfg := infra.RenderFileBrowserConfig(withCfg, false)
|
||||
newCfg := infra.RenderFileBrowserConfig(withoutCfg, false)
|
||||
|
||||
if strings.Contains(newCompose, "Felhom-Share") || strings.Contains(newCfg, "Felhom-Share") {
|
||||
t.Error("removed share left a trace in the renders")
|
||||
|
||||
@@ -636,6 +636,13 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||
data["EffectiveSubdomain"] = effectiveSubdomain
|
||||
|
||||
// „Hova tegyem a fájlokat?" (R-75) — deployed apps that declare data_paths only. Set EXPLICITLY,
|
||||
// like every other key here: appDetailHandler does not funnel through baseData, and the v0.150.0
|
||||
// app_export.html bug (a CSRF token rendered where the domain belonged) came from assuming it did.
|
||||
if cards := s.buildDataPathCards(found); len(cards) > 0 {
|
||||
data["DataPathCards"] = cards
|
||||
}
|
||||
|
||||
// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
|
||||
// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
|
||||
// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
|
||||
@@ -2217,13 +2224,29 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{
|
||||
isMount: system.IsMountPoint,
|
||||
classify: s.classifyFSPath,
|
||||
ensureSkeleton: appbackup.EnsureUserdataSkeleton,
|
||||
ensureSkeleton: s.ensureUserdataSkeleton,
|
||||
logger: s.logger,
|
||||
})
|
||||
|
||||
// R-75: the canonical drop-zone is an EXTRA bind, outside the registered-storage-path loop above.
|
||||
// The system drive is deliberately not a registered StoragePath (it would become a customer-visible
|
||||
// drive, a deploy target and a wipe candidate), so it is mounted here explicitly. Ensure the root
|
||||
// first — a source whose path does not exist renders a broken sidebar entry.
|
||||
importSource := false
|
||||
if s.stackMgr != nil {
|
||||
if importRoot := s.stackMgr.GetImportRoot(); importRoot != "" {
|
||||
if err := s.stackMgr.EnsureImportRoot(); err != nil {
|
||||
s.logger.Printf("[WARN] [web] FileBrowser: could not ensure the import root %s: %v", importRoot, err)
|
||||
}
|
||||
storageMounts = append(storageMounts,
|
||||
fmt.Sprintf(" - %s:/srv/%s", importRoot, infra.FileBrowserImportMount))
|
||||
importSource = true
|
||||
}
|
||||
}
|
||||
|
||||
// Generate and write config.yaml (sources + sidebar entries per drive/share)
|
||||
configPath := stackDir + "/config.yaml"
|
||||
fbConfig := generateFileBrowserConfig(configPaths)
|
||||
fbConfig := generateFileBrowserConfig(configPaths, importSource)
|
||||
|
||||
// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
|
||||
// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
|
||||
@@ -2382,6 +2405,6 @@ func generateFileBrowserCompose(domain string, storageMounts []string) string {
|
||||
|
||||
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
|
||||
// registered storage path. Delegates to internal/infra (single source of truth).
|
||||
func generateFileBrowserConfig(paths []settings.StoragePath) string {
|
||||
return infra.RenderFileBrowserConfig(paths)
|
||||
func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool) string {
|
||||
return infra.RenderFileBrowserConfig(paths, importSource)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-75 Scenario E — the system share's delete refusal is SERVER-SIDE.
|
||||
//
|
||||
// Two independent checks, tested independently on purpose (the v0.70.1 lesson): a handler test that
|
||||
// POSTs directly proves nothing about UI reachability, and a render gate proves nothing about
|
||||
// enforcement. Both are required; neither substitutes for the other.
|
||||
|
||||
func serverWithImportShare(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
s := testServer(t)
|
||||
// A real Manager so the handler's post-delete ReconcileSamba has a receiver. Sharing is left
|
||||
// DISABLED, so reconcileSambaAt early-returns and no docker call is made.
|
||||
s.cfg.Paths.SystemDataPath = "/mnt/sys_drive"
|
||||
s.cfg.Paths.StacksDir = t.TempDir()
|
||||
mgr, err := stacks.NewManager(s.cfg, s.logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mgr.SetMigrationDeps(s.settings, func() bool { return false })
|
||||
s.stackMgr = mgr
|
||||
if err := s.settings.AddSMBShare(settings.SMBShare{
|
||||
Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import",
|
||||
System: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.settings.AddSMBShare(settings.SMBShare{
|
||||
Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Scenario E, enforcement half: POST the delete endpoint directly. The share must survive.
|
||||
func TestScenarioE_SystemShareDeleteRefusedServerSide(t *testing.T) {
|
||||
s := serverWithImportShare(t)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/sharing/shares/delete",
|
||||
strings.NewReader(url.Values{"name": {settings.SystemImportShareName}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
s.sharingShareDeleteHandler(rr, req)
|
||||
|
||||
found := false
|
||||
for _, sh := range s.settings.GetSMBShares() {
|
||||
if strings.EqualFold(sh.Name, settings.SystemImportShareName) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the system share was DELETED by a direct POST — the refusal is not server-side")
|
||||
}
|
||||
|
||||
// And a non-system share is still deletable, so this is a targeted refusal and not a broken
|
||||
// endpoint that happens to refuse everything.
|
||||
rr2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("POST", "/sharing/shares/delete",
|
||||
strings.NewReader(url.Values{"name": {"csalad"}}.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
s.sharingShareDeleteHandler(rr2, req2)
|
||||
for _, sh := range s.settings.GetSMBShares() {
|
||||
if sh.Name == "csalad" {
|
||||
t.Error("an ordinary share must still be deletable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The store layer refuses too, so no future caller can bypass the handler.
|
||||
func TestScenarioE_StoreLayerRefusesSystemShare(t *testing.T) {
|
||||
s := serverWithImportShare(t)
|
||||
if err := s.settings.RemoveSMBShare(settings.SystemImportShareName); err == nil {
|
||||
t.Error("RemoveSMBShare must refuse a System share")
|
||||
}
|
||||
if err := s.settings.RemoveSMBShare("csalad"); err != nil {
|
||||
t.Errorf("RemoveSMBShare must still delete an ordinary share: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E, reachability half: the template must not render a delete button for a system share —
|
||||
// and must still render one for an ordinary share.
|
||||
func TestScenarioE_SharingTemplateOmitsSystemDeleteButton(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "sharing", "Title": "Hálózati megosztás",
|
||||
"SMBEnabled": true,
|
||||
// The SAME type the handler passes — see ShareRow's comment.
|
||||
"SMBShares": []ShareRow{
|
||||
{Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import", System: true, Available: true},
|
||||
{Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad", Available: true},
|
||||
},
|
||||
"CSRFField": "",
|
||||
}
|
||||
html := renderBackupPage(t, "sharing", data)
|
||||
|
||||
rows := strings.Split(html, "<tr>")
|
||||
var sysRow, normalRow string
|
||||
for _, row := range rows {
|
||||
if strings.Contains(row, settings.SystemImportShareName) {
|
||||
sysRow = row
|
||||
}
|
||||
if strings.Contains(row, "csalad") {
|
||||
normalRow = row
|
||||
}
|
||||
}
|
||||
if sysRow == "" || normalRow == "" {
|
||||
t.Fatalf("both share rows must render; sys=%v normal=%v", sysRow != "", normalRow != "")
|
||||
}
|
||||
if strings.Contains(sysRow, "/sharing/shares/delete") {
|
||||
t.Error("the system share row must NOT carry a delete form")
|
||||
}
|
||||
if !strings.Contains(normalRow, "/sharing/shares/delete") {
|
||||
t.Error("an ordinary share row must still carry its delete form")
|
||||
}
|
||||
}
|
||||
|
||||
// R-75 Scenario F — sharing stays OPT-IN. Deploying a drop-zone app must not put SMB on the LAN.
|
||||
func TestScenarioF_SharingStaysOptIn(t *testing.T) {
|
||||
s := testServer(t)
|
||||
if s.settings.GetSMBSettings().Enabled {
|
||||
t.Fatal("precondition: sharing must start disabled")
|
||||
}
|
||||
// The auto-create is wired to the ENABLE handler only; nothing in the deploy path calls it.
|
||||
// Assert the state a fresh box is in: no shares at all.
|
||||
if got := s.settings.GetSMBShares(); len(got) != 0 {
|
||||
t.Errorf("a fresh box must have no shares before sharing is enabled, got %v", got)
|
||||
}
|
||||
if s.settings.GetSMBSettings().Enabled {
|
||||
t.Error("sharing must not have been switched on")
|
||||
}
|
||||
}
|
||||
|
||||
// ensureImportShare is idempotent and correctly shaped.
|
||||
func TestEnsureImportShare_IdempotentAndCorrect(t *testing.T) {
|
||||
s := testServer(t)
|
||||
// A writable stand-in for /mnt/sys_drive so EnsureImportRoot really creates the dir (the test
|
||||
// user is not root, so the real path is not writable).
|
||||
s.cfg.Paths.SystemDataPath = t.TempDir()
|
||||
s.cfg.Paths.StacksDir = t.TempDir()
|
||||
mgr, err := stacks.NewManager(s.cfg, s.logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.stackMgr = mgr
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.ensureImportShare(); err != nil {
|
||||
t.Fatalf("call %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
shares := s.settings.GetSMBShares()
|
||||
if len(shares) != 1 {
|
||||
t.Fatalf("expected exactly 1 share after 3 calls (idempotent), got %d: %v", len(shares), shares)
|
||||
}
|
||||
sh := shares[0]
|
||||
if sh.Name != settings.SystemImportShareName {
|
||||
t.Errorf("share name = %q, want %q", sh.Name, settings.SystemImportShareName)
|
||||
}
|
||||
if !sh.System {
|
||||
t.Error("the import share must be marked System")
|
||||
}
|
||||
if sh.Offsite {
|
||||
t.Error("the drop-zone is class `excluded` — Offsite must be false, or the UI would contradict the backup engines")
|
||||
}
|
||||
if sh.ReadOnly {
|
||||
t.Error("a drop-zone the customer copies INTO must be writable")
|
||||
}
|
||||
if want := mgr.GetImportRoot(); sh.Path != want {
|
||||
t.Errorf("share path = %q, want the canonical import root %q", sh.Path, want)
|
||||
}
|
||||
// The name must be NetBIOS-safe — it is an SMB share name.
|
||||
if err := settings.ValidateSMBShareName(sh.Name); err != nil {
|
||||
t.Errorf("share name is not NetBIOS-safe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The brief's re-assertion: SPIKE P4 proved <root>/userdata/import is shareable against a GENERIC
|
||||
// registered root. This pins the SYSTEM-root shape specifically, because ProtectedHDDPaths has a
|
||||
// legacy felhom-data double-nest branch that only fires there.
|
||||
//
|
||||
// It documents the actual live shape, which is why ensureImportShare does not route through the
|
||||
// picker guard: the system drive is NOT a registered storage path on either demo box (verified
|
||||
// 2026-07-26), so sharingResolvePath — whose job is to validate CUSTOMER-supplied paths — refuses it.
|
||||
// A controller-generated constant is a different trust class.
|
||||
func TestImportRoot_NotReachableViaTheCustomerPicker(t *testing.T) {
|
||||
s := testServer(t)
|
||||
root := t.TempDir() // stands in for the system drive; deliberately NOT registered
|
||||
importRoot := filepath.Join(root, "felhom-data", "userdata", "import")
|
||||
if err := os.MkdirAll(importRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A registered data drive exists, so this is not "the registry is empty" trivially refusing.
|
||||
dataDrive := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dataDrive, "userdata", "import"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.settings.AddStoragePath(settings.StoragePath{Path: dataDrive, Label: "hdd_1", IsDefault: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := s.sharingResolvePath(importRoot); err == nil {
|
||||
t.Error("the customer picker must NOT accept the unregistered system-drive import root")
|
||||
}
|
||||
// Control: the data drive's own userdata subtree IS pickable, so the guard is not refusing all.
|
||||
if _, err := s.sharingResolvePath(filepath.Join(dataDrive, "userdata", "import")); err != nil {
|
||||
t.Errorf("a registered drive's userdata/import must stay shareable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,19 @@ func (s *Server) sharingResolveStorageRoot(raw string) (string, error) {
|
||||
return "", errNotShareable
|
||||
}
|
||||
|
||||
// ShareRow is one row of the shares table. It is a PACKAGE-LEVEL type, not a function-local struct,
|
||||
// so the render test constructs the exact shape the handler passes: this template reads .System and
|
||||
// .Available, and a field present in one and missing from the other is a render-time 500 that no
|
||||
// handler test would catch (the template-gate class this project has hit four times).
|
||||
type ShareRow struct {
|
||||
Name string
|
||||
Path string
|
||||
ReadOnly bool
|
||||
Offsite bool
|
||||
System bool // controller-owned (R-75): no delete button, and the handler refuses it anyway
|
||||
Available bool
|
||||
}
|
||||
|
||||
// sharingPageData assembles the „Megosztás" page state.
|
||||
func (s *Server) sharingPageData() map[string]interface{} {
|
||||
data := s.settingsBaseData("sharing", "Hálózati megosztás")
|
||||
@@ -134,18 +147,12 @@ func (s *Server) sharingPageData() map[string]interface{} {
|
||||
data["SMBDirectAddress"] = s.sambaLANAddress()
|
||||
}
|
||||
|
||||
type shareRow struct {
|
||||
Name string
|
||||
Path string
|
||||
ReadOnly bool
|
||||
Offsite bool
|
||||
Available bool
|
||||
}
|
||||
var rows []shareRow
|
||||
var rows []ShareRow
|
||||
for _, sh := range s.settings.GetSMBShares() {
|
||||
fi, err := os.Stat(sh.Path)
|
||||
rows = append(rows, shareRow{
|
||||
rows = append(rows, ShareRow{
|
||||
Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly, Offsite: sh.Offsite,
|
||||
System: sh.System,
|
||||
Available: err == nil && fi.IsDir(),
|
||||
})
|
||||
}
|
||||
@@ -272,6 +279,14 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
|
||||
return
|
||||
}
|
||||
// R-75: the canonical drop-zone share exists whenever sharing is ON — and NEVER before. Enabling
|
||||
// sharing is the customer's decision (it puts SMB on the household LAN and demands a household
|
||||
// password); deploying a drop-zone app must not trigger it. "Mandatory" here means "always present
|
||||
// once sharing is on", not "turns sharing on".
|
||||
if err := s.ensureImportShare(); err != nil {
|
||||
s.logger.Printf("[WARN] [sharing] could not ensure the import share: %v", err)
|
||||
}
|
||||
|
||||
// v0.147.0 (4b): the bring-up runs DETACHED and the page polls it. Synchronously it was a form
|
||||
// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
|
||||
// regardless of whether anything actually came up.
|
||||
@@ -411,6 +426,15 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
|
||||
func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
// SERVER-SIDE refusal for controller-owned shares (R-75), BEFORE any mutation. The template also
|
||||
// omits the button; both are required and they prove different things — a render gate is not
|
||||
// enforcement, and a handler check is not reachability (the v0.70.1 ghost-delete lesson).
|
||||
for _, sh := range s.settings.GetSMBShares() {
|
||||
if strings.EqualFold(sh.Name, name) && sh.System {
|
||||
sharingRedirect(w, r, "Ez a megosztás a rendszer része, nem törölhető.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.settings.RemoveSMBShare(name); err != nil {
|
||||
sharingRedirect(w, r, err.Error())
|
||||
return
|
||||
@@ -524,3 +548,40 @@ func writeSharingJSON(w http.ResponseWriter, code int, v interface{}) {
|
||||
fmt.Fprintf(w, `{"error":"encode"}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureImportShare creates the controller-owned drop-zone share (R-75) if it is not already there.
|
||||
// Idempotent, and a no-op when the import root is unresolvable.
|
||||
//
|
||||
// It writes to the store DIRECTLY rather than going through sharingResolvePath: that guard validates
|
||||
// paths a CUSTOMER supplied through the picker, and refuses anything outside a registered storage
|
||||
// root. The system drive is deliberately not registered (registering it would make a 50 GB volume
|
||||
// holding the recovery units a customer-visible drive, a deploy target and a wipe candidate), so the
|
||||
// guard would refuse this path — correctly, for customer input. A controller-generated constant is a
|
||||
// different trust class.
|
||||
//
|
||||
// Offsite is FALSE: the drop-zone is class `excluded` data, and shipping an inbox offsite would
|
||||
// contradict the class the backup engines already act on.
|
||||
func (s *Server) ensureImportShare() error {
|
||||
if s.stackMgr == nil {
|
||||
return nil
|
||||
}
|
||||
root := s.stackMgr.GetImportRoot()
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
for _, sh := range s.settings.GetSMBShares() {
|
||||
if strings.EqualFold(sh.Name, settings.SystemImportShareName) {
|
||||
return nil // already present
|
||||
}
|
||||
}
|
||||
if err := s.stackMgr.EnsureImportRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.settings.AddSMBShare(settings.SMBShare{
|
||||
Name: settings.SystemImportShareName,
|
||||
Path: root,
|
||||
ReadOnly: false,
|
||||
Offsite: false,
|
||||
System: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error
|
||||
// v0.66.0: create the full userdata skeleton with the shared-storage convention (2775 setgid,
|
||||
// gid 1000) the moment a drive is registered — system drive AND additional drives. Idempotent;
|
||||
// best-effort (a perms hiccup shouldn't block registration).
|
||||
if err := appbackup.EnsureUserdataSkeleton(where); err != nil {
|
||||
if err := s.ensureUserdataSkeleton(where); err != nil {
|
||||
s.logger.Printf("[WARN] [web] userdata skeleton on %s: %v", where, err)
|
||||
}
|
||||
// Change 4: re-enrolling a previously-DECOMMISSIONED drive must un-retire it. AddStoragePath
|
||||
@@ -890,3 +890,19 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", res)
|
||||
}
|
||||
|
||||
// ensureUserdataSkeleton applies the CATALOG-DERIVED userdata skeleton (R-75) to a storage path.
|
||||
//
|
||||
// It replaces the direct appbackup.EnsureUserdataSkeleton call, which used a hardcoded Go list, so a
|
||||
// new catalog app with a folder no longer needs a controller release. The derived set is merged with
|
||||
// appbackup.UserdataSkeletonCarry (which is the old hardcoded list verbatim), so the result can only
|
||||
// ever ADD — no directory this arc touches is ever removed.
|
||||
//
|
||||
// Falls back to the carry-list alone when the stack manager is not wired (setup mode / tests), which
|
||||
// is exactly the pre-R-75 behaviour.
|
||||
func (s *Server) ensureUserdataSkeleton(nsRoot string) error {
|
||||
if s.stackMgr == nil {
|
||||
return appbackup.EnsureUserdataSkeleton(nsRoot, appbackup.BuildUserdataSkeleton(nil))
|
||||
}
|
||||
return s.stackMgr.EnsureUserdataSkeleton(nsRoot)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,24 @@
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
|
||||
{{if .DataPathCards}}
|
||||
<div class="app-info-card" style="margin-top:1rem">
|
||||
<h3>Hova tegyem a fájlokat?</h3>
|
||||
<p class="form-hint">Ezeket a mappákat a Fájlkezelőben éred el. Előfordulhat, hogy először be kell jelentkezned a Fájlkezelőbe.</p>
|
||||
<div class="datapath-list">
|
||||
{{range .DataPathCards}}
|
||||
<div class="datapath-row">
|
||||
<div class="datapath-head">
|
||||
<a href="{{.Link}}" target="_blank" rel="noopener" class="datapath-link">{{.Label}} ↗</a>
|
||||
{{if .FreeSpace}}<span class="datapath-space">{{.FreeSpace}}</span>{{end}}
|
||||
</div>
|
||||
{{if .Consequence}}<p class="datapath-note">{{.Consequence}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if and .Stack.Deployed .MigrateTargets}}
|
||||
<div class="app-info-card" style="margin-top:1rem">
|
||||
<h3>Áthelyezés másik tárhelyre</h3>
|
||||
|
||||
@@ -146,11 +146,15 @@
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{{if .System}}
|
||||
<span class="form-hint">rendszer</span>
|
||||
{{else}}
|
||||
<form method="POST" action="/sharing/shares/delete">
|
||||
{{$.CSRFField}}
|
||||
<input type="hidden" name="name" value="{{.Name}}">
|
||||
<button type="submit" class="btn btn-xs btn-danger-outline">Törlés</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -1317,6 +1317,44 @@ a.stat-card:hover {
|
||||
font-size: .95rem;
|
||||
color: var(--text-1);
|
||||
}
|
||||
/* „Hova tegyem a fájlokat?" — R-75 folder rows on the app page. */
|
||||
.datapath-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
}
|
||||
.datapath-row {
|
||||
border-left: 3px solid var(--blue);
|
||||
background: var(--blue-dim);
|
||||
padding: .6rem .75rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.datapath-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: .75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.datapath-link {
|
||||
color: var(--blue-bright);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.datapath-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.datapath-space {
|
||||
color: var(--text-2);
|
||||
font-size: .8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.datapath-note {
|
||||
margin: .35rem 0 0 0;
|
||||
color: var(--text-2);
|
||||
font-size: .85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.app-info-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
|
||||
Reference in New Issue
Block a user