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
+51 -4
View File
@@ -62,10 +62,10 @@ func ProtectedHDDPaths(hddPath string) map[string]bool {
return map[string]bool{
// Model A: the in-guest drive mount IS the felhom-data namespace root, so backups/ and
// appdata/ sit directly under it (no felhom-data segment).
hddPath: true,
filepath.Join(hddPath, "appdata"): true,
filepath.Join(hddPath, "backups"): true,
filepath.Join(hddPath, "media"): true,
hddPath: true,
filepath.Join(hddPath, "appdata"): true,
filepath.Join(hddPath, "backups"): true,
filepath.Join(hddPath, "media"): true,
filepath.Join(hddPath, "Dokumentumok"): true,
// Legacy pre-Model-A double-nest location; kept protected so any leftover data there is
// never wiped by a removal.
@@ -505,6 +505,53 @@ func buildPathInfo(path string) HDDPath {
return item
}
// ParseComposeUserdataMounts reads a docker-compose.yml and extracts the host bind-source paths that
// reference ${USERDATA_PATH} (resolved to userdataPath) — the dirs the deploy belt must pre-create
// with the userdata convention. Same scanner shape as ParseComposeHDDMounts.
func ParseComposeUserdataMounts(composePath, userdataPath string) []string {
if userdataPath == "" {
return nil
}
data, err := os.ReadFile(composePath)
if err != nil {
return nil
}
var mounts []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(strings.NewReader(string(data)))
inVolumes := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "volumes:") {
inVolumes = true
continue
}
if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
inVolumes = false
}
if !inVolumes || !strings.HasPrefix(line, "- ") {
continue
}
mountStr := strings.Trim(strings.TrimPrefix(line, "- "), "\"'")
parts := strings.SplitN(mountStr, ":", 3)
if len(parts) < 2 {
continue
}
hostPath := strings.ReplaceAll(parts[0], "${USERDATA_PATH}", userdataPath)
cleanPath := filepath.Clean(hostPath)
cleanUD := filepath.Clean(userdataPath)
// must be userdataPath itself or a subpath (clean before check — traversal-safe)
if cleanPath != cleanUD && !strings.HasPrefix(cleanPath, cleanUD+string(filepath.Separator)) {
continue
}
if !seen[cleanPath] {
seen[cleanPath] = true
mounts = append(mounts, cleanPath)
}
}
return mounts
}
// ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths
// that reference the HDD path from volume bind mounts.
func ParseComposeHDDMounts(composePath, hddPath string) []string {
+1 -1
View File
@@ -919,4 +919,4 @@ func randomAlphanumeric(length int) (string, error) {
result[i] = alphanumChars[n.Int64()]
}
return string(result), nil
}
}
@@ -83,7 +83,7 @@ func TestTransitionalDeployStateReadsNotDeployed(t *testing.T) {
}
// This is exactly the expression ScanStacks uses: deployed := cfg != nil && cfg.Deployed
if got.Deployed {
t.Fatalf("CTRL-T2-1: a deploy that did not complete reads as Deployed=true — ghost-deployed; "+
t.Fatalf("CTRL-T2-1: a deploy that did not complete reads as Deployed=true — ghost-deployed; " +
"DeployStack must persist Deployed:false until compose succeeds")
}
+41
View File
@@ -13,6 +13,7 @@ import (
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
@@ -122,6 +123,33 @@ func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
}, nil
}
// ensureUserdataMounts is the deploy belt: pre-create every ${USERDATA_PATH}/... bind source the
// stack declares with the userdata convention, so Docker never auto-creates one as guest-root.
func (m *Manager) ensureUserdataMounts(stackDir string, env []string) {
userdataPath := envLookup(env, "USERDATA_PATH")
if userdataPath == "" {
return
}
composePath := filepath.Join(stackDir, "docker-compose.yml")
for _, src := range ParseComposeUserdataMounts(composePath, userdataPath) {
if err := appbackup.EnsureUserdataDir(src); err != nil {
m.logger.Printf("[WARN] [stacks] userdata belt: ensure %s: %v", src, err)
}
}
}
// envLookup returns the value of key in a "K=V" env slice (last occurrence wins, "" if absent).
func envLookup(env []string, key string) string {
prefix := key + "="
val := ""
for _, e := range env {
if strings.HasPrefix(e, prefix) {
val = e[len(prefix):]
}
}
return val
}
// SetEncryptionKey sets the AES-256 key used to encrypt/decrypt sensitive values in app.yaml.
func (m *Manager) SetEncryptionKey(key []byte) {
m.mu.Lock()
@@ -829,6 +857,12 @@ func (m *Manager) stackEnv(stackDir string) []string {
for k, v := range appCfg.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// Inject USERDATA_PATH = <namespace root>/userdata alongside HDD_PATH (v0.66.0). HDD_PATH IS
// the namespace root (the chosen StoragePath: a Model-A user drive's mount, or the SSD's
// felhom-data dir), so the catalog's ${USERDATA_PATH}/... mounts resolve under userdata/.
if hdd := appCfg.Env["HDD_PATH"]; hdd != "" {
env = append(env, fmt.Sprintf("USERDATA_PATH=%s", appbackup.UserdataDir(hdd)))
}
}
return env
@@ -857,6 +891,13 @@ func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string)
cmd.Env = env
}
// Deploy belt (v0.66.0): before `up`, pre-create every ${USERDATA_PATH}/... bind source with the
// userdata convention (2775 setgid, gid 1000) so the Docker daemon never auto-creates one as
// guest-root — covers apps not in the skeleton too. Best-effort: a perms hiccup never blocks deploy.
if len(args) > 0 && args[0] == "up" {
m.ensureUserdataMounts(dir, env)
}
// Log env var keys at debug level
if m.isDebug() {
var appKeys []string
+16 -16
View File
@@ -21,7 +21,7 @@ type Metadata struct {
AppInfo AppInfo `yaml:"app_info" json:"app_info"`
OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"`
HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
}
// AppInfo holds detailed app information for the info page.
@@ -52,25 +52,25 @@ type OptionalConfigField struct {
// ResourceHints describe what the app needs.
type ResourceHints struct {
MemRequest string `yaml:"mem_request" json:"mem_request"`
MemLimit string `yaml:"mem_limit" json:"mem_limit"`
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
HungarianUI bool `yaml:"hungarian_ui" json:"hungarian_ui"`
MemRequest string `yaml:"mem_request" json:"mem_request"`
MemLimit string `yaml:"mem_limit" json:"mem_limit"`
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
HungarianUI bool `yaml:"hungarian_ui" json:"hungarian_ui"`
}
// DeployField defines one configuration field shown during first deployment.
type DeployField struct {
EnvVar string `yaml:"env_var" json:"env_var"`
Label string `yaml:"label" json:"label"`
Type string `yaml:"type" json:"type"` // domain, subdomain, secret, password, path, text, select, boolean
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
Default string `yaml:"default" json:"default"`
Required bool `yaml:"required" json:"required"`
Placeholder string `yaml:"placeholder" json:"placeholder"`
Description string `yaml:"description" json:"description"`
EnvVar string `yaml:"env_var" json:"env_var"`
Label string `yaml:"label" json:"label"`
Type string `yaml:"type" json:"type"` // domain, subdomain, secret, password, path, text, select, boolean
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
Default string `yaml:"default" json:"default"`
Required bool `yaml:"required" json:"required"`
Placeholder string `yaml:"placeholder" json:"placeholder"`
Description string `yaml:"description" json:"description"`
LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"`
Options []SelectOption `yaml:"options" json:"options,omitempty"`
Options []SelectOption `yaml:"options" json:"options,omitempty"`
// DataKey marks a field as a DATA-ENCRYPTING key (e.g. AdventureLog's "Titkosítási kulcs"):
// the app encrypts stored data with it, so regenerating it would render restored data
// unreadable. It is a fail-closed annotation only — the recovery unit never stores secrets;
@@ -113,7 +113,7 @@ type HealthCheckConfig struct {
// HealthCheckItem defines a single health check probe.
type HealthCheckItem struct {
Type string `yaml:"type" json:"type"` // "http", "api", "tcp"
Type string `yaml:"type" json:"type"` // "http", "api", "tcp"
Port int `yaml:"port" json:"port"`
Path string `yaml:"path" json:"path"` // for http/api; default "/"
Method string `yaml:"method" json:"method"` // for api; default "GET"
+29 -2
View File
@@ -848,7 +848,13 @@ func walkMerge(lg *log.Logger, srcNS, dstNS string, skip map[string]bool, assert
if assertOnly {
return nil
}
return os.MkdirAll(dst, 0o755)
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
// #8 (v0.66.0): preserve the SOURCE dir's full mode (incl. setgid) + group, so the userdata
// ownership convention (2775 setgid, gid 1000) survives a whole-drive migration. MkdirAll's
// mode is umask-masked + drops setgid, so re-stamp explicitly from the source.
return preserveDirOwnership(dst, d)
}
// Symlink: recreate-if-absent (copy mode); ignored in assert mode.
@@ -994,10 +1000,15 @@ func copyFile(src, dst string) (int64, error) {
os.Remove(tmp)
return 0, err
}
if err := os.Chmod(tmp, fi.Mode().Perm()); err != nil {
// #8 (v0.66.0): preserve the SOURCE file's FULL mode (incl. setgid/setuid/sticky — not .Perm(),
// which masks them off) + group, so the userdata convention survives a whole-drive migration.
if err := os.Chmod(tmp, fi.Mode()); err != nil {
os.Remove(tmp)
return 0, err
}
if gid, ok := appbackup.StatGID(fi); ok {
_ = os.Chown(tmp, -1, gid) // best-effort; needs root for an arbitrary group (the controller is)
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return 0, err
@@ -1005,6 +1016,22 @@ func copyFile(src, dst string) (int64, error) {
return n, nil
}
// preserveDirOwnership re-stamps a freshly-created target dir with the SOURCE dir's full mode (incl.
// setgid) and group — part of the #8 fix so the userdata convention survives a migration.
func preserveDirOwnership(dst string, d fs.DirEntry) error {
info, err := d.Info()
if err != nil {
return err
}
if err := os.Chmod(dst, info.Mode()); err != nil {
return err
}
if gid, ok := appbackup.StatGID(info); ok {
_ = os.Chown(dst, -1, gid) // best-effort; root sets an arbitrary group (the controller is root)
}
return nil
}
// fileSum returns the hex sha256 of a file (streaming).
func fileSum(path string) (string, error) {
f, err := os.Open(path)
@@ -0,0 +1,59 @@
//go:build linux
package stacks
import (
"io"
"log"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// TestWalkMerge_PreservesSetgid is the #8 fix proof: the non-app merge walk must preserve the SOURCE
// dir's setgid bit + group, so the userdata ownership convention (2775 setgid, gid 1000) survives a
// whole-drive migration. Companion: the pre-fix path (MkdirAll 0755 / copyFile .Perm()) drops setgid
// → this test FAILS on the old code (mutation-proven separately).
func TestWalkMerge_PreservesSetgid(t *testing.T) {
lg := log.New(io.Discard, "", 0)
src := t.TempDir()
dst := t.TempDir()
// Source userdata-style tree with setgid dirs (the convention).
media := filepath.Join(src, "userdata", "media")
if err := os.MkdirAll(media, 0o755); err != nil {
t.Fatal(err)
}
for _, d := range []string{filepath.Join(src, "userdata"), media} {
if err := os.Chmod(d, os.ModeSetgid|0o775); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(media, "movie.txt"), []byte("VID"), 0o664); err != nil {
t.Fatal(err)
}
if err := walkMerge(lg, src, dst, nil, false, nil); err != nil {
t.Fatalf("walkMerge: %v", err)
}
// Target media dir must still carry setgid + the source group.
srcFI, _ := os.Stat(media)
dstFI, err := os.Stat(filepath.Join(dst, "userdata", "media"))
if err != nil {
t.Fatalf("target media dir missing: %v", err)
}
if dstFI.Mode()&os.ModeSetgid == 0 {
t.Errorf("migration DROPPED the setgid bit on the dir: mode=%v", dstFI.Mode())
}
if dstFI.Mode().Perm() != 0o775 {
t.Errorf("target dir perm = %o, want 0775", dstFI.Mode().Perm())
}
sg, _ := appbackup.StatGID(srcFI)
dg, _ := appbackup.StatGID(dstFI)
if sg != dg {
t.Errorf("target dir group = %d, want source group %d", dg, sg)
}
}
@@ -0,0 +1,73 @@
package stacks
import (
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
const beltCompose = `services:
app:
image: x
volumes:
- app_config:/config
- ${USERDATA_PATH}/media/movies:/media/movies
- ${USERDATA_PATH}/downloads:/downloads
- ${HDD_PATH}/appdata/app:/data
- /etc/passwd:/host:ro
volumes:
app_config:
`
// TestParseComposeUserdataMounts: only ${USERDATA_PATH}/... bind sources are returned, resolved; HDD
// appdata mounts, named volumes, and unrelated host paths are ignored.
func TestParseComposeUserdataMounts(t *testing.T) {
dir := t.TempDir()
cp := filepath.Join(dir, "docker-compose.yml")
if err := os.WriteFile(cp, []byte(beltCompose), 0o644); err != nil {
t.Fatal(err)
}
ud := filepath.Clean("/mnt/felhom-usb/userdata")
got := map[string]bool{}
for _, m := range ParseComposeUserdataMounts(cp, ud) {
got[m] = true
}
for _, want := range []string{
filepath.Join(ud, "media", "movies"),
filepath.Join(ud, "downloads"),
} {
if !got[want] {
t.Errorf("missing userdata mount %q (got %v)", want, got)
}
}
if len(got) != 2 {
t.Errorf("expected exactly 2 userdata mounts, got %d: %v", len(got), got)
}
}
// TestEnsureUserdataMounts_CreatesBeltDirs: the deploy belt pre-creates every declared ${USERDATA_PATH}
// bind source before compose-up (so Docker never auto-creates one as root). Uses a real temp userdata
// root via env injection.
func TestEnsureUserdataMounts_CreatesBeltDirs(t *testing.T) {
m := newMigManager(t, "") // minimal Manager (cfg+logger+settings)
stackDir := t.TempDir()
if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte(beltCompose), 0o644); err != nil {
t.Fatal(err)
}
ud := filepath.Join(t.TempDir(), "userdata")
env := []string{"USERDATA_PATH=" + ud}
// movies dir absent before
if _, err := os.Stat(filepath.Join(ud, "media", "movies")); err == nil {
t.Fatal("precondition: movies dir should not exist yet")
}
m.ensureUserdataMounts(stackDir, env)
for _, p := range []string{filepath.Join(ud, "media", "movies"), filepath.Join(ud, "downloads")} {
if fi, err := os.Stat(p); err != nil || !fi.IsDir() {
t.Errorf("belt did not create %s (%v)", p, err)
}
}
_ = appbackup.SharedContentGID // keep import referenced cross-platform
}