package stacks import ( "bytes" "fmt" "log" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "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" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // ContainerState represents the current state of a container. type ContainerState string const ( StateRunning ContainerState = "running" StateStarting ContainerState = "starting" // running but health: starting StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy StateStopped ContainerState = "stopped" StateDegraded ContainerState = "degraded" // multi-container stack: a SUPERVISED member is dead (R-51) StateRestarting ContainerState = "restarting" StateExited ContainerState = "exited" StatePaused ContainerState = "paused" StateUnknown ContainerState = "unknown" StateNotDeployed ContainerState = "not_deployed" StateDeploying ContainerState = "deploying" // compose up in progress (image pull, etc.) StateOrphaned ContainerState = "orphaned" ) // IsDownState reports whether a container state means a DEPLOYED app is not running and won't recover // on its own (fix-3, CAMPAIGN-3). Only `stopped`, `exited` and `degraded` qualify — a Docker // "created"/"dead" container (a failed-at-boot app, the F11 case) resolves to `stopped`. Deliberately // NOT `starting` / `unhealthy` (running, with their own health handling), `restarting` // (self-recovering), `deploying` (mid-deploy), `paused` (a deliberate user action), or `unknown` // (ambiguous — fail-open, never manufacture a dead-app alert from an inconclusive read). // // R-51 (v0.156.0) added `degraded`: a multi-container stack whose SUPERVISED member is dead is as // unreachable as a single-container app that exited (immich-server sat Exited for 18 h with the app // 100 % dead and no alert, while single-container Calibre-Web alerted in 90 s). This is deliberately // NOT the same as folding `unhealthy` into down — that exclusion stays byte-identical, because // `unhealthy` is a *running* container whose healthcheck is failing and folding it in reintroduces // the flapping fix-3 was added to stop. func IsDownState(s ContainerState) bool { return s == StateStopped || s == StateExited || s == StateDegraded } // C9-F2 — a SUSTAINED `restarting` is a crash loop, and a crash loop is a dead app. // // THE BUG THIS EXISTS TO KILL. `IsDownState` above excludes `restarting` as "self-recovering", and // for a brief restart that is exactly right. But Docker sets `restarting` while a container is being // restarted BY POLICY, and for the catalog's standard `restart: unless-stopped` that is precisely the // crash-loop signal — the retry count is unlimited, so "self-recovering" is a promise Docker never // made. Campaign 9 watched docmost loop for nine minutes (restartcount 18, policy `unless-stopped`) // while the F-OBS heartbeat printed "180 scans since boot, 4 deployed app(s) evaluated, 0 currently // down". No banner, no app_start_failed, no email, no hub event — indefinitely. // // This is CONTEXT.md's own lesson one state over: "Docker's .State says 'running' even for unhealthy // containers — must parse .Status". Same trap, different state, and this state means something worse. // // ── WHY A THRESHOLD AND NOT A DOWN-STATE ───────────────────────────────────────────────────── // // Adding StateRestarting to IsDownState would alarm on every deploy and every update, fleet-wide, // because the normal `docker compose up -d` path passes through `restarting`. An alarm that fires on // routine operations is one the operator learns to ignore — which is what F-A1 nearly cost us right // after R-97a built it. So `restarting` becomes down only once it has PERSISTED. // // ── WHERE 5 MINUTES COMES FROM ─────────────────────────────────────────────────────────────── // // Measured against the three real numbers already in this codebase, not picked round: // - the deploy flow allows **120 s** for a stack to come up healthy — the project's own existing // answer to "how long is too long"; an app still restarting past it has failed deployment; // - the slowest catalog healthcheck start_period is Mealie's **60 s**, after which a couple of // check intervals must still elapse before any verdict is meaningful; // - R-97b's quiesce grace is **180 s**, and this must sit ABOVE it so the two windows compose into // one bounded delay rather than a gap where an app is un-suppressed but not yet sustained. // // 300 s clears all three with margin. It is also unambiguous against Docker's own backoff, which // grows 100 ms → 200 ms → … and caps at 60 s: a genuine crash loop registers at least four restart // attempts inside this window, so a stack that is still `restarting` at 5 minutes is not mid-deploy. // // The cost is a bounded DELAY in reporting a real crash loop, never its loss — the same trade R-97b // made deliberately, and the opposite of the indefinite silence this replaces. const crashLoopAfter = 5 * time.Minute // CrashLooping reports whether the stack has been `restarting` for longer than crashLoopAfter. // `now` is injected so the rule is a unit-testable contract rather than a property of the clock. // A zero RestartingSince means "not restarting, or not yet observed restarting" — never a crash loop. func (s *Stack) CrashLooping(now time.Time) bool { if s == nil || s.State != StateRestarting || s.RestartingSince.IsZero() { return false } return now.Sub(s.RestartingSince) >= crashLoopAfter } // ContainerInfo holds status info about a single container within a stack. type ContainerInfo struct { Name string `json:"name"` Image string `json:"image"` State ContainerState `json:"state"` Status string `json:"status"` // e.g. "Up 3 hours (healthy)" } // HealthProbeResult holds the latest controller-side health probe result. type HealthProbeResult struct { Healthy bool `json:"healthy"` LastCheck time.Time `json:"last_check"` Details []HealthCheckDetail `json:"details"` } // HealthCheckDetail holds the result of a single health check item. type HealthCheckDetail struct { Type string `json:"type"` // "http", "api", "tcp" Target string `json:"target"` // e.g. ":3456/api/v1/info" Healthy bool `json:"healthy"` Status int `json:"status,omitempty"` // HTTP status code (for http/api) Latency string `json:"latency"` // e.g. "45ms" Error string `json:"error,omitempty"` // error message if unhealthy } // Stack represents a docker compose stack on disk. type Stack struct { Name string `json:"name"` Meta Metadata `json:"meta"` ComposePath string `json:"compose_path"` State ContainerState `json:"state"` Deployed bool `json:"deployed"` // Has app.yaml with deployed=true Protected bool `json:"protected"` Orphaned bool `json:"orphaned"` // Deployed but no catalog template Containers []ContainerInfo `json:"containers"` AppConfig *AppConfig `json:"app_config,omitempty"` Deploying bool `json:"deploying"` // compose up in progress DeployError string `json:"deploy_error,omitempty"` // last async deploy error HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result LastUpdated time.Time `json:"last_updated"` // RestartingSince (C9-F2) is when this stack was FIRST observed in StateRestarting during the // current restarting run; zero whenever the stack is in any other state. It is what turns a brief // restart (normal: deploy, update, quiesce restart) into a distinguishable crash loop — see // CrashLooping. Not persisted: a controller restart re-observes the state within one refresh, and // forgetting costs at most one threshold window, whereas persisting could carry a stale // "this app is crash-looping" verdict across the restart that fixed it. RestartingSince time.Time `json:"restarting_since,omitempty"` } // Manager handles all docker compose stack operations. type Manager struct { cfg *config.Config logger *log.Logger composeCmd string stacks map[string]*Stack mu sync.RWMutex encKey []byte // AES-256 key for encrypting sensitive values in app.yaml infraMu sync.Mutex // single-flight guard for EnsureBaseStack (base-infra bring-up/self-heal) // Migration engine (B1): single-flight + live job + deps wired via SetMigrationDeps. migrateMu sync.Mutex migrating bool migJob *MigrationJob settings *settings.Settings sysDataPath string backupRunning func() bool // mutual exclusion with the backup orchestrator (Change 3) migDoneHook func(*MigrationJob) // fired on successful completion (decommission policy lives in caller) testSeams *migSeams // nil in production; tests inject fakes // R-51: docker restart policies for DOWN members of mixed stacks. Keyed by // containerName+"|"+state so a transitioned or recreated container re-reads rather than // answering from a stale entry; pruned every refresh to the live container set. Guarded by mu // (every read/write happens under refreshStatusLocked's write lock). restartPolicyCache map[string]string // execFn replaces execCommand's process boundary in tests; nil in production. execFn func(name string, args ...string) (string, error) // inspectRestartPolicyFn is the docker-inspect seam for the above; nil in production // (dockerRestartPolicy). Tests inject a scripted lookup and never touch docker. inspectRestartPolicyFn func(containerName string) (string, error) // isMountPoint reports whether a path is a live mountpoint; defaults to system.IsMountPoint. // Injectable so the userdata-belt drive-absent gate is testable (a t.TempDir is never a real mount). isMountPoint func(string) bool // Samba (R-7) seams — nil in production. sambaUpFn replaces the `compose up -d` call (tests // assert the idempotent no-op performs ZERO calls); sambaPasswdFn replaces the smbpasswd // docker-exec so no unit test touches docker or handles a real secret. sambaUpFn func(dir string) error sambaPasswdFn func(password string) error sambaRunFn func() bool // replaces the docker-inspect liveness probe sambaImgFn func() bool // replaces the `docker image inspect` local-presence probe (4b card) // sambaAddrFn replaces the docker-exec that reads the guest's LAN IPv4 out of the samba // container's network namespace (v0.151.0, S-2/S-5 connect-address card). sambaAddrFn func() (string, error) // guestNetExecFn replaces guestnet.go's docker-exec into the samba netns (R-66 gateway row + // Debug dump network section); nil in production. One seam for all guest-net reads — tests // script canned `ip`/resolv.conf outputs per argv and never touch docker. guestNetExecFn func(args ...string) (string, error) } // SetSambaRunProbe injects the samba liveness probe. Exported for the same reason // SetMigrationDoneHook and SetOffboxStreamRunner are: the seam has to be reachable from ANOTHER // package's tests — here internal/web, which needs a live-container world to prove that a live // container no longer manufactures a job phase (S-1). Production never calls it; nil keeps the real // docker-inspect probe. func (m *Manager) SetSambaRunProbe(fn func() bool) { m.sambaRunFn = fn } // NewManager creates a new stack manager. func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) { composeCmd := cfg.Stacks.ComposeCommand if composeCmd == "" { composeCmd = detectComposeCommand() } if composeCmd == "" { return nil, fmt.Errorf("docker compose not found (tried 'docker compose' and 'docker-compose')") } logger.Printf("[INFO] [stacks] Using compose command: %s", composeCmd) if err := os.MkdirAll(cfg.Paths.StacksDir, 0755); err != nil { return nil, fmt.Errorf("creating stacks directory %s: %w", cfg.Paths.StacksDir, err) } return &Manager{ cfg: cfg, logger: logger, composeCmd: composeCmd, stacks: make(map[string]*Stack), isMountPoint: system.IsMountPoint, }, nil } // GetImportRoot returns the CANONICAL drop-zone root (R-75): /userdata/import. // // It is resolved from the SYSTEM drive, never from the app's HDD_PATH, so every app's drop-zone lands // in one place regardless of which drive the app was deployed to. The system drive holds a felhom-data // SUBDIR (it is not itself the namespace root — that is the inGuestDrive=false case), which is why // NamespaceRoot is applied rather than using the configured path directly. // // Returns "" when the system data path is unconfigured. Callers must NOT substitute a per-drive // fallback: that would put a folder that looks like a drop-zone on every drive while only one works. // withPathVars leaves IMPORT_PATH unset instead, so compose fails loudly on ${IMPORT_PATH}. // // NOTE: the system drive is deliberately NOT a registered StoragePath (verified on both demo boxes, // 2026-07-26), so this root is invisible to the storage UI, to buildFileBrowserPaths' per-path loop // and to sharingResolvePath's owning-root check. Everything that must reach it does so explicitly — // see EnsureImportRoot, the FileBrowser import bind, and the System SMB share. func (m *Manager) GetImportRoot() string { sys := m.cfg.Paths.SystemDataPath if sys == "" { m.logger.Printf("[ERROR] [stacks] IMPORT_PATH unresolvable: paths.system_data_path is empty — a drop-zone bind will fail to resolve rather than silently land on a data drive") return "" } return appbackup.ImportDir(appbackup.NamespaceRoot(sys, false)) } // ensureUserdataMounts is the deploy belt: pre-create every ${USERDATA_PATH}/... and ${IMPORT_PATH}/... // bind source the stack declares with the userdata convention, so Docker never auto-creates one as // guest-root. // // The two roots are gated DIFFERENTLY and that is load-bearing. ${USERDATA_PATH} is on the app's own // data drive and is subject to the drive-absent gate; ${IMPORT_PATH} (R-75) is on the SYSTEM drive, // which is always present, so gating it on a detached data drive would refuse to create a directory // that has nothing to do with that drive. func (m *Manager) ensureUserdataMounts(stackDir string, env []string) { composePath := filepath.Join(stackDir, "docker-compose.yml") binds := ParseComposeClassifiableBinds(composePath) // --- import binds: system drive, never drive-gated --- if importPath := envLookup(env, "IMPORT_PATH"); importPath != "" { for _, b := range binds { if b.Root != appbackup.RootImport { continue } src := filepath.Join(importPath, filepath.FromSlash(b.RelPath)) if err := appbackup.EnsureUserdataDir(src); err != nil { m.logger.Printf("[WARN] [stacks] import belt: ensure %s: %v", src, err) } } } else { for _, b := range binds { if b.Root == appbackup.RootImport { m.logger.Printf("[ERROR] [stacks] import belt: stack declares a ${IMPORT_PATH} bind but IMPORT_PATH is unset — compose will fail rather than bind a wrong-drive path") break } } } // --- userdata binds: the app's own drive, drive-absent gated --- userdataPath := envLookup(env, "USERDATA_PATH") if userdataPath == "" { return } // Drive-absent gate: an external drive root (not the system/local path) that isn't currently a live // mountpoint means the drive is detached. Creating ${USERDATA_PATH}/... now would write app data onto // the guest ROOTFS, shadowed when the drive returns (data-integrity + rootfs-fill hazard). Skip — the // app is held by the drive gate (planDriveGates). The system/local path is legitimately not a // mountpoint, so it is never gated. if hdd := envLookup(env, "HDD_PATH"); hdd != "" && hdd != m.sysDataPath && !m.isMountPoint(hdd) { m.logger.Printf("[INFO] [stacks] userdata belt: drive %s not mounted — skipping ensure (held by drive gate)", hdd) return } for _, b := range binds { if b.Root != appbackup.RootUserdata { continue } src := filepath.Join(userdataPath, filepath.FromSlash(b.RelPath)) 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() defer m.mu.Unlock() m.encKey = key } // GetStacksBaseDir returns the base directory where stacks live. func (m *Manager) GetStacksBaseDir() string { return m.cfg.Paths.StacksDir } // MigrateEncryption re-saves app.yaml for deployed stacks that still have // plaintext values in sensitive fields. Called once on startup. func (m *Manager) MigrateEncryption() { m.mu.Lock() defer m.mu.Unlock() if m.encKey == nil { if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: no encryption key set, skipping") } return } if m.isDebug() { deployedCount := 0 for _, s := range m.stacks { if s.Deployed { deployedCount++ } } m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: checking %d deployed stacks for plaintext sensitive values", deployedCount) } migrated := 0 for _, s := range m.stacks { if !s.Deployed { continue } stackDir := filepath.Dir(s.ComposePath) appCfg := LoadAppConfig(stackDir) if appCfg == nil { continue } meta := LoadMetadata(stackDir) sensitive := SensitiveEnvVars(&meta) if len(sensitive) == 0 { continue } if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: checking stack %q (%d sensitive fields)", s.Name, len(sensitive)) } needsMigration := false for _, envVar := range sensitive { if v, ok := appCfg.Env[envVar]; ok && v != "" && !crypto.IsEncrypted(v) { needsMigration = true break } } if needsMigration { if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: stack %q needs migration — re-saving with encryption", s.Name) } if err := SaveAppConfig(stackDir, appCfg, m.encKey, sensitive); err != nil { m.logger.Printf("[WARN] [stacks] Encryption migration failed for %s: %v", s.Name, err) } else { migrated++ } } } if migrated > 0 { m.logger.Printf("[INFO] [stacks] Encrypted sensitive values in %d app.yaml file(s)", migrated) } else { m.logger.Printf("[INFO] [stacks] Encryption migration: no stacks needed migration") } } // toTitleCase capitalizes the first letter of each word. func toTitleCase(s string) string { words := strings.Fields(s) for i, w := range words { if len(w) > 0 { words[i] = strings.ToUpper(w[:1]) + w[1:] } } return strings.Join(words, " ") } func detectComposeCommand() string { if err := exec.Command("docker", "compose", "version").Run(); err == nil { return "docker compose" } if _, err := exec.LookPath("docker-compose"); err == nil { return "docker-compose" } return "" } // DeployedStackNames returns the names of all deployed stacks. func (m *Manager) DeployedStackNames() []string { m.mu.RLock() defer m.mu.RUnlock() var names []string for name, stack := range m.stacks { if stack.Deployed { names = append(names, name) } } return names } // RunningAppStacks returns the names of deployed, NON-protected stacks that currently have // containers up (running/starting/unhealthy/restarting/degraded) — the set the quiesce loop (slice 8B) // stops before an app-consistent backup and restarts after. Protected infra (traefik, cloudflared, // felhom-controller) is excluded so the controller never stops its own tunnel/proxy or itself. // Sorted for deterministic stop/start order. func (m *Manager) RunningAppStacks() []string { m.mu.RLock() defer m.mu.RUnlock() var names []string for name, stack := range m.stacks { if !stack.Deployed || stack.Protected || m.cfg.IsProtectedStack(name) { continue } switch stack.State { // StateDegraded (R-51) belongs here: a degraded stack still has LIVE members, and the // quiesce loop must stop them before an app-consistent backup and start them after. case StateRunning, StateStarting, StateUnhealthy, StateRestarting, StateDegraded: names = append(names, name) } } sort.Strings(names) return names } // ScanStacks discovers all compose stacks in the stacks directory. func (m *Manager) ScanStacks() error { m.mu.Lock() defer m.mu.Unlock() entries, err := os.ReadDir(m.cfg.Paths.StacksDir) if err != nil { return fmt.Errorf("reading stacks directory: %w", err) } found := make(map[string]bool) for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() stackDir := filepath.Join(m.cfg.Paths.StacksDir, name) composePath := filepath.Join(stackDir, "docker-compose.yml") if _, err := os.Stat(composePath); os.IsNotExist(err) { composePath = filepath.Join(stackDir, "docker-compose.yaml") if _, err := os.Stat(composePath); os.IsNotExist(err) { continue } } found[name] = true meta := LoadMetadata(stackDir) appCfg := LoadAppConfig(stackDir) deployed := appCfg != nil && appCfg.Deployed if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] ScanStacks: found stack %q deployed=%v composePath=%s", name, deployed, composePath) } if existing, ok := m.stacks[name]; ok { existing.ComposePath = composePath existing.Meta = meta existing.Protected = m.cfg.IsProtectedStack(name) // Don't overwrite Deployed/AppConfig while an async deploy is in // progress — the goroutine manages these fields (H3 fix). if !existing.Deploying { existing.Deployed = deployed existing.AppConfig = appCfg } } else { m.stacks[name] = &Stack{ Name: name, Meta: meta, ComposePath: composePath, State: StateNotDeployed, Deployed: deployed, Protected: m.cfg.IsProtectedStack(name), AppConfig: appCfg, } } } // Remove stacks no longer on disk for name := range m.stacks { if !found[name] { delete(m.stacks, name) } } // Detect orphaned stacks (deployed but no longer in catalog) catalogTemplates := m.getCatalogTemplateSlugs() if m.isDebug() { if catalogTemplates != nil { m.logger.Printf("[DEBUG] [stacks] ScanStacks: catalog has %d template slugs for orphan detection", len(catalogTemplates)) } else { m.logger.Printf("[DEBUG] [stacks] ScanStacks: catalog templates unavailable, skipping orphan detection") } } if catalogTemplates != nil { orphanCount := 0 for _, stack := range m.stacks { if stack.Protected || !stack.Deployed { stack.Orphaned = false continue } stack.Orphaned = !catalogTemplates[stack.Name] if stack.Orphaned { orphanCount++ if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] ScanStacks: stack %q is orphaned (deployed but not in catalog)", stack.Name) } } } if orphanCount > 0 { m.logger.Printf("[INFO] [stacks] Detected %d orphaned stack(s)", orphanCount) } } deployedCount := 0 for _, s := range m.stacks { if s.Deployed { deployedCount++ } } m.logger.Printf("[INFO] [stacks] ScanStacks complete: %d stacks found (%d deployed, %d available)", len(m.stacks), deployedCount, len(m.stacks)-deployedCount) return m.refreshStatusLocked() } // RefreshStatus updates container status for all known stacks. func (m *Manager) RefreshStatus() error { m.mu.Lock() defer m.mu.Unlock() return m.refreshStatusLocked() } func (m *Manager) refreshStatusLocked() error { output, err := m.execCommand("docker", "ps", "-a", "--format", "{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Label \"com.docker.compose.project\"}}", "--no-trunc") if err != nil { return fmt.Errorf("docker ps: %w", err) } projectContainers := make(map[string][]ContainerInfo) liveContainers := make(map[string]bool) totalContainers := 0 for _, line := range strings.Split(strings.TrimSpace(output), "\n") { if line == "" { continue } parts := strings.SplitN(line, "\t", 5) if len(parts) < 5 || parts[4] == "" { continue } ci := ContainerInfo{ Name: parts[0], Image: parts[1], State: resolveContainerState(parts[2], parts[3]), Status: parts[3], } projectContainers[parts[4]] = append(projectContainers[parts[4]], ci) liveContainers[ci.Name] = true totalContainers++ } m.pruneRestartPolicyCacheLocked(liveContainers) policyOf := m.restartPolicyLookupLocked() // fix-6: refreshStatusLocked runs every 10s (the status-refresh job) — its per-cycle enumeration // lines are TRACE (dropped from the debug ring) so they don't eat the post-incident window. A real // state change is logged elsewhere at INFO; a docker error returns up the stack. m.logger.Printf("[TRACE] [stacks] refreshStatusLocked: docker ps returned %d containers across %d projects", totalContainers, len(projectContainers)) m.logger.Printf("[INFO] [stacks] Status refresh: %d containers across %d stacks", totalContainers, len(m.stacks)) for name, stack := range m.stacks { containers, exists := projectContainers[name] if !exists { stack.Containers = nil if stack.Deploying { stack.State = StateDeploying } else if stack.Deployed { stack.State = StateStopped } else { stack.State = StateNotDeployed } } else { stack.Containers = containers stack.State = aggregateState(containers, policyOf) } // Re-apply controller-side health probe results: if the last probe // failed and Docker thinks the container is running, override to unhealthy. if stack.State == StateRunning && stack.HealthProbe != nil && !stack.HealthProbe.Healthy { stack.State = StateUnhealthy } // C9-F2: stamp the start of a restarting RUN, and clear it the moment the stack is anything // else. Set AFTER the health-probe override above so the stamp always agrees with the state // that is actually stored. Clearing on any other state is what keeps a normal deploy — which // passes through restarting briefly — from ever accumulating toward the threshold. if stack.State == StateRestarting { if stack.RestartingSince.IsZero() { stack.RestartingSince = time.Now() } } else { stack.RestartingSince = time.Time{} } if m.isDebug() { m.logger.Printf("[TRACE] [stacks] refreshStatusLocked: stack %q → state=%s containers=%d", name, stack.State, len(stack.Containers)) } stack.LastUpdated = time.Now() } return nil } // dockerRestartPolicy reads one container's configured restart policy via docker inspect. // Returns ("", err) when the container is gone or the inspect fails — the caller treats that as // UNKNOWN (see supervisedPolicy). func (m *Manager) dockerRestartPolicy(containerName string) (string, error) { if m.inspectRestartPolicyFn != nil { return m.inspectRestartPolicyFn(containerName) } out, err := m.execCommand("docker", "inspect", "-f", "{{.HostConfig.RestartPolicy.Name}}", containerName) if err != nil { return "", err } return strings.TrimSpace(out), nil } // restartPolicyLookupLocked returns the cached-and-memoizing lookup handed to aggregateState. // MUST be called with m.mu held for writing (it populates the cache). func (m *Manager) restartPolicyLookupLocked() restartPolicyLookup { return func(name string) string { key := name + "|policy" if m.restartPolicyCache != nil { if p, ok := m.restartPolicyCache[key]; ok { return p } } p, err := m.dockerRestartPolicy(name) if err != nil { // UNKNOWN — deliberately not cached, so a transient docker hiccup does not pin a // container to "unknown" for the rest of the process lifetime. m.logger.Printf("[WARN] [stacks] restart-policy inspect failed for container %q: %v (treating as supervised)", name, err) return "" } if m.restartPolicyCache == nil { m.restartPolicyCache = map[string]string{} } m.restartPolicyCache[key] = p if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] restart-policy of down member %q = %q", name, p) } return p } } // pruneRestartPolicyCacheLocked drops cache entries for containers that no longer exist, so a // long-lived controller cannot accumulate entries for deleted apps. MUST hold mu for writing. func (m *Manager) pruneRestartPolicyCacheLocked(live map[string]bool) { for key := range m.restartPolicyCache { name := strings.TrimSuffix(key, "|policy") if !live[name] { delete(m.restartPolicyCache, key) } } } // resolveContainerState determines the effective state by combining Docker's // State field (running/exited/etc.) with the Status field that contains health info. // // Docker State: "running", "exited", "restarting", "paused", "created", "dead", "removing" // Docker Status: "Up 3 hours (healthy)", "Up 9 seconds (health: starting)", "Up 2 min (unhealthy)" func resolveContainerState(dockerState, dockerStatus string) ContainerState { state := strings.ToLower(strings.TrimSpace(dockerState)) status := strings.ToLower(dockerStatus) switch state { case "running": // Check health sub-status for containers with healthchecks if strings.Contains(status, "(health: starting)") { return StateStarting } if strings.Contains(status, "(unhealthy)") { return StateUnhealthy } // "(healthy)" or no healthcheck = running return StateRunning case "exited": return StateExited case "restarting": return StateRestarting case "paused": return StatePaused case "created", "dead", "removing": return StateStopped default: return StateUnknown } } // restartPolicyLookup returns a container's docker restart policy name ("always", "unless-stopped", // "on-failure", "no"). An empty string means UNKNOWN — the inspect failed or no lookup was supplied. type restartPolicyLookup func(containerName string) string // supervisedPolicy reports whether a restart policy means "docker is supposed to keep this container // running" — i.e. its being Exited is a fault, not a design. // // UNKNOWN ("") counts as supervised, deliberately fail-CLOSED, which is the opposite of the // IsDownState fail-open rule and for a different reason: there the input is an *ambiguous state*, // here we already KNOW a member is dead and only the excuse is missing. The P2 census (2026-07-21, // 53 catalog templates / 78 services) found **every** catalog service on `unless-stopped` and zero // one-shot init/migrate containers, so "unknown" in the field is an inspect failure on a container // that is almost certainly supervised. Missing a real dead-primary alarm is the failure that cost // 18 h; a false alarm is a banner. func supervisedPolicy(policy string) bool { switch policy { case "no", "on-failure": return false default: // "always", "unless-stopped", "" (unknown) return true } } // aggregateState determines the overall stack state from its containers. // Priority: unhealthy/starting > restarting > all-running > degraded > stopped // // policyOf is consulted ONLY for the mixed case (some members up, some down) and ONLY for the down // members — see the mix branch. nil is allowed (every exited member then reads as supervised). func aggregateState(containers []ContainerInfo, policyOf restartPolicyLookup) ContainerState { if len(containers) == 0 { return StateNotDeployed } running := 0 starting := 0 unhealthy := 0 restarting := 0 stopped := 0 var down []ContainerInfo for _, c := range containers { switch c.State { case StateRunning: running++ case StateStarting: starting++ case StateUnhealthy: unhealthy++ case StateRestarting: restarting++ case StateStopped, StateExited: stopped++ down = append(down, c) } } total := len(containers) // Any unhealthy → whole stack is unhealthy if unhealthy > 0 { return StateUnhealthy } // Any still starting → stack is starting if starting > 0 { return StateStarting } // Any restarting → stack is restarting if restarting > 0 { return StateRestarting } // All running (and healthy) → stack is running if running == total { return StateRunning } // All stopped → stack is stopped if stopped == total { return StateStopped } // Mix (some members up, some down) — R-51. Until v0.156.0 this reported StateRunning // unconditionally ("partial"), which is why a dead immich-server behind three live helpers was // invisible to fix-3 for 18 hours. A down member whose restart policy says docker should be // keeping it up is a FAULT → the whole stack is degraded (and degraded is a down state). A down // member with policy `no`/`on-failure` is a one-shot init/migrate container that has legitimately // finished → benign, the stack stays running. if running > 0 { for _, c := range down { policy := "" if policyOf != nil { policy = policyOf(c.Name) } if supervisedPolicy(policy) { return StateDegraded } } return StateRunning } return StateStopped } // --- Stack accessors --- func (m *Manager) GetStacks() []Stack { m.mu.RLock() defer m.mu.RUnlock() result := make([]Stack, 0, len(m.stacks)) for _, s := range m.stacks { result = append(result, deepCopyStack(s)) } // Sort alphabetically by display name for consistent UI ordering sort.Slice(result, func(i, j int) bool { return result[i].Meta.DisplayName < result[j].Meta.DisplayName }) return result } func (m *Manager) GetStack(name string) (*Stack, bool) { m.mu.RLock() defer m.mu.RUnlock() s, ok := m.stacks[name] if !ok { return nil, false } cp := deepCopyStack(s) return &cp, true } // deepCopyStack creates a deep copy of a Stack, including pointer fields. func deepCopyStack(s *Stack) Stack { cp := *s // Deep-copy Containers slice if s.Containers != nil { cp.Containers = make([]ContainerInfo, len(s.Containers)) copy(cp.Containers, s.Containers) } // Deep-copy AppConfig pointer if s.AppConfig != nil { acCopy := *s.AppConfig if s.AppConfig.Env != nil { acCopy.Env = make(map[string]string, len(s.AppConfig.Env)) for k, v := range s.AppConfig.Env { acCopy.Env[k] = v } } if s.AppConfig.LockedFields != nil { acCopy.LockedFields = make([]string, len(s.AppConfig.LockedFields)) copy(acCopy.LockedFields, s.AppConfig.LockedFields) } cp.AppConfig = &acCopy } // Deep-copy HealthProbe pointer if s.HealthProbe != nil { hpCopy := *s.HealthProbe if s.HealthProbe.Details != nil { hpCopy.Details = make([]HealthCheckDetail, len(s.HealthProbe.Details)) copy(hpCopy.Details, s.HealthProbe.Details) } cp.HealthProbe = &hpCopy } // Deep-copy Meta.DeployFields slice (including nested Options) if s.Meta.DeployFields != nil { cp.Meta.DeployFields = make([]DeployField, len(s.Meta.DeployFields)) copy(cp.Meta.DeployFields, s.Meta.DeployFields) for i, f := range s.Meta.DeployFields { if f.Options != nil { cp.Meta.DeployFields[i].Options = make([]SelectOption, len(f.Options)) copy(cp.Meta.DeployFields[i].Options, f.Options) } } } // Deep-copy Meta.OptionalConfig (slice of groups with nested Fields slices) if s.Meta.OptionalConfig != nil { cp.Meta.OptionalConfig = make([]OptionalConfigGroup, len(s.Meta.OptionalConfig)) copy(cp.Meta.OptionalConfig, s.Meta.OptionalConfig) for i, g := range s.Meta.OptionalConfig { if g.Fields != nil { cp.Meta.OptionalConfig[i].Fields = make([]OptionalConfigField, len(g.Fields)) copy(cp.Meta.OptionalConfig[i].Fields, g.Fields) } } } // Deep-copy Meta.Integrations if s.Meta.Integrations != nil { cp.Meta.Integrations = make([]IntegrationDef, len(s.Meta.Integrations)) copy(cp.Meta.Integrations, s.Meta.Integrations) } // Deep-copy Meta.HealthCheck pointer if s.Meta.HealthCheck != nil { hcCopy := *s.Meta.HealthCheck if s.Meta.HealthCheck.Checks != nil { hcCopy.Checks = make([]HealthCheckItem, len(s.Meta.HealthCheck.Checks)) copy(hcCopy.Checks, s.Meta.HealthCheck.Checks) for i, c := range s.Meta.HealthCheck.Checks { if c.Expect != nil { eCopy := *c.Expect hcCopy.Checks[i].Expect = &eCopy } } } cp.Meta.HealthCheck = &hcCopy } // Deep-copy Meta.InitialCreds pointer if s.Meta.InitialCreds != nil { icCopy := *s.Meta.InitialCreds cp.Meta.InitialCreds = &icCopy } return cp } // --- Stack operations --- // StartStack, StopStack, etc. now load app.yaml env for deployed stacks. func (m *Manager) StartStack(name string) error { stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] StartStack %s: current state=%s deployed=%v", name, stack.State, stack.Deployed) } m.logger.Printf("[INFO] [stacks] Starting stack: %s", name) start := time.Now() dir := filepath.Dir(stack.ComposePath) env := m.stackEnv(dir) if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] StartStack %s: prepared %d env vars for compose", name, len(env)) } if _, err := m.composeExecCustomEnv(dir, env, "up", "-d"); err != nil { m.logger.Printf("[ERROR] [stacks] Stack %s start failed after %.1fs: %v", name, time.Since(start).Seconds(), err) return fmt.Errorf("starting stack %s: %w", name, err) } m.logger.Printf("[INFO] [stacks] Stack %s started successfully (took %.1fs)", name, time.Since(start).Seconds()) m.logPostStartStatus(name, dir, env) // Clear stale health probe so refreshStatus won't re-apply an old unhealthy override. // The next health-probes tick (≤10s) will run a fresh probe. m.mu.Lock() if s, ok := m.stacks[name]; ok { s.HealthProbe = nil } m.mu.Unlock() return m.RefreshStatus() } // StartStackServices brings up ONLY the named compose services (`docker compose up -d ...`), // leaving the rest of the stack down. It exists for R-47: a database dump must be replayed into a // running DB container while the application that owns the schema is still stopped, otherwise the // app's own schema management races the replay (proven live — H4, // DIAG-immich-restore-round2-2026-07-19). Every catalog template's dependency direction is app→db, // so naming the DB service starts the DB and nothing else. // // An EMPTY service list is refused rather than passed through: `up -d` with no arguments is a FULL // start, which is precisely the behaviour this function exists to avoid — a silent fall-through // would reintroduce the race at the one call site that most needs it not to. // // Deliberately no logPostStartStatus: the app containers are absent ON PURPOSE here, and it would // WARN about every one of them. The full StartStack that always follows logs the real post-start // state. func (m *Manager) StartStackServices(name string, services []string) error { if len(services) == 0 { return fmt.Errorf("starting services of stack %s: empty service list", name) } stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } m.logger.Printf("[INFO] [stacks] Starting stack %s services only: %v", name, services) start := time.Now() dir := filepath.Dir(stack.ComposePath) env := m.stackEnv(dir) if _, err := m.composeExecCustomEnv(dir, env, append([]string{"up", "-d"}, services...)...); err != nil { m.logger.Printf("[ERROR] [stacks] Stack %s service start failed after %.1fs: %v", name, time.Since(start).Seconds(), err) return fmt.Errorf("starting services %v of stack %s: %w", services, name, err) } m.logger.Printf("[INFO] [stacks] Stack %s services %v started (took %.1fs)", name, services, time.Since(start).Seconds()) return m.RefreshStatus() } func (m *Manager) StopStack(name string) error { if m.cfg.IsProtectedStack(name) { return fmt.Errorf("stack %q is protected and cannot be stopped", name) } stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] StopStack %s: current state=%s deployed=%v containers=%d", name, stack.State, stack.Deployed, len(stack.Containers)) } m.logger.Printf("[INFO] [stacks] Stopping stack: %s", name) start := time.Now() dir := filepath.Dir(stack.ComposePath) if _, err := m.composeExec(dir, "down"); err != nil { m.logger.Printf("[ERROR] [stacks] Stack %s stop failed after %.1fs: %v", name, time.Since(start).Seconds(), err) return fmt.Errorf("stopping stack %s: %w", name, err) } m.logger.Printf("[INFO] [stacks] Stack %s stopped successfully (took %.1fs)", name, time.Since(start).Seconds()) return m.RefreshStatus() } func (m *Manager) RestartStack(name string) error { stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] RestartStack %s: current state=%s deployed=%v containers=%d", name, stack.State, stack.Deployed, len(stack.Containers)) } m.logger.Printf("[INFO] [stacks] Restarting stack: %s", name) start := time.Now() dir := filepath.Dir(stack.ComposePath) env := m.stackEnv(dir) // Use "up -d" instead of bare "restart" so that env vars from app.yaml // are injected and any template changes (new images, healthchecks) are // picked up. Plain "docker compose restart" only sends SIGTERM+start // to existing containers without re-reading the compose file or env. if _, err := m.composeExecCustomEnv(dir, env, "up", "-d"); err != nil { m.logger.Printf("[ERROR] [stacks] Stack %s restart failed after %.1fs: %v", name, time.Since(start).Seconds(), err) return fmt.Errorf("restarting stack %s: %w", name, err) } m.logger.Printf("[INFO] [stacks] Stack %s restarted successfully (took %.1fs)", name, time.Since(start).Seconds()) m.logPostStartStatus(name, dir, env) // Clear stale health probe so refreshStatus won't re-apply an old unhealthy override. m.mu.Lock() if s, ok := m.stacks[name]; ok { s.HealthProbe = nil } m.mu.Unlock() return m.RefreshStatus() } func (m *Manager) UpdateStack(name string) error { stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } m.logger.Printf("[INFO] [stacks] Updating stack: %s", name) start := time.Now() dir := filepath.Dir(stack.ComposePath) env := m.stackEnv(dir) if m.isDebug() { m.checkLocalImages(name, dir) } if _, err := m.composeExecCustomEnv(dir, env, "pull"); err != nil { m.logger.Printf("[ERROR] [stacks] Stack %s update (pull) failed after %.1fs: %v", name, time.Since(start).Seconds(), err) return fmt.Errorf("pulling images for %s: %w", name, err) } if _, err := m.composeExecCustomEnv(dir, env, "up", "-d", "--remove-orphans"); err != nil { m.logger.Printf("[ERROR] [stacks] Stack %s update (up) failed after %.1fs: %v", name, time.Since(start).Seconds(), err) return fmt.Errorf("recreating %s: %w", name, err) } m.logger.Printf("[INFO] [stacks] Stack %s updated successfully (took %.1fs)", name, time.Since(start).Seconds()) m.logPostStartStatus(name, dir, env) return m.RefreshStatus() } func (m *Manager) GetLogs(name string, lines int) (string, error) { stack, ok := m.GetStack(name) if !ok { return "", fmt.Errorf("stack %q not found", name) } if lines <= 0 { lines = 100 } if lines > 1000 { lines = 1000 } m.logger.Printf("[INFO] [stacks] Fetching logs for stack %s (tail=%d)", name, lines) dir := filepath.Dir(stack.ComposePath) output, err := m.composeExec(dir, "logs", "--tail", fmt.Sprintf("%d", lines), "--no-color") if err != nil { m.logger.Printf("[WARN] [stacks] Failed to fetch logs for %s: %v", name, err) return "", fmt.Errorf("getting logs for %s: %w", name, err) } if len(output) == 0 { m.logger.Printf("[DEBUG] Logs result for %s: 0 bytes returned (empty)", name) } else { m.logger.Printf("[DEBUG] Logs result for %s: %d bytes returned", name, len(output)) } return output, nil } // --- Env and compose helpers --- // stackEnv builds the full OS env slice for a stack, merging app.yaml values. func (m *Manager) stackEnv(stackDir string) []string { env := os.Environ() // Always inject DOMAIN env = append(env, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain)) // Load app.yaml if it exists — merge its env vars (decrypted for docker-compose) appCfg := LoadAppConfigDecrypted(stackDir, m.encKey) if appCfg != nil { for k, v := range appCfg.Env { env = append(env, fmt.Sprintf("%s=%s", k, v)) } // Inject USERDATA_PATH = /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/. // IMPORT_PATH (R-75) rides along but is derived from the SYSTEM drive, never from HDD_PATH. env = withPathVars(env, appCfg.Env["HDD_PATH"], m.GetImportRoot()) } // App-email relay env (appended LAST so it wins over any app.yaml default). Returns nil unless // global + per-app toggles are on and the app declares an smtp_mapping — so apps with email off // get nothing injected. Derived, never persisted to app.yaml. emailOn := appCfg != nil && appCfg.EmailEnabled meta := LoadMetadata(stackDir) if smtp := m.smtpEnv(&meta, emailOn); len(smtp) > 0 { env = append(env, smtp...) } return env } func (m *Manager) composeExec(dir string, args ...string) (string, error) { return m.composeExecCustomEnv(dir, nil, args...) } func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string) (string, error) { var cmd *exec.Cmd if m.composeCmd == "docker compose" { fullArgs := append([]string{"compose"}, args...) cmd = exec.Command("docker", fullArgs...) } else { cmd = exec.Command("docker-compose", args...) } cmd.Dir = dir if env != nil { cmd.Env = env } else { env = m.stackEnv(dir) 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 sysCount := 0 for _, e := range env { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { key := parts[0] // Only log non-system env vars (skip PATH, HOME, etc.) if strings.ToUpper(key) == key && !strings.HasPrefix(key, "_") { appKeys = append(appKeys, key) } else { sysCount++ } } } if len(appKeys) > 0 { m.logger.Printf("[DEBUG] Env vars for compose: [%s] (%d app + %d system)", strings.Join(appKeys, ", "), len(appKeys), sysCount) } } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr m.logger.Printf("[DEBUG] Running: %s %s (in %s)", m.composeCmd, strings.Join(args, " "), dir) start := time.Now() if err := cmd.Run(); err != nil { elapsed := time.Since(start) exitCode := -1 if exitErr, ok := err.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } m.logger.Printf("[ERROR] [stacks] Command failed: %s %s (in %s) — exit code %d (took %.1fs)", m.composeCmd, strings.Join(args, " "), dir, exitCode, elapsed.Seconds()) if stdoutStr := truncateStr(stdout.String(), 500); stdoutStr != "" { m.logger.Printf("[ERROR] [stacks] stdout: %s", stdoutStr) } if stderrStr := truncateStr(stderr.String(), 500); stderrStr != "" { m.logger.Printf("[ERROR] [stacks] stderr: %s", stderrStr) } return stdout.String(), fmt.Errorf("exit code %d\nstderr: %s", exitCode, truncateStr(stderr.String(), 500)) } m.logger.Printf("[DEBUG] Command completed: %s %s (took %.1fs)", m.composeCmd, strings.Join(args, " "), time.Since(start).Seconds()) return stdout.String(), nil } func (m *Manager) execCommand(name string, args ...string) (string, error) { // execFn is the process-boundary seam (nil in production). It exists so R-51 can be proven // through the REAL refreshStatusLocked path — docker ps → aggregateState → docker inspect — // rather than only through an injected aggregation helper, which would prove the helper and // not the caller (the v0.154.0 / v0.91.0 inert-seam class). if m.execFn != nil { return m.execFn(name, args...) } cmd := exec.Command(name, args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { m.logger.Printf("[ERROR] [stacks] execCommand failed: %v", err) return "", fmt.Errorf("exec %s %s: %w\nstderr: %s", name, strings.Join(args, " "), err, stderr.String()) } return stdout.String(), nil } // isDebug returns true if logging level is "debug". func (m *Manager) isDebug() bool { return m.cfg.Logging.Level == "debug" } // truncateStr truncates a string to maxLen characters, appending "..." if truncated. func truncateStr(s string, maxLen int) string { s = strings.TrimSpace(s) if len(s) <= maxLen { return s } return s[:maxLen] + "..." } // logPostStartStatus queries container states after a start/deploy operation // and logs them. This runs asynchronously to avoid blocking the HTTP response. func (m *Manager) logPostStartStatus(name, stackDir string, env []string) { envCopy := make([]string, len(env)) copy(envCopy, env) go func() { time.Sleep(3 * time.Second) output, err := m.composeExecCustomEnv(stackDir, envCopy, "ps", "-a", "--format", "table {{.Name}}\t{{.Image}}\t{{.State}}\t{{.Status}}") if err != nil { m.logger.Printf("[WARN] [stacks] Post-start status check failed for %s: %v", name, err) return } lines := strings.Split(strings.TrimSpace(output), "\n") if len(lines) <= 1 { m.logger.Printf("[WARN] [stacks] Post-start status for %s: no containers found", name) return } m.logger.Printf("[INFO] [stacks] Stack %s post-start status:", name) // Skip header line for _, line := range lines[1:] { m.logger.Printf("[INFO] [stacks] %s", line) } }() } // checkLocalImages parses docker-compose.yml for image: lines and checks which // are available locally. Informational only — logs results but never fails. func (m *Manager) checkLocalImages(name, stackDir string) { composePath := filepath.Join(stackDir, "docker-compose.yml") data, err := os.ReadFile(composePath) if err != nil { composePath = filepath.Join(stackDir, "docker-compose.yaml") data, err = os.ReadFile(composePath) if err != nil { m.logger.Printf("[DEBUG] Could not read compose file for image check: %v", err) return } } var images []string for _, line := range strings.Split(string(data), "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "image:") { img := strings.TrimSpace(strings.TrimPrefix(trimmed, "image:")) img = strings.Trim(img, "\"'") if img != "" && !strings.Contains(img, "${") { images = append(images, img) } } } if len(images) == 0 { m.logger.Printf("[DEBUG] No static image references found in %s compose file", name) return } m.logger.Printf("[INFO] [stacks] Deploying stack %s — checking %d images...", name, len(images)) for _, img := range images { cmd := exec.Command("docker", "image", "inspect", img) if err := cmd.Run(); err != nil { m.logger.Printf("[DEBUG] %s — not found locally, will pull", img) } else { m.logger.Printf("[DEBUG] %s — found locally", img) } } } // --- Memory helpers --- // ParseMemoryMB parses a memory string like "500M", "1G", "1.5G", "1024M", "768" // into megabytes. Returns 0 for empty or unparseable values. Case-insensitive. func ParseMemoryMB(s string) int { s = strings.TrimSpace(s) if s == "" { return 0 } upper := strings.ToUpper(s) if strings.HasSuffix(upper, "GB") { val, err := strconv.ParseFloat(strings.TrimSuffix(upper, "GB"), 64) if err != nil { return 0 } return int(val * 1024) } if strings.HasSuffix(upper, "G") { val, err := strconv.ParseFloat(strings.TrimSuffix(upper, "G"), 64) if err != nil { return 0 } return int(val * 1024) } if strings.HasSuffix(upper, "MB") { val, err := strconv.ParseFloat(strings.TrimSuffix(upper, "MB"), 64) if err != nil { return 0 } return int(val) } if strings.HasSuffix(upper, "M") { val, err := strconv.ParseFloat(strings.TrimSuffix(upper, "M"), 64) if err != nil { return 0 } return int(val) } // Plain number — assume MB val, err := strconv.ParseFloat(s, 64) if err != nil { return 0 } return int(val) } // CommittedMemory returns the sum of mem_request and mem_limit across all // deployed stacks that are currently running (or starting/unhealthy/restarting). // Stopped and exited apps are excluded since they do not consume memory. func (m *Manager) CommittedMemory() (requestMB int, limitMB int) { m.mu.RLock() defer m.mu.RUnlock() for _, s := range m.stacks { if !s.Deployed { continue } if s.State == StateStopped || s.State == StateExited { continue } requestMB += ParseMemoryMB(s.Meta.Resources.MemRequest) limitMB += ParseMemoryMB(s.Meta.Resources.MemLimit) } return } // StackMemoryMB returns the mem_request for a specific stack. func (m *Manager) StackMemoryMB(name string) int { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.stacks[name]; ok { return ParseMemoryMB(s.Meta.Resources.MemRequest) } return 0 } // getCatalogTemplateSlugs reads the synced catalog cache and returns a set of // template slugs (directory names) that have a docker-compose.yml. func (m *Manager) getCatalogTemplateSlugs() map[string]bool { cacheDir := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates") entries, err := os.ReadDir(cacheDir) if err != nil { m.logger.Printf("[WARN] [stacks] Cannot read catalog cache for orphan detection: %v", err) return nil } slugs := make(map[string]bool, len(entries)) for _, e := range entries { if e.IsDir() { composePath := filepath.Join(cacheDir, e.Name(), "docker-compose.yml") if _, err := os.Stat(composePath); err == nil { slugs[e.Name()] = true } } } if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] getCatalogTemplateSlugs: found %d template slugs in %s", len(slugs), cacheDir) } return slugs }