dbcb306fcf
gates / gates (push) Successful in 8s
The box stops inferring the customer's intent from a container count and reads
what they actually asked for.
Part 1 — desired state. AppConfig gains a tri-state `desired_state`
(""/running/stopped), written ONLY by the customer's own action: the API action
switch, DeployStack, UpdateOptionalConfig's redeploy branch, and the .fab
import. Intent is written BEFORE the act and a failed write REFUSES the act.
StartStack/StopStack are deliberately not writers — 14 callers, only 2 are the
customer. bootrecon.isBootOrphan now reads intent instead of len(Containers)>0,
which closes R-157 mechanism B (a power cut or interrupted deploy left an app
with zero containers, read as a deliberate stop, and stranded silently).
ABSENT MEANS UNKNOWN, NEVER "running": every pre-v0.189.0 app.yaml reads absent,
so the legacy fallback is byte-identical to the old rule. A running-only startup
backfill converges the unambiguous cases; `stopped` is never inferred.
Part 2 — backup.AppStopGuard, a persisted marker over every stop→work→start
window (volume dump, offbox reconstitute, .fab export). Its own file, never
quiesce's. Written before the stop, cleared only after a restart that succeeded,
kept when one fails. Recover() completes before the boot reconciler is launched
and returns its outcome, which main.go reports on the existing backup_failed
event once the notifier exists. A defer is not the mechanism — a SIGKILL runs
none (Campaign 8 fault 10).
Also: SaveAppConfig rebuilt AppConfig field-by-field (the R-100 shape) and would
have dropped desired_state on every save across nine call sites. Replaced with
copy-and-overlay. Measured: app.yaml does not round-trip unknown YAML keys.
No hub change, no agent coupling, no user-visible string. 27/27 packages green;
7 red-proofs observed FAIL then restored.
159 lines
7.2 KiB
Go
159 lines
7.2 KiB
Go
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
|
|
}
|
|
}
|