v0.189.0 — desired state + the app-stop crash marker (R-166 / D-b)
gates / gates (push) Successful in 8s
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.
This commit is contained in:
@@ -104,6 +104,23 @@ type AppConfig struct {
|
||||
// EmailEnabled is the per-app app-email toggle (default off). When on AND the global toggle is
|
||||
// on AND the app has an smtp_mapping, the controller injects the relay SMTP env at compose time.
|
||||
EmailEnabled bool `yaml:"email_enabled,omitempty" json:"email_enabled,omitempty"`
|
||||
// DesiredState (R-166 / decision D-b) is what the CUSTOMER asked for: DesiredStateRunning or
|
||||
// DesiredStateStopped. It is TRI-state, and the third value is the entire safety property:
|
||||
//
|
||||
// ABSENT ("") MEANS UNKNOWN — IT NEVER MEANS "running".
|
||||
//
|
||||
// Every app.yaml on every existing box was written before this field existed, so absent is the
|
||||
// overwhelmingly common value on upgrade. Reading it as "running" would start, on the next boot
|
||||
// after the upgrade, every app its owner deliberately stopped — fleet-wide, silently. Where the
|
||||
// state is unknown the boot reconciler falls back to its pre-R-166 behaviour instead of inventing
|
||||
// an answer (see internal/bootrecon.isBootOrphan and the §8.1 table it implements).
|
||||
//
|
||||
// ONE OWNER: the customer's own action writes this and nothing else does. StartStack/StopStack
|
||||
// are NOT writers — twelve of their fourteen callers are machines (quiesce, the backup volume
|
||||
// dump, app export, the storage gate, migration, the boot reconciler), and recording intent in
|
||||
// the primitive would make a nightly backup indistinguishable from the customer pressing Stop,
|
||||
// which is the exact confusion this field exists to end. Writers: SetDesiredState's callers.
|
||||
DesiredState string `yaml:"desired_state,omitempty" json:"desired_state,omitempty"`
|
||||
}
|
||||
|
||||
// DeployRequest contains the user-provided values from the deploy form.
|
||||
@@ -330,6 +347,12 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
DeployedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Env: env,
|
||||
LockedFields: lockedFields,
|
||||
// R-166: deploying an app IS the customer asking for it to run, and this is the
|
||||
// intent-before-the-act write (§8.2). Recorded on the transitional Deployed:false write too,
|
||||
// which is harmless and correct: nothing reads desired state on a stack that is not deployed
|
||||
// (isBootOrphan gates on Deployed first), and if the compose-up then fails, runComposeDeploy
|
||||
// reverts Deployed to false — so a failed deploy can never present as an app owed a restart.
|
||||
DesiredState: DesiredStateRunning,
|
||||
}
|
||||
|
||||
diskCfg := *appCfg
|
||||
@@ -670,6 +693,26 @@ func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]strin
|
||||
// If deployed, recreate containers to pick up new env vars
|
||||
// (docker compose restart does NOT pick up new env vars — must use up -d)
|
||||
if stack.Deployed {
|
||||
// R-166 — the THIRD customer-intent point, alongside the API action switch and deploy/import.
|
||||
// This branch runs `up -d`, so the customer editing an app's settings ends with the app
|
||||
// RUNNING; recording that keeps intent and reality in step. Written before the act (§8.2).
|
||||
//
|
||||
// Deliberately inside the `stack.Deployed` branch only: the other branch starts nothing, so
|
||||
// it expresses no opinion about whether the app should run. Set on the already-loaded appCfg
|
||||
// rather than through SetDesiredState so it rides the save just above instead of rewriting
|
||||
// app.yaml twice — the load-then-save is what makes that safe (SaveAppConfig copies-and-
|
||||
// overlays, so no other field is disturbed).
|
||||
if appCfg.DesiredState != DesiredStateRunning {
|
||||
appCfg.DesiredState = DesiredStateRunning
|
||||
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
|
||||
return fmt.Errorf("recording desired state before applying the new config: %w", err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if s, ok := m.stacks[stackName]; ok && s.AppConfig != nil {
|
||||
s.AppConfig.DesiredState = DesiredStateRunning
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] Restarting %s to apply new optional config", stackName)
|
||||
env := m.stackEnv(stackDir)
|
||||
if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); err != nil {
|
||||
@@ -741,14 +784,24 @@ func LoadAppConfig(stackDir string) *AppConfig {
|
||||
|
||||
func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error {
|
||||
encryptedCount := 0
|
||||
// Clone env and encrypt sensitive values
|
||||
saveCfg := &AppConfig{
|
||||
Deployed: cfg.Deployed,
|
||||
DeployedAt: cfg.DeployedAt,
|
||||
Env: make(map[string]string, len(cfg.Env)),
|
||||
LockedFields: cfg.LockedFields,
|
||||
EmailEnabled: cfg.EmailEnabled,
|
||||
}
|
||||
// COPY-AND-OVERLAY, never a field-by-field rebuild (the R-100 lesson, v0.181.0).
|
||||
//
|
||||
// This used to be a struct literal naming five fields. That shape is safe exactly until someone
|
||||
// adds a sixth: the new field is silently dropped on every save, and because the save path is
|
||||
// shared by nine call sites the loss shows up far from the code that caused it. R-100 shipped
|
||||
// with two live instances of precisely this bug (offboxConfigHandler and ApplyOffsiteTarget both
|
||||
// rebuilt a target field-by-field and erased LastSuccess).
|
||||
//
|
||||
// A value copy carries EVERY field the struct has, including ones added after this line was
|
||||
// written, so it is safe by construction. Only Env is rebuilt below — it is the one field that
|
||||
// needs transforming (encryption), and it must not alias the caller's map.
|
||||
//
|
||||
// LIMITATION, measured not assumed (TestSaveAppConfig_UnknownYAMLKeysAreDropped): keys present in
|
||||
// the on-disk YAML that this struct does not model are NOT preserved — the round-trip goes
|
||||
// through the struct, so yaml.Unmarshal discards them before this function ever sees them. That
|
||||
// is unchanged by R-166 and is why every writer must load-then-save rather than construct.
|
||||
saveCfg := *cfg
|
||||
saveCfg.Env = make(map[string]string, len(cfg.Env))
|
||||
sensitiveSet := make(map[string]bool, len(sensitiveVars))
|
||||
for _, v := range sensitiveVars {
|
||||
sensitiveSet[v] = true
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// R-166 / decision D-b — desired state is owned by the customer's action, persisted in app.yaml, and
|
||||
// backfilled only from an UNAMBIGUOUS observation.
|
||||
//
|
||||
// Every assertion here is on the FILE ON DISK (or on the started/skipped effect), never on "no error
|
||||
// returned": the whole feature is a durable record, so a test that does not read the record back has
|
||||
// proven nothing.
|
||||
|
||||
// newDSManager builds a Manager over a temp stacks dir, with `names` registered as stacks. Real FS
|
||||
// (t.TempDir) because the thing under test is a file write.
|
||||
func newDSManager(t *testing.T, names ...string) (*Manager, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0), stacks: map[string]*Stack{}}
|
||||
for _, n := range names {
|
||||
dir := filepath.Join(root, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compose := filepath.Join(dir, "docker-compose.yml")
|
||||
if err := os.WriteFile(compose, []byte("services: {}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.stacks[n] = &Stack{Name: n, ComposePath: compose}
|
||||
}
|
||||
return m, root
|
||||
}
|
||||
|
||||
func stackDirOf(root, name string) string { return filepath.Join(root, name) }
|
||||
|
||||
// writeAppYAML puts an app.yaml on disk verbatim — so a LEGACY file (no desired_state key) can be
|
||||
// modelled exactly, rather than approximated through the struct that added the key.
|
||||
func writeAppYAML(t *testing.T, dir, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readAppYAML(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// --- Group A/B — the intent is persisted, and only the two legal values are accepted --------------
|
||||
|
||||
func TestSetDesiredState_PersistsStoppedToDisk(t *testing.T) {
|
||||
m, root := newDSManager(t, "immich")
|
||||
dir := stackDirOf(root, "immich")
|
||||
writeAppYAML(t, dir, "deployed: true\ndeployed_at: \"2026-08-01T10:00:00Z\"\nenv:\n HDD_PATH: /mnt/hdd_1\n")
|
||||
|
||||
if err := m.SetDesiredState("immich", DesiredStateStopped); err != nil {
|
||||
t.Fatalf("SetDesiredState: %v", err)
|
||||
}
|
||||
|
||||
got := LoadAppConfig(dir)
|
||||
if got == nil {
|
||||
t.Fatal("app.yaml disappeared")
|
||||
}
|
||||
if got.DesiredState != DesiredStateStopped {
|
||||
t.Fatalf("desired_state on disk = %q, want %q", got.DesiredState, DesiredStateStopped)
|
||||
}
|
||||
// The rest of the file must be intact — this write must not cost the app its deploy record.
|
||||
if !got.Deployed || got.Env["HDD_PATH"] != "/mnt/hdd_1" || got.DeployedAt == "" {
|
||||
t.Fatalf("recording intent damaged the config: %+v", got)
|
||||
}
|
||||
if raw := readAppYAML(t, dir); !strings.Contains(raw, "desired_state: stopped") {
|
||||
t.Fatalf("the YAML key is not on disk:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDesiredState_RunningAndStoppedRoundTrip(t *testing.T) {
|
||||
m, root := newDSManager(t, "app")
|
||||
dir := stackDirOf(root, "app")
|
||||
writeAppYAML(t, dir, "deployed: true\nenv: {}\n")
|
||||
|
||||
for _, want := range []string{DesiredStateRunning, DesiredStateStopped, DesiredStateRunning} {
|
||||
if err := m.SetDesiredState("app", want); err != nil {
|
||||
t.Fatalf("SetDesiredState(%q): %v", want, err)
|
||||
}
|
||||
if got := LoadAppConfig(dir).DesiredState; got != want {
|
||||
t.Fatalf("after SetDesiredState(%q), disk says %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDesiredState_RefusesUnknownAndArbitraryValues(t *testing.T) {
|
||||
m, root := newDSManager(t, "app")
|
||||
dir := stackDirOf(root, "app")
|
||||
writeAppYAML(t, dir, "deployed: true\ndesired_state: running\nenv: {}\n")
|
||||
|
||||
for _, bad := range []string{DesiredStateUnknown, "paused", "RUNNING", "true"} {
|
||||
if err := m.SetDesiredState("app", bad); err == nil {
|
||||
t.Fatalf("SetDesiredState(%q) was accepted — only running/stopped are writable, and "+
|
||||
"'unknown' in particular must be the ABSENCE of a record, never a written value", bad)
|
||||
}
|
||||
}
|
||||
// A refused write must not have touched the file.
|
||||
if got := LoadAppConfig(dir).DesiredState; got != DesiredStateRunning {
|
||||
t.Fatalf("a refused write changed the record to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDesiredState_NoAppYAMLIsANoOpNotAnError(t *testing.T) {
|
||||
// No app.yaml = nothing deployed in that dir. Every consumer gates on Deployed first, so there
|
||||
// is no intent to record — and returning an error here would refuse a customer's Start on a
|
||||
// stack that simply is not installed.
|
||||
m, root := newDSManager(t, "app")
|
||||
if err := m.SetDesiredState("app", DesiredStateRunning); err != nil {
|
||||
t.Fatalf("want a silent no-op, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stackDirOf(root, "app"), "app.yaml")); !os.IsNotExist(err) {
|
||||
t.Fatal("an app.yaml was created for a stack that has none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDesiredState_UnknownStackIsAnError(t *testing.T) {
|
||||
m, _ := newDSManager(t)
|
||||
if err := m.SetDesiredState("ghost", DesiredStateStopped); err == nil {
|
||||
t.Fatal("SetDesiredState on an unknown stack silently succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group H (§1.2) — the save path preserves what it is given ----------------------------------
|
||||
|
||||
func TestSaveAppConfig_PreservesEveryKnownFieldAcrossLoadSave(t *testing.T) {
|
||||
// THE R-100 SHAPE. SaveAppConfig used to rebuild AppConfig from a five-field struct literal, so
|
||||
// any field added later was dropped on every save — and nine call sites share this path, so the
|
||||
// loss would surface far from its cause. DesiredState is exactly such a later field: without the
|
||||
// copy-and-overlay, a customer's Stop would be erased by the next unrelated app.yaml write (an
|
||||
// email-toggle change, an optional-config edit, the encryption migration).
|
||||
//
|
||||
// RED-PROOF: replace `saveCfg := *cfg` with the old literal
|
||||
// saveCfg := AppConfig{Deployed: cfg.Deployed, DeployedAt: cfg.DeployedAt,
|
||||
// Env: ..., LockedFields: cfg.LockedFields, EmailEnabled: cfg.EmailEnabled}
|
||||
// and this test fails on desired_state. Demonstrated in REPORT.md §5.
|
||||
dir := t.TempDir()
|
||||
orig := &AppConfig{
|
||||
Deployed: true,
|
||||
DeployedAt: "2026-08-02T09:00:00Z",
|
||||
Env: map[string]string{"HDD_PATH": "/mnt/hdd_1", "SUBDOMAIN": "fotok"},
|
||||
LockedFields: []string{"HDD_PATH"},
|
||||
EmailEnabled: true,
|
||||
DesiredState: DesiredStateStopped,
|
||||
}
|
||||
if err := SaveAppConfig(dir, orig, nil, nil); err != nil {
|
||||
t.Fatalf("first save: %v", err)
|
||||
}
|
||||
|
||||
// Load and save again WITHOUT touching anything — the round-trip an unrelated writer performs.
|
||||
reloaded := LoadAppConfig(dir)
|
||||
if reloaded == nil {
|
||||
t.Fatal("load returned nil")
|
||||
}
|
||||
if err := SaveAppConfig(dir, reloaded, nil, nil); err != nil {
|
||||
t.Fatalf("second save: %v", err)
|
||||
}
|
||||
|
||||
got := LoadAppConfig(dir)
|
||||
if got.DesiredState != DesiredStateStopped {
|
||||
t.Fatalf("desired_state was LOST across load→save (got %q) — a customer's Stop would be "+
|
||||
"erased by any unrelated app.yaml write", got.DesiredState)
|
||||
}
|
||||
if !got.Deployed || got.DeployedAt != orig.DeployedAt || !got.EmailEnabled {
|
||||
t.Fatalf("a known field was lost across load→save: %+v", got)
|
||||
}
|
||||
if len(got.LockedFields) != 1 || got.LockedFields[0] != "HDD_PATH" {
|
||||
t.Fatalf("locked_fields lost: %v", got.LockedFields)
|
||||
}
|
||||
if got.Env["HDD_PATH"] != "/mnt/hdd_1" || got.Env["SUBDOMAIN"] != "fotok" {
|
||||
t.Fatalf("env lost: %v", got.Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_UnknownYAMLKeysAreDropped(t *testing.T) {
|
||||
// MEASURED, NOT ASSUMED (§1.2 / §15.12). The answer is NO: app.yaml does not round-trip keys the
|
||||
// struct does not model, because the trip goes through the struct and yaml.Unmarshal discards
|
||||
// them before SaveAppConfig is ever reached.
|
||||
//
|
||||
// This test exists to make that limitation VISIBLE rather than discovered later. It is not a
|
||||
// defect introduced here and R-166 does not widen it — but it is the reason every writer must
|
||||
// load-then-save, and the reason a hand-edited app.yaml annotation will not survive.
|
||||
dir := t.TempDir()
|
||||
writeAppYAML(t, dir, "deployed: true\ndesired_state: running\nenv:\n A: b\nfuture_field: keep-me\n")
|
||||
|
||||
cfg := LoadAppConfig(dir)
|
||||
if cfg == nil {
|
||||
t.Fatal("load returned nil")
|
||||
}
|
||||
if err := SaveAppConfig(dir, cfg, nil, nil); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
raw := readAppYAML(t, dir)
|
||||
if strings.Contains(raw, "future_field") {
|
||||
t.Fatal("an unknown key SURVIVED — the documented limitation no longer holds; update the " +
|
||||
"comment on SaveAppConfig and REPORT.md §12, which both state that it does not")
|
||||
}
|
||||
// The modelled fields must of course survive.
|
||||
if got := LoadAppConfig(dir); got.DesiredState != DesiredStateRunning || got.Env["A"] != "b" {
|
||||
t.Fatalf("a MODELLED field was lost: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scenario D — backfill is running-only, and never invents "stopped" --------------------------
|
||||
|
||||
func TestBackfillDesiredState_RunningIsRecorded_AmbiguousIsLeftAlone(t *testing.T) {
|
||||
// Two legacy apps, no desired_state on either. One is observed RUNNING — unambiguous, so its
|
||||
// intent converges without waiting for a button press. One has ZERO CONTAINERS — the ambiguous
|
||||
// case that could be a deliberate stop, a power cut or an interrupted deploy, which is precisely
|
||||
// the inference R-166 exists to remove. It must be left with NO record.
|
||||
//
|
||||
// RED-PROOF: delete the `if !isObservedUp(s) { ... continue }` guard in BackfillDesiredState and
|
||||
// this test fails — the stopped app gets `running` written and would be started at the next boot.
|
||||
// Demonstrated in REPORT.md §5.
|
||||
m, root := newDSManager(t, "running-app", "stopped-app")
|
||||
writeAppYAML(t, stackDirOf(root, "running-app"), "deployed: true\nenv: {}\n")
|
||||
writeAppYAML(t, stackDirOf(root, "stopped-app"), "deployed: true\nenv: {}\n")
|
||||
|
||||
m.stacks["running-app"].Deployed = true
|
||||
m.stacks["running-app"].State = StateRunning
|
||||
m.stacks["running-app"].AppConfig = LoadAppConfig(stackDirOf(root, "running-app"))
|
||||
m.stacks["stopped-app"].Deployed = true
|
||||
m.stacks["stopped-app"].State = StateStopped
|
||||
m.stacks["stopped-app"].AppConfig = LoadAppConfig(stackDirOf(root, "stopped-app"))
|
||||
|
||||
if n := m.BackfillDesiredState(); n != 1 {
|
||||
t.Fatalf("backfilled %d, want exactly 1", n)
|
||||
}
|
||||
|
||||
if got := LoadAppConfig(stackDirOf(root, "running-app")).DesiredState; got != DesiredStateRunning {
|
||||
t.Fatalf("a deployed, RUNNING app was not backfilled: desired_state=%q", got)
|
||||
}
|
||||
if got := LoadAppConfig(stackDirOf(root, "stopped-app")).DesiredState; got != DesiredStateUnknown {
|
||||
t.Fatalf("an AMBIGUOUS app (zero containers) was given desired_state=%q — inferring intent "+
|
||||
"from a container count is the exact defect R-166 removes", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillDesiredState_NeverWritesStopped_AndNeverOverwrites(t *testing.T) {
|
||||
// Two invariants that must hold no matter what is observed:
|
||||
// 1. "stopped" is never written by the backfill, from any signal, ever.
|
||||
// 2. an EXISTING record is never overwritten — the customer's own decision outranks any
|
||||
// observation, so a stopped-but-somehow-running app keeps its recorded stop.
|
||||
m, root := newDSManager(t, "exited", "degraded", "restarting", "already-stopped")
|
||||
for _, n := range []string{"exited", "degraded", "restarting"} {
|
||||
writeAppYAML(t, stackDirOf(root, n), "deployed: true\nenv: {}\n")
|
||||
}
|
||||
writeAppYAML(t, stackDirOf(root, "already-stopped"), "deployed: true\ndesired_state: stopped\nenv: {}\n")
|
||||
|
||||
states := map[string]ContainerState{
|
||||
"exited": StateExited, "degraded": StateDegraded,
|
||||
"restarting": StateRestarting, "already-stopped": StateRunning,
|
||||
}
|
||||
for n, st := range states {
|
||||
m.stacks[n].Deployed = true
|
||||
m.stacks[n].State = st
|
||||
m.stacks[n].AppConfig = LoadAppConfig(stackDirOf(root, n))
|
||||
}
|
||||
|
||||
m.BackfillDesiredState()
|
||||
|
||||
for _, n := range []string{"exited", "degraded", "restarting"} {
|
||||
if got := LoadAppConfig(stackDirOf(root, n)).DesiredState; got != DesiredStateUnknown {
|
||||
t.Fatalf("%s (state=%s) was backfilled to %q — only a POSITIVE up-reading may seed a record",
|
||||
n, states[n], got)
|
||||
}
|
||||
}
|
||||
if got := LoadAppConfig(stackDirOf(root, "already-stopped")).DesiredState; got != DesiredStateStopped {
|
||||
t.Fatalf("the backfill OVERWROTE a customer's recorded stop with %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillDesiredState_SkipsProtectedAndUndeployed(t *testing.T) {
|
||||
m, root := newDSManager(t, "traefik", "not-deployed")
|
||||
writeAppYAML(t, stackDirOf(root, "traefik"), "deployed: true\nenv: {}\n")
|
||||
writeAppYAML(t, stackDirOf(root, "not-deployed"), "deployed: false\nenv: {}\n")
|
||||
|
||||
m.stacks["traefik"].Deployed = true
|
||||
m.stacks["traefik"].Protected = true
|
||||
m.stacks["traefik"].State = StateRunning
|
||||
m.stacks["traefik"].AppConfig = LoadAppConfig(stackDirOf(root, "traefik"))
|
||||
m.stacks["not-deployed"].Deployed = false
|
||||
m.stacks["not-deployed"].State = StateRunning
|
||||
m.stacks["not-deployed"].AppConfig = LoadAppConfig(stackDirOf(root, "not-deployed"))
|
||||
|
||||
if n := m.BackfillDesiredState(); n != 0 {
|
||||
t.Fatalf("backfilled %d, want 0 — protected stacks have their own supervision and an "+
|
||||
"undeployed stack has no intent to record", n)
|
||||
}
|
||||
if got := LoadAppConfig(stackDirOf(root, "traefik")).DesiredState; got != DesiredStateUnknown {
|
||||
t.Fatalf("a PROTECTED stack was backfilled to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- The in-memory view keeps step with the disk -------------------------------------------------
|
||||
|
||||
func TestSetDesiredState_UpdatesTheInMemoryStackToo(t *testing.T) {
|
||||
// Otherwise a reader between this write and the next ScanStacks sees a stale intent — and the
|
||||
// dashboard reads GetStacks on every render.
|
||||
m, root := newDSManager(t, "app")
|
||||
dir := stackDirOf(root, "app")
|
||||
writeAppYAML(t, dir, "deployed: true\nenv: {}\n")
|
||||
m.stacks["app"].Deployed = true
|
||||
m.stacks["app"].AppConfig = LoadAppConfig(dir)
|
||||
|
||||
if err := m.SetDesiredState("app", DesiredStateStopped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range m.GetStacks() {
|
||||
if s.Name == "app" && DesiredStateOf(s) != DesiredStateStopped {
|
||||
t.Fatalf("in-memory desired state = %q, disk says stopped", DesiredStateOf(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredStateOf_NilAppConfigIsUnknown(t *testing.T) {
|
||||
if got := DesiredStateOf(Stack{Name: "x"}); got != DesiredStateUnknown {
|
||||
t.Fatalf("a stack with no AppConfig reported desired state %q — absent must read as unknown", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user