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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user