From d0718e8d0e4f0dd24e8c73625db25f45e2f1dce3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 14 Jun 2026 22:31:30 +0200 Subject: [PATCH] docs: README userdata convention + REPORT for v0.66.x (userdata layout) Co-Authored-By: Claude Opus 4.8 (1M context) --- REPORT.md | 137 ++++++++++++++++++++++++------------------- controller/README.md | 25 ++++++++ 2 files changed, 103 insertions(+), 59 deletions(-) diff --git a/REPORT.md b/REPORT.md index d7499f9..559ff55 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,69 +1,88 @@ -# REPORT — TASK B1+B2 data migration + self-serve decommission → controller v0.65.0 (2026-06-14) +# REPORT — userdata layout + shared-storage ownership convention → controller v0.66.1 + catalog (2026-06-14) -Customer-self-serve storage **migration** (move app data between drives) and **decommission** (retire a -drive), implemented trunk-based on `main` against the locked spike design -(`felhom.eu/documentation/audits/SPIKE-decommission-migration-2026-06-14.md`). Pairs with **agent -v0.32.0**. Each change carries non-hollow tests; the load-bearing companions were mutation-proven to -fail on the pre-fix code. Built + deployed both images to demo guest 9201 / felhom-pve. +Customer-facing `userdata/` tree with a shared-ownership convention so FileBrowser + content apps +collaborate without permission collisions. Spike: +`felhom.eu/documentation/audits/SPIKE-userdata-layout-2026-06-14.md`. Trunk-based on `main` in both the +controller and the app-catalog. No back-compat (existing demo apps moved to the new layout — no real +data). Built + deployed + live-verified on demo guest 9201. -> Validate against pushed source, not this report. **Live decommission/migration of real data is NOT -> yet validated — that is the supervised B3 session (see the end).** +> Validate against pushed source at file:line, not this report. -## Confirmed baselines -- Controller `main` was `c37ee6d` = **v0.64.0** → shipped **v0.65.0**. -- Agent `main` was `8e6d00a` = **v0.31.0** → shipped **v0.32.0**. +## Confirmed baseline +- Controller `main` was `cbaa53f` = **v0.65.0** → shipped **v0.66.0** then **v0.66.1** (first-deploy fix). +- Catalog `main` was `5ec2b2c` (commits, no semver). -## Commits (controller repo unless noted) -| # | Commit | Scope | +## Commit hashes +| Repo | Commit | Scope | |---|---|---| -| 1 | `b57150e` | B1 migration engine (`internal/stacks/migrate.go`, MigrateAll+MigrateApp) + tests + main wiring + backup mutual-exclusion (Change 3) | -| 2 | `16a4c3e` | B1 UI — `/api/storage/migrate{,-app,/status}` + settings.html & app_info.html + progress panel | -| 3 | `f43697c` (felhom-agent) | v0.32.0 — `POST /disks/decommission` + intent-aware `ReassertGuestBinds` + `GuestBindStore.Remove` + tests | -| 4 | `f2596ea` | B2b decommission orchestration + "Hiányzó tárhely" indicator + Change 4 re-enroll fix + tests | +| app-catalog | `69611ce` | repoint 12 composes off `${HDD_PATH}/media` → `${USERDATA_PATH}/...` + .felhom.yml | +| app-catalog | `0d60a5c` | komga + audiobookshelf revert to root (user:1000 fallback) | +| app-catalog | `c99070c` | komga + audiobookshelf `no-new-privileges:true` (root hardening) | +| controller | `c48f95f` | v0.66.0 — userdata helper, USERDATA_PATH inject, skeleton, deploy belt, FileBrowser switch, migrate setgid fix + tests | +| controller | `3b7d089` | v0.66.1 — fix USERDATA_PATH on first deploy (shared `withUserdataPath`) + regression test | ## What shipped -- **B1 engine** — crash-safe, resumable, journaled (`/migration.json`). Pipeline validate → stop - → copy (`rsync -a --checksum`, additive, **no `--delete`**; conflict-merge walk for non-app content) → - verify (`rsync -ani --checksum`, zero pending) → flip+redeploy (`RedeployFromEnv`) → **cleanup gated on - all-verified AND all-redeployed**. Two entry points (whole-namespace + per-app incl. SSD→drive) over one - pipeline. Single-flight; mutually exclusive with the backup orchestrator (migration refuses while a - backup runs; scheduled DB-dump/Tier-2 skip while a migration runs). -- **B1 UI** — migrate-all (settings page) + per-app "Áthelyezés másik tárhelyre" (app-info page) + shared - status poll/progress panel. -- **B2a (agent)** — self-serve `POST /disks/decommission` mirroring eject (user-data role gate, no - signature, never formats): intent=decommissioned + bind-prune + unmount. **`ReassertGuestBinds` is now - intent-aware** (skips non-enrolled) — the F9-reconnect correctness fix. Operator-signed path untouched. -- **B2b** — `handleStorageDecommission` (migrate-all-or-none): migrate-then-decommission via the migration - done-hook, or decommission-anyway (stop apps, keep HDD_PATH). `agentapi.Decommission` added. Soft-marker - retained. **Change 4:** re-enroll clears the marker (`ClearDecommissioned` had zero callers before). - **"Hiányzó tárhely"** badge for apps on a missing drive. +- **Catalog**: `${USERDATA_PATH}` introduced; media/ingest mounts repointed per app (jellyfin/emby/plex + `media:/media:ro`; navidrome `media/music:ro`; audiobookshelf `media/{audiobooks,podcasts}`; komga + `media/comics`; calibre `media/books` + `import/calibre`, `UMASK=002`; radarr `media/movies`+`downloads`, + sonarr `media/tv`+`downloads`, both `UMASK=002`; romm library→`userdata/roms`; immich +external + `media/photos:/external/photos:ro`; paperless consume→`import/paperless`). nextcloud unchanged. +- **Controller** (`internal/appbackup/userdata.go`): `EnsureUserdataDir`/`EnsureDirOwned` (MkdirAll → + explicit `Chmod(ModeSetgid|0775)` → chown gid 1000), `UserdataSkeleton`, `EnsureUserdataSkeleton`; + Linux `chownGID`/`StatGID` + non-Linux stubs. `withUserdataPath` injects `USERDATA_PATH=/userdata` + in BOTH env builders. Skeleton pre-create on register + FB sync; deploy belt in `composeExecCustomEnv` + (`up`). FileBrowser mounts `userdata` (uid 1000). `migrate.go` preserves setgid+group (#8). -## Tests (per-commit green: `go build && go vet && go test ./...`) -- `internal/stacks/migrate_test.go` — siblingName, conflict-merge (differ→`(1)`, identical→dedup, re-run - idempotent no `(1)(1)`, 3rd→`(2)`), assert-only verify, skip-dirs, cleanup-only-after-redeploy - (+companion: redeploy-fail → source intact), verify-catches-corruption, resume (no re-copy), - collision-refused, backup-exclusion, single-flight, SSD→drive resolution, cleanup gate. -- `internal/backup/migration_exclusion_test.go` — scheduled DB-dump **skipped** while migrating. -- `internal/web/decommission_test.go` — finalize soft-marks + calls agent (entry retained), re-enroll - clears marker (Change 4), missing-storage label, unflagged-migration no-op. -- **Mutation-proven companions** (fail on pre-fix, pass after revert): migration collision guard, cleanup - gate, Change-4 re-enroll clearing, and (agent) the intent-aware reassert (`disks.go` guard removed → - decommissioned drive re-bound → test FAILS). +## komga / audiobookshelf — user:1000 outcome: FELL BACK TO ROOT +Tried `user: "1000:1000"` on both (catalog `0d60a5c`). **Both crash-looped** — their named config/metadata +volumes are Docker-created root-owned and the pinned-1000 process can't write them (live: komga SQLite +`/config` open failure, 14 restarts; audiobookshelf `EACCES mkdir '/metadata/logs'`; neither image has a +PUID-style root-init chown). **Reverted to root** (the spec's accepted fallback) + added +`no-new-privileges:true` (catalog `c99070c`) to restore an escalation boundary (full `cap_drop` NOT +applied — the root-init needs CHOWN/SETUID/SETGID). Both then deploy **healthy as root** (audiobookshelf +healthy; komga serving — see healthcheck note). Consequence: root+setgid → their output is **group- +readable** (FileBrowser browses/reads), not group-writable; the app manages its own files. PUID-1000 apps +(radarr/sonarr/calibre + UMASK 002) give **full group-write**. -## Build / deploy / verify -- Built+pushed `gitea.dooplex.hu/admin/felhom-controller:0.65.0` (192.168.0.180); deployed to guest 9201 - via golden/bootstrap → `docker ps`: `:0.65.0 Up (healthy)`, clean startup (`controller_started (0.65.0)`, - no errors), `GET /api/storage/migrate/status` → `{"job":null}` (idle, wired). -- Built agent v0.32.0 on 180; installed on felhom-pve (`/usr/local/bin/felhom-agent`, backup - `.bak-0.31.0`), `systemctl restart felhom-agent` → active, `--version` 0.32.0. -- **Live-proven (incidental, safe):** on agent restart the new intent-aware re-assert logged - `F9 re-assert: skipping non-enrolled drive (intent-gated) vmid=9201 durable_id=uuid:ce9d… intent=ejected` - — the exact drive that lingered in `guest-binds.json` (B0 spike finding) is now correctly NOT re-bound. +## immich / paperless — DEPLOY DEFERRED (guest RAM), design verified +Guest 9201 has **2 GiB RAM total**. immich needs ~4 GiB → cannot deploy. paperless (~1.1 GiB: +app+postgres+redis) does not fit alongside the running apps; the controller's memory gate refuses it +(observed: komga refused at 92 MB free until non-media apps were temporarily stopped). Their compose + +run-identity design is shipped + statically verified (paperless `USERMAP 1000` consumes from +`import/paperless`; immich `media/photos:/external/photos:ro`). **immich external library: MOUNT READY, +REGISTRATION PENDING** — making /external/photos visible is only the mount; surfacing it in Immich +requires registering an External Library at `Administration → External Libraries` (admin UI/API), a +post-deploy step compose cannot do. Flagged in the immich compose + `.felhom.yml` first_steps. Not +overclaimed. -## NOT yet live-validated — awaiting supervised B3 -The real end-to-end UI flow with actual data was deliberately NOT run (per the task STOP): -- put data on a drive → decommission (migrate-to-another OR decommission-anyway) → verify data integrity - end-to-end → drive un-enrolled and does NOT auto-mount/re-bind on reconnect → orphaned apps show - "Hiányzó tárhely" → re-enroll clears it. Operator-present, real UI (no agent/CLI shortcuts, per the A4 - rule). The engine/endpoint logic is unit-proven and the intent-aware reassert is already proven live by - the restart log above; the data-bearing round-trip is what B3 covers. +## Live-verify evidence (guest 9201) — all three fixes PASS +- **(c) convention**: `ls -la`/`stat` of `/mnt/felhom-usb/userdata` and every subdir → **mode 2775 + (setgid) group 1000** throughout (media/{movies,comics,...}, roms, import/{paperless,calibre}, downloads, + documents). Same on the system drive. +- **(a) FileBrowser writes userdata**: `docker exec filebrowser` (uid 1000) `mkdir` + write a file under + `/srv/felhom-usb/documents/...` → `MKDIR_OK`/`WRITE_OK`, new dir inherited gid 1000 via setgid. The + original permission-denied (root-owned `appdata` mount) is gone. +- **(b) shared media across apps**: radarr (uid 1000, UMASK 002) wrote `media/movies/Inception/Inception.mkv` + → dir **2775**, file **664 group 1000**; jellyfin (root, `media:/media:ro`) `cat` it → `MOVIEDATA`; + FileBrowser (uid 1000) listed **and deleted** it → full RW collaboration. komga (root fallback) wrote + `media/comics/Batman` group 1000 → FileBrowser **lists/reads** but cannot delete (the documented + root-fallback group-read tradeoff). +- App health after moving to the new layout: jellyfin, calibre-web, romm, radarr, audiobookshelf all + **Up healthy**; komga serving (HTTP up, tasks running, writes/reads OK) but its Docker healthcheck + (`/api/v1/actuator/health`) reports **unhealthy** — a pre-existing healthcheck-probe nuance in the komga + template, NOT a userdata/permission issue (no OOM, 0 restarts, logs clean). Flagged for a separate + healthcheck fix. + +## Tests (per-commit green; authoritative Linux gate on 192.168.0.180) +`internal/appbackup`: SharedContentGID==1000, skeleton list+structure, `EnsureDirOwned` setgid+gid +(Linux; in-test companion proves a plain MkdirAll has NO setgid). `internal/stacks`: +`ParseComposeUserdataMounts` selectivity, deploy belt creates declared dirs, `withUserdataPath` +on/off-by-HDD_PATH, **migration preserves setgid+group** (Linux — mutation-proven: reverting to +0755/.Perm() drops setgid → test FAILS). Full `go build && go vet && go test ./...` green. + +## Follow-ups (small, noted not done) +- FileBrowser-created dirs are 2755 (its umask 022) — group-read not group-write for OTHER actors; + consider a FileBrowser `UMASK`/`user:1000:1000`-with-002 so customer-made folders are group-writable. +- komga healthcheck endpoint (`/api/v1/actuator/health`) returns non-200 on komga 1.20 → "unhealthy" + despite serving; fix the healthcheck definition. +- immich/paperless live deploy needs a larger guest (the 2 GiB demo can't host them). diff --git a/controller/README.md b/controller/README.md index 0428d45..0bc14f1 100644 --- a/controller/README.md +++ b/controller/README.md @@ -698,6 +698,31 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an > restores `Schedulable`. A deployed app whose drive is decommissioned/disconnected/absent shows the > **"Hiányzó tárhely"** warning badge on the dashboard/stacks/app card. > +> - **(v0.66.0) Userdata layout + shared-storage ownership convention** (`internal/appbackup/userdata.go`). +> Each drive's felhom-data namespace gains a customer-facing **`userdata/`** tree (sibling of +> `appdata/`/`backups/`) — the ONLY thing FileBrowser mounts. Skeleton: +> `media/{movies,tv,music,audiobooks,books,comics,photos}`, `downloads`, `import/{paperless,calibre}`, +> `roms`, `documents`. **Convention:** every userdata dir is group-owned by `SharedContentGID` (1000), +> mode **2775** (setgid + group-rwx) — `EnsureUserdataDir` = MkdirAll → explicit `Chmod(ModeSetgid|0775)` +> (MkdirAll's mode is umask-masked AND drops setgid) → chown group 1000. Setgid makes new files inherit +> group 1000 so FileBrowser (uid 1000) + the content apps collaborate without permission collisions. +> - **`USERDATA_PATH`** = `/userdata` is injected into the compose env (the shared +> `withUserdataPath`, used by BOTH `stackEnv` and `composeExecWithEnv` — the initial deploy builds env +> from values, not app.yaml). Catalog media mounts use `${USERDATA_PATH}/...`. +> - **Pre-create**: the full skeleton is created with the convention on `registerStoragePath` + +> `syncFileBrowserMounts` (system + additional drives); a **deploy belt** in `composeExecCustomEnv` +> (on `up`) pre-creates every `${USERDATA_PATH}/...` bind source the app declares, so Docker never +> auto-creates a userdata dir as guest-root (covers apps not in the skeleton). +> - **FileBrowser** mounts `/userdata` (was `appdata`) and runs as uid 1000 → it can create +> folders + upload into the 2775 setgid tree (fixes the old permission-denied); app internals +> (`appdata/`) are no longer browsable. +> - **Run-identity**: PUID/PGID-1000 apps (radarr/sonarr/calibre with `UMASK=002`) write **group- +> writable**, so FileBrowser can fully manage their output. Root-only apps (jellyfin :ro; komga + +> audiobookshelf RW after the `user:1000` fallback) write **group-readable** via setgid (FileBrowser +> browses/reads, the app manages its own files). +> - **Migration-safe**: `migrate.go`'s merge walk preserves the source dir's setgid + group (and +> `copyFile` the full file mode + group), so the convention survives a whole-drive `MigrateAll`. +> > The privileged controller-side disk subsections **below are historical** (the `internal/storage/*` scan/ > format code was removed in 8C — execution is the agent's now).