8fadbd9891
Found on the demo-hp live leg: with import, import/paperless and import/calibre in the carry-list, the derived skeleton RE-CREATES a per-drive drop-zone on every drive forever — the dead lookalike the canonical root exists to remove, and one that is never backed up (class: excluded). Not a zero-removals violation: nothing deletes what an existing box has. Both demo boxes' old drop-zones were verified to hold zero files before the change.
156 lines
7.0 KiB
Go
156 lines
7.0 KiB
Go
package appbackup
|
|
|
|
import (
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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.
|
|
//
|
|
// DELIBERATELY ABSENT: `import`, `import/paperless`, `import/calibre`. They were in the v0.171.0
|
|
// hardcoded list, and carrying them would have the skeleton RE-CREATE a per-drive drop-zone on every
|
|
// drive forever — the exact dead-lookalike R-75 exists to remove, and one that is never backed up
|
|
// (`class: excluded`). Zero-removals is about not DELETING what a box already has, not about
|
|
// re-creating it on boxes that never had it: nothing here removes the pre-existing dirs on
|
|
// demo-felhom / demo-hp, they simply stop being maintained and stop appearing on fresh boxes.
|
|
// Verified before the change: both boxes' old drop-zones held ZERO files (2026-07-26). A box with
|
|
// pending files in an old drop-zone would need an operator-run move — see REPORT.md.
|
|
//
|
|
// ASCII, no spaces (flows through ${} interpolation, shell, and the rsync merge walk).
|
|
func UserdataSkeletonCarry() []string {
|
|
return []string{
|
|
"media", "media/movies", "media/tv", "media/music", "media/audiobooks",
|
|
"media/books", "media/comics", "media/photos",
|
|
"downloads",
|
|
"roms",
|
|
"documents",
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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.
|
|
//
|
|
// 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) {
|
|
if e != nil && firstErr == nil {
|
|
firstErr = e
|
|
}
|
|
}
|
|
rec(EnsureUserdataDir(base))
|
|
for _, sub := range dirs {
|
|
rec(EnsureUserdataDir(filepath.Join(base, sub)))
|
|
}
|
|
return firstErr
|
|
}
|