package stacks import ( "os" "path/filepath" "sort" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // Catalog-derived userdata skeleton (R-75). // // Before this, the customer-facing tree was a hardcoded Go list, so a new catalog app with a folder // needed a controller release. The set is now DERIVED from the catalog the controller has already // synced to disk — using the same authoritative parser the classifier uses — merged with the // carry-list (appbackup.UserdataSkeletonCarry) so it can only ever ADD. // // Scope note: this derives ${USERDATA_PATH} binds only. ${IMPORT_PATH} binds are NOT part of a // drive's skeleton — the canonical drop-zone lives once, on the system drive, and is ensured by // EnsureImportRoot instead. // DeriveUserdataDirs returns the ${USERDATA_PATH}-relative dirs implied by every template in // stacksDir. ALL catalog apps count, not just deployed ones (Fork-2 ruling): the skeleton is // storage-path-scoped and idempotent, and the all-apps set is barely larger than the historical // hardcoded one (SPIKE P0(a): 14 vs 14, differing by one entry each way). Deployed-only filtering // belongs in the UI, where an empty folder would actually confuse someone. // // Unreadable dirs / missing composes are skipped silently — a partially-synced catalog must degrade // to "fewer derived dirs", never to an error that blocks the skeleton (the carry-list is the floor). // The result is sorted; BuildUserdataSkeleton sorts again after merging, so both layers are pinned. func DeriveUserdataDirs(stacksDir string) []string { entries, err := os.ReadDir(stacksDir) if err != nil { return nil } set := map[string]bool{} for _, e := range entries { if !e.IsDir() { continue } composePath := filepath.Join(stacksDir, e.Name(), "docker-compose.yml") for _, b := range ParseComposeClassifiableBinds(composePath) { if b.Root != appbackup.RootUserdata || b.RelPath == "" { continue } set[b.RelPath] = true } } out := make([]string, 0, len(set)) for d := range set { out = append(out, d) } sort.Strings(out) return out } // UserdataSkeletonDirs is the merged, sorted set to create on a storage path: catalog-derived plus // the carry-list. This is THE function every skeleton caller should use. func (m *Manager) UserdataSkeletonDirs() []string { return appbackup.BuildUserdataSkeleton(DeriveUserdataDirs(m.cfg.Paths.StacksDir)) } // EnsureUserdataSkeleton applies the merged skeleton to a storage path's namespace root. Signature // kept as func(string) error so it drops straight into fbPathDeps.ensureSkeleton. func (m *Manager) EnsureUserdataSkeleton(nsRoot string) error { return appbackup.EnsureUserdataSkeleton(nsRoot, m.UserdataSkeletonDirs()) } // EnsureImportRoot creates the CANONICAL drop-zone root on the system drive with the userdata // convention, so it exists (and is browsable + shareable) even before any drop-zone app is deployed. // Idempotent; a no-op when the import root is unresolvable. // // It deliberately does NOT pre-create per-app subfolders: those are the deploy belt's job, so an app // the customer never installed does not litter the drop-zone with an empty folder. func (m *Manager) EnsureImportRoot() error { root := m.GetImportRoot() if root == "" { return nil } // The PARENT userdata dir must carry the convention too. EnsureUserdataDir MkdirAll's its // intermediates at plain 0755 and then chmods only the leaf, so ensuring just the import dir // leaves /userdata at 755 root:root — the one userdata root on the box that would not // be 2775/gid-1000. Observed live on demo-felhom before this line existed. sys := m.cfg.Paths.SystemDataPath if sys == "" { return nil } if err := appbackup.EnsureUserdataDir(appbackup.UserdataDir(appbackup.NamespaceRoot(sys, false))); err != nil { return err } return appbackup.EnsureUserdataDir(root) }