package report import ( "bufio" "os" "path/filepath" "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // readComposeFile is the production composeReader: best-effort read of a stack's docker-compose.yml // (an unreadable file yields "" → no storage bindings for that app, never a failure). func readComposeFile(path string) string { data, err := os.ReadFile(path) if err != nil { return "" } return string(data) } // DR recipe — the controller (customer + apps) HALF of the secret-free reconstruction recipe // (SPIKE-dr-recipe-2026-06-16). The recipe is the non-secret re-provision plan that complements escrow // (keys) + PBS/restic (bytes). This emitter is THE BOUNDARY ENFORCEMENT POINT: the controller is the // component that distinguishes secret from non-secret deploy fields (it encrypts ENC: secrets in // app.yaml), so v1 ships ONLY an explicit allowlist — {catalog_ref, enabled, storage_bindings} — and // NEVER touches AppConfig.Env. A new field cannot leak a secret because it is excluded by default // (allowlist, not denylist). TestBuildAppRecipe_NoSecrets is the load-bearing proof. // // recipe_version=1; read is ignore-unknown (forward-compat). The hub assembles this half with the // agent's storage/guest/PBS half into one customer recipe keyed by customer + recipe_version. const DRRecipeVersion = 1 // DRRecipeAppHalf is the controller-emitted half. type DRRecipeAppHalf struct { RecipeVersion int `json:"recipe_version"` Customer DRCustomer `json:"customer"` Apps []AppRecipe `json:"apps"` // OffsiteRestic (fork-4) is the non-secret location of the offsite restic repo, so DR knows WHERE to // recover from. nil when offsite is not configured. Coordinates ONLY — see DRResticCoord. OffsiteRestic *DRResticCoord `json:"offsite_restic,omitempty"` } // DRResticCoord is the offsite restic repo's non-secret coordinates. The repo PASSWORD rides the R-escrow // (IdentityBundle.ResticRepoPassword); the SFTP access key is regenerated at DR (a fresh sub-account key) — // so NEITHER appears here. All field names deliberately clear the _NoSecrets regex (no password/key/token). type DRResticCoord struct { Host string `json:"host"` User string `json:"user"` Port int `json:"port"` RepoPath string `json:"repo_path"` } // DRCustomer is the customer identity — public identifiers only. type DRCustomer struct { ID string `json:"id"` Display string `json:"display"` Domain string `json:"domain"` } // AppRecipe is the v1 per-app allowlist. NOTHING from AppConfig.Env is emitted — the three fields below // are the entire surface, so no env secret (ENC: value, token, password) can ride along. type AppRecipe struct { CatalogRef string `json:"catalog_ref"` Enabled bool `json:"enabled"` StorageBindings []StorageBinding `json:"storage_bindings"` } // StorageBinding names WHERE an app's data lives on a user-data drive: the container mount target, the // drive name, and the path under the drive. Identifiers/paths only — e.g. romm.library → felhom-flash // : userdata/roms. The hub correlates `drive` to the agent half's durable-id. type StorageBinding struct { ContainerPath string `json:"container_path"` Drive string `json:"drive"` Subpath string `json:"subpath"` } // BuildDRRecipeAppHalf assembles the controller half: customer identity + an AppRecipe per DEPLOYED, // non-protected stack. composeReader returns a stack's docker-compose.yml content (seam: tests inject a // fake; production passes os.ReadFile-backed). Pure given the reader → unit-tested directly. func BuildDRRecipeAppHalf(custID, custName, domain string, all []stacks.Stack, composeReader func(path string) string) *DRRecipeAppHalf { half := &DRRecipeAppHalf{ RecipeVersion: DRRecipeVersion, Customer: DRCustomer{ID: custID, Display: custName, Domain: domain}, Apps: []AppRecipe{}, } for _, s := range all { if s.Protected || !s.Deployed { continue } var composeYAML string if composeReader != nil && s.ComposePath != "" { composeYAML = composeReader(s.ComposePath) } half.Apps = append(half.Apps, buildAppRecipe(s, composeYAML)) } return half } // buildAppRecipe constructs ONE app's recipe from the allowlist ONLY. This function deliberately reads // NOTHING from s.AppConfig.Env — that is the boundary. catalog_ref = the catalog slug (falls back to the // stack name for an orphaned app); storage_bindings parsed from the compose against the app's HDD_PATH. func buildAppRecipe(s stacks.Stack, composeYAML string) AppRecipe { catalogRef := s.Meta.Slug if catalogRef == "" { catalogRef = s.Name } var hddPath string if s.AppConfig != nil { hddPath = s.AppConfig.Env["HDD_PATH"] // a path identifier, NOT a secret (secrets are ENC: in Env) } return AppRecipe{ CatalogRef: catalogRef, Enabled: s.Deployed, StorageBindings: appStorageBindings(composeYAML, hddPath), } } // appStorageBindings parses a docker-compose.yml for volume binds that land on the app's user-data drive // (under HDD_PATH, or its USERDATA_PATH=/userdata sibling) and returns {container_path, drive, // subpath}. Pure (operates on the compose text), so it is unit-tested without files. The drive name is // the basename of HDD_PATH (e.g. /mnt/felhom-drives/felhom-flash → "felhom-flash"). func appStorageBindings(composeYAML, hddPath string) []StorageBinding { if hddPath == "" || composeYAML == "" { return []StorageBinding{} } cleanHDD := filepath.ToSlash(filepath.Clean(hddPath)) userdata := cleanHDD + "/userdata" drive := filepath.Base(cleanHDD) bindings := []StorageBinding{} seen := map[string]bool{} scanner := bufio.NewScanner(strings.NewReader(composeYAML)) 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 } host := parts[0] host = strings.ReplaceAll(host, "${USERDATA_PATH}", userdata) host = strings.ReplaceAll(host, "${HDD_PATH}", cleanHDD) // Also tolerate the un-braced $VAR form. host = strings.ReplaceAll(host, "$USERDATA_PATH", userdata) host = strings.ReplaceAll(host, "$HDD_PATH", cleanHDD) host = filepath.ToSlash(filepath.Clean(host)) if host != cleanHDD && !strings.HasPrefix(host, cleanHDD+"/") { continue // not on this drive } subpath := strings.TrimPrefix(host, cleanHDD) subpath = strings.TrimPrefix(subpath, "/") container := parts[1] key := container + "\x00" + subpath if seen[key] { continue } seen[key] = true bindings = append(bindings, StorageBinding{ContainerPath: container, Drive: drive, Subpath: subpath}) } return bindings }