v0.66.1: inject USERDATA_PATH on first deploy too (shared withUserdataPath)
DeployStack's initial compose-up builds env from deploy values (not stackEnv), so
v0.66.0 missed USERDATA_PATH on first deploy → ${USERDATA_PATH} resolved to '' and
Docker bound a root-owned dir at the container root (found live: radarr /media/movies
was 0:0 755). Shared withUserdataPath injector now used by stackEnv AND
composeExecWithEnv. Regression test included.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
## Changelog
|
## 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 `<drive>/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)
|
### 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)
|
Customer-facing `userdata/` tree (sibling of appdata/backups under each drive's felhom-data namespace)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -513,16 +514,31 @@ func (m *Manager) RedeployFromEnv(name string, env map[string]string) error {
|
|||||||
return m.RefreshStatus()
|
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) {
|
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
|
||||||
cmdEnv := os.Environ()
|
cmdEnv := os.Environ()
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
|
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
|
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
|
||||||
|
cmdEnv = withUserdataPath(cmdEnv, env["HDD_PATH"])
|
||||||
return m.composeExecCustomEnv(dir, cmdEnv, args...)
|
return m.composeExecCustomEnv(dir, cmdEnv, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withUserdataPath appends USERDATA_PATH=<hdd>/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).
|
// GetDeployFields returns the deployment fields for a stack (for the deploy form).
|
||||||
func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
|
func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
|
||||||
stack, ok := m.GetStack(name)
|
stack, ok := m.GetStack(name)
|
||||||
|
|||||||
@@ -860,9 +860,7 @@ func (m *Manager) stackEnv(stackDir string) []string {
|
|||||||
// Inject USERDATA_PATH = <namespace root>/userdata alongside HDD_PATH (v0.66.0). HDD_PATH IS
|
// 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
|
// 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/.
|
// felhom-data dir), so the catalog's ${USERDATA_PATH}/... mounts resolve under userdata/.
|
||||||
if hdd := appCfg.Env["HDD_PATH"]; hdd != "" {
|
env = withUserdataPath(env, appCfg.Env["HDD_PATH"])
|
||||||
env = append(env, fmt.Sprintf("USERDATA_PATH=%s", appbackup.UserdataDir(hdd)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return env
|
return env
|
||||||
|
|||||||
@@ -71,3 +71,26 @@ func TestEnsureUserdataMounts_CreatesBeltDirs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_ = appbackup.SharedContentGID // keep import referenced cross-platform
|
_ = appbackup.SharedContentGID // keep import referenced cross-platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWithUserdataPath: the shared injector adds USERDATA_PATH=<hdd>/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user