v0.66.0: userdata layout + shared-storage ownership convention

appbackup/userdata.go: EnsureUserdataDir (MkdirAll + explicit setgid Chmod 2775 +
chown gid 1000), UserdataSkeleton, EnsureUserdataSkeleton; linux chown/StatGID +
non-linux stubs. stackEnv injects USERDATA_PATH=<HDD_PATH>/userdata. Skeleton
pre-created on register + FileBrowser sync; deploy belt (composeExecCustomEnv on
'up') pre-creates every ${USERDATA_PATH} bind source. FileBrowser mounts userdata
(was appdata) — uid 1000 can now write into 2775 setgid. #8: migrate merge walk +
copyFile preserve source setgid+group so the convention survives MigrateAll.
Non-hollow tests incl. Linux setgid assertions + migration-preserve companion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 21:58:49 +02:00
parent cbaa53f565
commit c48f95fe06
17 changed files with 523 additions and 34 deletions
@@ -14,8 +14,8 @@ func TestDeriveStackName_KnownCrossRef(t *testing.T) {
note string
}{
{"romm-postgres", "romm", "role suffix of a real stack → strip"},
{"my-cache", "my-cache", "container name IS a real stack → do NOT strip to 'my'"}, // pre-fix: "my"
{"my-cache-postgres", "my-cache", "strip role, result is a known stack"}, // pre-fix: "my-cache" (ok) — but via prefix here
{"my-cache", "my-cache", "container name IS a real stack → do NOT strip to 'my'"}, // pre-fix: "my"
{"my-cache-postgres", "my-cache", "strip role, result is a known stack"}, // pre-fix: "my-cache" (ok) — but via prefix here
{"paperless-ngx-postgres", "paperless-ngx", "multi-hyphen stack, role suffix"},
{"romm_postgres", "romm", "underscore-separated compose name → longest known prefix"},
{"romm-1", "romm", "compose numeric suffix → longest known prefix"},
+76
View File
@@ -0,0 +1,76 @@
package appbackup
import (
"os"
"path/filepath"
)
// Customer-facing userdata layout + the shared-storage ownership convention (v0.66.0).
//
// userdata/ is a sibling of appdata/ and backups/ under a drive's felhom-data namespace. It is the
// ONLY customer-browsable tree (FileBrowser mounts it). Apps that handle customer content write here.
//
// Ownership convention: every userdata dir is group-owned by SharedContentGID, mode 2775 (setgid +
// group-rwx). Setgid makes new files/dirs inherit the shared group regardless of which app (or
// FileBrowser) created them, so members collaborate without permission collisions. FileBrowser
// (uid/gid 1000) and the content apps (PUID/PGID 1000, or pinned user 1000:1000) are all members.
// SharedContentGID is the group that owns the userdata tree.
const SharedContentGID = 1000
// userdataDirMode is the on-disk mode for every userdata dir: setgid + group-rwx. os.ModeSetgid (NOT
// the raw 0o2000) is how Go's Chmod requests S_ISGID. MkdirAll's mode is umask-masked AND drops the
// setgid bit, so an explicit Chmod is mandatory after MkdirAll.
const userdataDirMode = os.ModeSetgid | 0o775
// UserdataDir returns the customer-facing userdata root under a namespace root.
func UserdataDir(nsRoot string) string {
return filepath.Join(nsRoot, "userdata")
}
// UserdataSkeleton is the standard subtree created on every storage path (relative to UserdataDir).
// ASCII, no spaces (flows through ${} interpolation, shell, and the rsync merge walk).
func UserdataSkeleton() []string {
return []string{
"media", "media/movies", "media/tv", "media/music", "media/audiobooks",
"media/books", "media/comics", "media/photos",
"downloads",
"import", "import/paperless", "import/calibre",
"roms",
"documents",
}
}
// 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.
func EnsureDirOwned(path string, gid int) error {
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
if err := os.Chmod(path, userdataDirMode); err != nil {
return err
}
return chownGID(path, gid)
}
// EnsureUserdataDir applies the convention with the shared content group (GID 1000). Idempotent.
func EnsureUserdataDir(path string) error { return EnsureDirOwned(path, SharedContentGID) }
// 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 {
base := UserdataDir(nsRoot)
var firstErr error
rec := func(e error) {
if e != nil && firstErr == nil {
firstErr = e
}
}
rec(EnsureUserdataDir(base))
for _, sub := range UserdataSkeleton() {
rec(EnsureUserdataDir(filepath.Join(base, sub)))
}
return firstErr
}
@@ -0,0 +1,20 @@
//go:build linux
package appbackup
import (
"os"
"syscall"
)
// chownGID sets the GROUP of path (owner unchanged via -1). Setting an arbitrary group requires
// CAP_CHOWN; the in-guest controller runs as root, so this succeeds in production.
func chownGID(path string, gid int) error { return os.Chown(path, -1, gid) }
// StatGID returns the owning GID of fi (Linux). ok=false when the underlying stat is unavailable.
func StatGID(fi os.FileInfo) (int, bool) {
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
return int(st.Gid), true
}
return -1, false
}
@@ -0,0 +1,11 @@
//go:build !linux
package appbackup
import "os"
// chownGID is a no-op off Linux (the dev machine has no POSIX group ownership). Production is Linux.
func chownGID(path string, gid int) error { return nil }
// StatGID is unavailable off Linux.
func StatGID(fi os.FileInfo) (int, bool) { return -1, false }
@@ -0,0 +1,43 @@
//go:build linux
package appbackup
import (
"os"
"path/filepath"
"testing"
)
// TestEnsureDirOwned_Setgid is the load-bearing assertion: EnsureDirOwned produces a dir with the
// SETGID bit + group-rwx (mode 02775) and the requested group. Uses the test's own gid so the chown
// succeeds without root. Companion: a plain MkdirAll(0755) does NOT get setgid — proving the explicit
// Chmod is what sets it (the spike's collision fix). This test FAILS on a pre-fix MkdirAll-only impl.
func TestEnsureDirOwned_Setgid(t *testing.T) {
gid := os.Getgid()
dir := filepath.Join(t.TempDir(), "userdata", "media", "movies")
if err := EnsureDirOwned(dir, gid); err != nil {
t.Fatalf("EnsureDirOwned: %v", err)
}
fi, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if fi.Mode()&os.ModeSetgid == 0 {
t.Errorf("dir is missing the setgid bit: mode=%v", fi.Mode())
}
if perm := fi.Mode().Perm(); perm != 0o775 {
t.Errorf("dir perm = %o, want 0775", perm)
}
if g, ok := StatGID(fi); !ok || g != gid {
t.Errorf("dir gid = %d (ok=%v), want %d", g, ok, gid)
}
// Companion: the pre-fix behaviour (MkdirAll only, no explicit setgid Chmod) → NO setgid.
plain := filepath.Join(t.TempDir(), "plain")
if err := os.MkdirAll(plain, 0o755); err != nil {
t.Fatal(err)
}
if pfi, _ := os.Stat(plain); pfi.Mode()&os.ModeSetgid != 0 {
t.Errorf("plain MkdirAll unexpectedly has setgid — the explicit Chmod is not load-bearing")
}
}
@@ -0,0 +1,52 @@
package appbackup
import (
"os"
"path/filepath"
"testing"
)
// TestSharedContentGID pins the shared content group to 1000 (FileBrowser's gid + the apps' PUID/PGID).
func TestSharedContentGID(t *testing.T) {
if SharedContentGID != 1000 {
t.Errorf("SharedContentGID = %d, want 1000", SharedContentGID)
}
}
// TestUserdataSkeleton_List asserts the locked skeleton subdir set.
func TestUserdataSkeleton_List(t *testing.T) {
got := map[string]bool{}
for _, s := range UserdataSkeleton() {
got[s] = true
}
for _, want := range []string{
"media/movies", "media/tv", "media/music", "media/audiobooks", "media/books",
"media/comics", "media/photos", "downloads", "import/paperless", "import/calibre",
"roms", "documents",
} {
if !got[want] {
t.Errorf("skeleton missing %q", want)
}
}
}
// TestUserdataDir confirms the userdata root is a sibling under the namespace.
func TestUserdataDir(t *testing.T) {
if got := UserdataDir("/mnt/felhom-usb"); got != filepath.Clean("/mnt/felhom-usb/userdata") {
t.Errorf("UserdataDir = %q", got)
}
}
// TestEnsureUserdataSkeleton_Structure: every skeleton dir is created (chown may fail off-root, which
// 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
base := UserdataDir(ns)
for _, sub := range append([]string{""}, UserdataSkeleton()...) {
p := filepath.Join(base, sub)
if fi, err := os.Stat(p); err != nil || !fi.IsDir() {
t.Errorf("skeleton dir missing: %s (%v)", p, err)
}
}
}