package stacks import ( "fmt" "path/filepath" ) // Desired-state values for AppConfig.DesiredState (R-166, decision D-b). // // THREE values, and the empty one is load-bearing — see the field's own comment in deploy.go. // Named constants rather than bare strings so a typo is a compile error and every reader can be // found with one grep. const ( // DesiredStateUnknown is the absent value: nobody has told us what the customer wants. It is the // value of every app.yaml written before v0.189.0. It NEVER means "running". DesiredStateUnknown = "" // DesiredStateRunning — the customer asked for this app to be running. An app in this state that // is not running is a fault the boot reconciler repairs, HOWEVER it came to be down. DesiredStateRunning = "running" // DesiredStateStopped — the customer pressed Stop. Nothing may start it again on its own. DesiredStateStopped = "stopped" ) // SetDesiredState records the CUSTOMER's intent for a stack in its app.yaml. // // THE OWNERSHIP RULE, and the reason this is a separate function rather than a line inside // StopStack/StartStack: desired state is written by the customer's own action and by nothing else. // A census of the two primitives on 2026-08-02 found fourteen call sites, of which exactly two are // the customer (the API action switch and the deploy path). The other twelve are machines — the // quiesce loop, the backup volume dump, offbox reconstitution, app export/restore, the storage // drive-absent gate, the migration engine and the boot reconciler itself. If the primitive recorded // intent, a nightly backup stopping an app for a consistent volume dump would be indistinguishable // from the customer stopping it, and the app would never come back. That confusion is the defect // R-166 exists to end, so it must not be reintroduced one layer down. // // Callers MUST write intent BEFORE performing the act (§8.2), and MUST refuse the act if this // returns an error. The asymmetry is deliberate: // // - Stop: intent first. If the write lands and the stop then fails, the record says "stopped" // while the app runs — harmless, because the reconciler only ever acts on apps that are DOWN. // The reverse order risks an app with zero containers and "running" still recorded, i.e. a // deliberate stop undone at the next boot. // - Start: intent first. If the start then fails, the reconciler retries it later — which is // exactly what is wanted. // // An app with no app.yaml is a no-op, not an error: no app.yaml means nothing is deployed in that // directory, and every consumer of desired state gates on Deployed first, so there is no intent to // record and nothing that could read one. func (m *Manager) SetDesiredState(name, desired string) error { switch desired { case DesiredStateRunning, DesiredStateStopped: default: // DesiredStateUnknown is deliberately NOT settable. "Unknown" is the absence of a record, // and a caller asking to write it is a caller that has confused "no opinion" with "stopped". return fmt.Errorf("desired state %q is not one of %q/%q", desired, DesiredStateRunning, DesiredStateStopped) } stack, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } stackDir := filepath.Dir(stack.ComposePath) cfg := LoadAppConfig(stackDir) if cfg == nil { m.logger.Printf("[DEBUG] [stacks] desired state %s=%s: no app.yaml — nothing deployed here, nothing to record", name, desired) return nil } if cfg.DesiredState == desired { return nil // already recorded — do not rewrite app.yaml for no change } previous := cfg.DesiredState cfg.DesiredState = desired meta := LoadMetadata(stackDir) if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil { // NEVER swallowed: the caller refuses the action on this error, because an act whose intent // could not be recorded is exactly the ambiguity this feature removes. return fmt.Errorf("recording desired state %q for stack %s: %w", desired, name, err) } m.logger.Printf("[INFO] [stacks] desired state for %s recorded as %q (was %q)", name, desired, previous) // Keep the in-memory view in step so nothing reads a stale intent between here and the next // ScanStacks. Under the same lock every other AppConfig mutation uses. m.mu.Lock() if s, ok := m.stacks[name]; ok && s.AppConfig != nil { s.AppConfig.DesiredState = desired } m.mu.Unlock() return nil } // DesiredStateOf returns the recorded customer intent for a stack, or DesiredStateUnknown when // there is none (no app.yaml, or an app.yaml predating v0.189.0). func DesiredStateOf(s Stack) string { if s.AppConfig == nil { return DesiredStateUnknown } return s.AppConfig.DesiredState } // BackfillDesiredState writes DesiredStateRunning for every deployed app that has NO recorded // desired state AND is observed UP right now. Returns how many were backfilled. Call ONCE at // startup, before the boot reconciler. // // RUNNING-ONLY, AND THAT IS NOT AN OVERSIGHT. The one inference available for the other direction — // "zero containers, therefore the customer stopped it" — IS THE DEFECT R-166 exists to remove. A // power cut mid-compose, an interrupted deploy and a deliberate Stop all leave an app with zero // containers, and nothing on disk distinguishes them. So an ambiguous app is left ambiguous: it // keeps the legacy boot behaviour (never auto-started) until the customer next presses a button, // which is both the safe outcome and byte-identical to what the box did before this feature. // // A running app is the one observation that IS unambiguous — an app that is up was, at some point, // asked to be up — so it converges without waiting for a button press. func (m *Manager) BackfillDesiredState() int { backfilled := 0 skippedAmbiguous := 0 for _, s := range m.GetStacks() { if !s.Deployed || s.Protected || s.Deploying { continue } if DesiredStateOf(s) != DesiredStateUnknown { continue } if !isObservedUp(s) { skippedAmbiguous++ continue } if err := m.SetDesiredState(s.Name, DesiredStateRunning); err != nil { m.logger.Printf("[WARN] [stacks] desired-state backfill: %s: %v", s.Name, err) continue } backfilled++ } // A positive observable either way (standing rule 3): "0 backfilled" and "the backfill never // ran" must not look the same in a log. m.logger.Printf("[INFO] [stacks] desired-state backfill: %d app(s) recorded as running, %d left unrecorded (state ambiguous — legacy boot behaviour retained)", backfilled, skippedAmbiguous) return backfilled } // isObservedUp reports whether a stack's AGGREGATE state is up right now. // // It is deliberately an allow-list of up-states rather than !IsDownState: IsDownState excludes // restarting, unknown and deploying, so its negation would call a crash-looping or unreadable stack // "up" and backfill an intent from it. Only a positive reading may seed a durable record. // // aggregateState (manager.go) already walks EVERY container and lets any unhealthy or mixed result // win, so a partly-dead app cannot reach here reading healthy — D-b's every-container requirement is // met upstream and is deliberately not re-implemented. func isObservedUp(s Stack) bool { switch s.State { case StateRunning, StateStarting: return true default: return false } }