package stacks import ( "fmt" "log" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // `data_paths:` — the customer-facing folder ANNOTATION (R-75). // // It annotates paths that ALREADY EXIST as compose binds; it can never declare a new one. That is // what keeps it cheap: no new filesystem-write primitive is introduced from catalog data, because // every path it names is a path the deploy belt already creates. // DataPathRole is the presentation role of an annotated folder. type DataPathRole string const ( RoleImport DataPathRole = "import" // a drop-zone: the app consumes and usually DELETES from it RoleLibrary DataPathRole = "library" // where the customer's files live RoleExport DataPathRole = "export" // the app writes results here for the customer to collect ) // DataPath is one entry of the `data_paths:` block. type DataPath struct { Path string `yaml:"path" json:"path"` // relative to Root, must match a compose bind Root appbackup.BindRoot `yaml:"root" json:"root"` // import | userdata | hdd Role DataPathRole `yaml:"role" json:"role"` // import | library | export Label string `yaml:"label" json:"label"` // Hungarian, customer-facing } // validRole reports whether r is a role this controller knows how to render. func validRole(r DataPathRole) bool { switch r { case RoleImport, RoleLibrary, RoleExport: return true default: return false } } // ValidateDataPaths applies the Fork-3 ruling, and the ASYMMETRY is deliberate — a decision, not an // inconsistency: // // - A MALFORMED PATH is a whole-block reject (returns an error; the caller drops the entire block // and the app renders no folder card). Paths govern DATA HANDLING: a path that is absolute, // escapes the root, or matches no compose bind would point the customer at a directory that is // not the app's, so nothing from that block can be trusted. This follows the `backup:` precedent // and reuses its refusal set (appbackup.ValidateRelPath) rather than defining a second one. // // - An UNKNOWN ROLE fails OPEN (that one entry is dropped with a WARN; everything else renders). // Roles govern PRESENTATION only, and this follows the Lifecycle precedent (metadata.go): a typo // in a catalog push must never brick a template. The cost of an unknown role is one missing UI // affordance, never a mishandled file. // // Returns the surviving entries. An error means the caller must discard the WHOLE block. func ValidateDataPaths(entries []DataPath, binds []appbackup.ComposeBind, appName string, logger *log.Logger) ([]DataPath, error) { if len(entries) == 0 { return nil, nil } present := make(map[appbackup.BindRoot]map[string]bool) for _, b := range binds { if present[b.Root] == nil { present[b.Root] = make(map[string]bool) } present[b.Root][b.RelPath] = true } seen := make(map[string]bool) out := make([]DataPath, 0, len(entries)) for _, e := range entries { // --- path rules: whole-block reject --- if !appbackup.ValidRoot(e.Root) { return nil, fmt.Errorf("data_paths[%q]: unknown root %q (want import|userdata|hdd)", e.Path, e.Root) } if err := appbackup.ValidateRelPath(e.Root, e.Path); err != nil { return nil, fmt.Errorf("data_paths: %w", err) } key := string(e.Root) + "\x00" + e.Path if seen[key] { return nil, fmt.Errorf("data_paths[%s/%s]: duplicate entry", e.Root, e.Path) } seen[key] = true if !present[e.Root][e.Path] { return nil, fmt.Errorf("data_paths[%s/%s]: matches no compose bind — data_paths ANNOTATES existing binds, it cannot declare new ones", e.Root, e.Path) } // --- role: fail OPEN --- if !validRole(e.Role) { if logger != nil { logger.Printf("[WARN] [stacks] %s: unknown data_paths role %q for %s/%s — entry not surfaced (known: %s, %s, %s)", appName, e.Role, e.Root, e.Path, RoleImport, RoleLibrary, RoleExport) } continue } out = append(out, e) } return out, nil }