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
+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