diff --git a/CHANGELOG.md b/CHANGELOG.md index de21604..fdbc7db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ ## Changelog +### v0.66.1 — fix USERDATA_PATH on first deploy (2026-06-14) + +The initial deploy path (`DeployStack` → `composeExecWithEnv`) builds its compose env from the deploy +values, not from app.yaml via `stackEnv` — so v0.66.0 injected `USERDATA_PATH` only on start/redeploy, +NOT on the FIRST deploy. A freshly-deployed app resolved `${USERDATA_PATH}` to `""` and Docker bound a +bogus root-owned dir at the container root (e.g. `/media/movies`) instead of `/userdata/...` +(found live: radarr's media mount was `0:0 755` at the container root). Fix: a shared `withUserdataPath` +injector used by BOTH `stackEnv` and `composeExecWithEnv`. Regression test asserts injection on/off by +HDD_PATH presence. + ### v0.66.0 — userdata layout + shared-storage ownership convention (2026-06-14) Customer-facing `userdata/` tree (sibling of appdata/backups under each drive's felhom-data namespace) diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index e8d12e4..ae45fa6 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/system" "gopkg.in/yaml.v3" @@ -513,16 +514,31 @@ func (m *Manager) RedeployFromEnv(name string, env map[string]string) error { return m.RefreshStatus() } -// composeExecWithEnv runs a compose command with custom env vars injected. +// composeExecWithEnv runs a compose command with custom env vars injected. Used by the initial deploy +// path (DeployStack), which builds env from the deploy values rather than from app.yaml via stackEnv — +// so USERDATA_PATH must be injected here too (mirrors stackEnv), else the FIRST deploy resolves +// ${USERDATA_PATH} to "" and binds a bogus root-owned dir at the container root. func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) { cmdEnv := os.Environ() for k, v := range env { cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v)) } cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain)) + cmdEnv = withUserdataPath(cmdEnv, env["HDD_PATH"]) return m.composeExecCustomEnv(dir, cmdEnv, args...) } +// withUserdataPath appends USERDATA_PATH=/userdata to a "K=V" env slice when hdd is non-empty. +// Shared by BOTH compose-env builders (stackEnv for start/redeploy, composeExecWithEnv for the initial +// deploy) so ${USERDATA_PATH} always resolves — the initial-deploy path missing it bound a bogus +// root-owned dir at the container root. +func withUserdataPath(cmdEnv []string, hdd string) []string { + if hdd != "" { + cmdEnv = append(cmdEnv, "USERDATA_PATH="+appbackup.UserdataDir(hdd)) + } + return cmdEnv +} + // GetDeployFields returns the deployment fields for a stack (for the deploy form). func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) { stack, ok := m.GetStack(name) diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 8ae336e..efb4d2c 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -860,9 +860,7 @@ func (m *Manager) stackEnv(stackDir string) []string { // Inject USERDATA_PATH = /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))) - } + env = withUserdataPath(env, appCfg.Env["HDD_PATH"]) } return env diff --git a/controller/internal/stacks/userdata_belt_test.go b/controller/internal/stacks/userdata_belt_test.go index 5bb3043..0bf7c55 100644 --- a/controller/internal/stacks/userdata_belt_test.go +++ b/controller/internal/stacks/userdata_belt_test.go @@ -71,3 +71,26 @@ func TestEnsureUserdataMounts_CreatesBeltDirs(t *testing.T) { } _ = appbackup.SharedContentGID // keep import referenced cross-platform } + +// TestWithUserdataPath: the shared injector adds USERDATA_PATH=/userdata when HDD_PATH is set, and +// adds nothing when it's empty. Regression for the initial-deploy bug where ${USERDATA_PATH} resolved +// to "" and bound a bogus root-owned dir at the container root. +func TestWithUserdataPath(t *testing.T) { + got := withUserdataPath([]string{"DOMAIN=x"}, "/mnt/felhom-usb") + want := "USERDATA_PATH=" + appbackup.UserdataDir("/mnt/felhom-usb") + found := false + for _, e := range got { + if e == want { + found = true + } + } + if !found { + t.Errorf("USERDATA_PATH not injected: got %v, want %q", got, want) + } + // companion: empty HDD_PATH → no USERDATA_PATH at all + for _, e := range withUserdataPath([]string{"DOMAIN=x"}, "") { + if len(e) >= 13 && e[:13] == "USERDATA_PATH" { + t.Errorf("USERDATA_PATH must NOT be set when HDD_PATH is empty: %q", e) + } + } +}