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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user