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
+10 -8
View File
@@ -12,6 +12,7 @@ import (
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
@@ -1456,18 +1457,19 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
return
}
// Build volume mount lines. SCOPE to the drive's `appdata/` subtree only (Phase 4A): the customer
// browses their userdata, but the recovery units + Tier 2 copies under `backups/` are NOT mounted
// into FileBrowser at all — so the thing that restores them can't be browsed or (even read-only)
// surfaced. mkdir the appdata dir first so the bind source exists with sane ownership.
// Build volume mount lines. SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer
// browses ONLY userdata — app internals (appdata/) and the recovery units + Tier 2 copies
// (backups/) are NOT mounted into FileBrowser. userdata is owned group 1000 mode 2775 (setgid),
// and FileBrowser runs as uid 1000 → it can create folders + upload files (the old appdata mount
// was guest-root 0755 → permission-denied). Pre-create the full skeleton with the convention.
var storageMounts []string
for _, sp := range paths {
mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"
appdataSrc := filepath.Join(sp.Path, "appdata")
if err := os.MkdirAll(appdataSrc, 0755); err != nil {
s.logger.Printf("[WARN] [web] FileBrowser: could not ensure appdata dir %s: %v", appdataSrc, err)
if err := appbackup.EnsureUserdataSkeleton(sp.Path); err != nil {
s.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err)
}
line := fmt.Sprintf(" - %s:/srv/%s", appdataSrc, mountName)
userdataSrc := appbackup.UserdataDir(sp.Path)
line := fmt.Sprintf(" - %s:/srv/%s", userdataSrc, mountName)
storageMounts = append(storageMounts, line)
}
@@ -219,6 +219,12 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error
if strings.TrimSpace(label) == "" {
label = settings.InferStorageLabel(where)
}
// 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 {
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
// dedups a re-register into a no-op, so without this the soft marker would persist forever and the
// apps' "missing storage" indicator would never clear.