| index 118cb3f8e..8c65f7371 100644 |
| --- a/internal/appconfig/syncexec_test.go |
| +++ b/internal/appconfig/syncexec_test.go |
| @@ -1,10 +1,14 @@ |
| package appconfig |
| |
| import ( |
| + "bytes" |
| "context" |
| + "errors" |
| + "io" |
| "os" |
| "path/filepath" |
| "slices" |
| + "strings" |
| "testing" |
| |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -26,6 +30,52 @@ func execKit(t *testing.T) (ctx context.Context, s release.Store, reg *registry. |
| return |
| } |
| |
| +// captureStdout runs fn with os.Stdout redirected to a pipe and returns |
| +// everything fn printed. SyncExec logs via fmt.Printf directly to |
| +// os.Stdout, matching every other pass in this package — this is the only |
| +// way to pin a log line's presence without restructuring every call site |
| +// to take a Writer, so it's used sparingly: only where a test's whole |
| +// point IS that a specific line got printed (a `fmt.Printf` deleted by a |
| +// future edit would otherwise go unnoticed by every other test, which |
| +// only assert on the filesystem). |
| +func captureStdout(t *testing.T, fn func()) string { |
| + t.Helper() |
| + r, w, err := os.Pipe() |
| + if err != nil { |
| + t.Fatalf("os.Pipe: %v", err) |
| + } |
| + old := os.Stdout |
| + os.Stdout = w |
| + defer func() { os.Stdout = old }() |
| + fn() |
| + if err := w.Close(); err != nil { |
| + t.Fatalf("close pipe writer: %v", err) |
| + } |
| + var buf bytes.Buffer |
| + if _, err := io.Copy(&buf, r); err != nil { |
| + t.Fatalf("read captured stdout: %v", err) |
| + } |
| + return buf.String() |
| +} |
| + |
| +// failingKeyStore wraps a release.Store and makes Get fail with a fixed |
| +// error for exactly one key — simulating a transient bucket blip against a |
| +// single app's default bundle while every other key (other apps' bundles, |
| +// including in the SAME pass) resolves normally, the shape a real S3 |
| +// SlowDown/503 against one object takes. |
| +type failingKeyStore struct { |
| + release.Store |
| + failKey string |
| + err error |
| +} |
| + |
| +func (f *failingKeyStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == f.failKey { |
| + return nil, "", f.err |
| + } |
| + return f.Store.Get(ctx, key) |
| +} |
| + |
| func TestSyncExecWritesConfigFileAndBouncesOnChange(t *testing.T) { |
| ctx, s, reg, dd, priv, _ := execKit(t) |
| pk, err := ParsePrivateKey(priv, "test") |
| @@ -554,7 +604,7 @@ func TestSyncExecRefusesNonBareHostname(t *testing.T) { |
| pk, _ := ParsePrivateKey(priv, "test") |
| m := &Manifest{Schema: 1, App: "eleven", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}} |
| for _, host := range []string{"../escape", "a/b", "", ".", ".."} { |
| - syncExecHost(registry.Route{Host: host}, m, "apps/bab/eleven/config.json", pk, dd, func(string) error { return nil }) |
| + syncExecHost(registry.Route{Host: host}, m, "apps/bab/eleven/config.json", pk, dd, func(string) error { return nil }, "") |
| } |
| ents, err := os.ReadDir(dd) |
| if err != nil { |
| @@ -596,8 +646,9 @@ func TestSyncExecOptedInAddrRouteMaterializesFile(t *testing.T) { |
| if err != nil { |
| t.Fatalf("opted-in TCP-addr route got no config file: %v", err) |
| } |
| - if string(got) != "TITOGO_DEMO_BANNER=1\n" { |
| - t.Fatalf("config env = %q, want TITOGO_DEMO_BANNER=1", got) |
| + want := execAddrConfigHeader + "TITOGO_DEMO_BANNER=1\n" |
| + if string(got) != want { |
| + t.Fatalf("config env = %q, want %q (issue #18's required header, then the vars)", got, want) |
| } |
| } |
| |
| @@ -663,8 +714,12 @@ func TestSyncExecUnrecognizedExecDeliveryValueIgnoredNotFatal(t *testing.T) { |
| t.Fatalf("Upsert %s: %v", rt.Host, err) |
| } |
| } |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil { |
| - t.Fatalf("SyncExec: %v", err) |
| + var syncErr error |
| + out := captureStdout(t, func() { |
| + syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }) |
| + }) |
| + if syncErr != nil { |
| + t.Fatalf("SyncExec: %v", syncErr) |
| } |
| if _, err := os.Stat(filepath.Join(dd, "demo.go.tito.io.config.env")); !os.IsNotExist(err) { |
| t.Fatalf("unrecognized exec_delivery value was treated as opted-in, stat err = %v", err) |
| @@ -672,6 +727,84 @@ func TestSyncExecUnrecognizedExecDeliveryValueIgnoredNotFatal(t *testing.T) { |
| if _, err := os.Stat(filepath.Join(dd, "inst.example.com.config.env")); err != nil { |
| t.Fatalf("eleven's ordinary socket route did not converge despite titogo's unrecognized value: %v", err) |
| } |
| + // MINOR 5 (2026-08-03 review): the whole safety story for this case is |
| + // "ignored LOUDLY" — pin the loudness itself, not just the tolerance, |
| + // so a future edit that quietly dropped the fmt.Printf would fail a |
| + // test instead of only being noticed by someone reading a log by hand. |
| + if !strings.Contains(out, "titogo") || !strings.Contains(out, "grpc") { |
| + t.Fatalf("stdout = %q, want a line naming the app (titogo) and the unrecognized value (grpc)", out) |
| + } |
| +} |
| + |
| +// TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs is |
| +// IMPORTANT 1 from the 2026-08-03 review: a transient default-bundle read |
| +// failure against an ALREADY-opted-in app must not go silent. Before this |
| +// fix, checkOptedIn folded def.err != nil into the same "not opted in, |
| +// say nothing" branch as a genuinely never-configured app — so an opted-in |
| +// app whose bucket blips became indistinguishable, in the log, from an |
| +// app that was never opted in at all, on the exact path this feature |
| +// exists to make observable. The safety property (the file survives |
| +// byte-identical) was already correct; only the silence was the bug. |
| +func TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs(t *testing.T) { |
| + ctx, s, reg, dd, priv, _ := execKit(t) |
| + pk, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"TITOGO_DEMO_BANNER": "1"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", ExecDeliveryFile); err != nil { |
| + t.Fatalf("SetExecDelivery: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "demo.go.tito.io", Kind: "instance", App: "titogo", Account: "bab", |
| + Status: "ready", Addr: "127.0.0.1:8444", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + // First pass materializes normally — this is the file a transient |
| + // blip must leave exactly as it is. |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil { |
| + t.Fatalf("SyncExec (initial): %v", err) |
| + } |
| + path := filepath.Join(dd, "demo.go.tito.io.config.env") |
| + original, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read initial config env: %v", err) |
| + } |
| + |
| + blipErr := errors.New("s3: transient 503 SlowDown") |
| + failing := &failingKeyStore{Store: s, failKey: Key("bab", "titogo"), err: blipErr} |
| + var bounced []string |
| + bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| + var syncErr error |
| + out := captureStdout(t, func() { |
| + syncErr = SyncExec(ctx, reg, failing, pk, dd, bounce) |
| + }) |
| + if syncErr != nil { |
| + t.Fatalf("SyncExec (blip): %v", syncErr) |
| + } |
| + got, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read config env after the blip: %v", err) |
| + } |
| + if string(got) != string(original) { |
| + t.Fatalf("config env changed across a transient fetch failure: got %q, want the original %q untouched", got, original) |
| + } |
| + if len(bounced) != 0 { |
| + t.Fatalf("bounced = %v, want none — a fetch failure is not a change", bounced) |
| + } |
| + // The fix: this must no longer be silent. Name the app and the |
| + // underlying error, the same "unresolved ... left as-is" contract the |
| + // socket path's resolution loop already gives an ordinary fetch |
| + // failure. |
| + if !strings.Contains(out, "titogo") { |
| + t.Fatalf("stdout = %q, want a line naming the app (titogo)", out) |
| + } |
| + if !strings.Contains(out, "SlowDown") { |
| + t.Fatalf("stdout = %q, want the underlying fetch error surfaced", out) |
| + } |
| } |
| |
| // TestSyncExecOptedInAddrRouteIsNeverBounced is the structural pin the |
| @@ -726,8 +859,9 @@ func TestSyncExecOptedInAddrRouteIsNeverBounced(t *testing.T) { |
| if err != nil { |
| t.Fatalf("read config env: %v", err) |
| } |
| - if string(got) != "TITOGO_DEMO_BANNER=2\n" { |
| - t.Fatalf("config env = %q, want the changed content (TITOGO_DEMO_BANNER=2) — the write path must still run", got) |
| + want := execAddrConfigHeader + "TITOGO_DEMO_BANNER=2\n" |
| + if string(got) != want { |
| + t.Fatalf("config env = %q, want %q — the changed content, header included — the write path must still run", got, want) |
| } |
| } |
| |
| @@ -817,7 +951,7 @@ func TestSyncExecOptedInAddrRouteHonorsBoundEnvironment(t *testing.T) { |
| if err != nil { |
| t.Fatalf("read config env: %v", err) |
| } |
| - want := "BASE_ONLY=1\nDEMO_ONLY=yes\nLOG_LEVEL=debug\n" |
| + want := execAddrConfigHeader + "BASE_ONLY=1\nDEMO_ONLY=yes\nLOG_LEVEL=debug\n" |
| if string(got) != want { |
| t.Fatalf("config env = %q, want %q", got, want) |
| } |
| @@ -854,10 +988,13 @@ func TestSyncExecOptedInAddrAppWithNoVarsConvergesToEmptyFile(t *testing.T) { |
| } |
| got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| if err != nil { |
| - t.Fatalf("opted-in-but-unconfigured app's route got no file at all (want an empty one, not none): %v", err) |
| + t.Fatalf("opted-in-but-unconfigured app's route got no file at all (want the header alone, not none): %v", err) |
| } |
| - if string(got) != "" { |
| - t.Fatalf("config env = %q, want empty", got) |
| + // No vars, but never a truly empty file: the header (issue #18) is |
| + // unconditional for this shape, whether or not the app has any vars |
| + // set yet. |
| + if string(got) != execAddrConfigHeader { |
| + t.Fatalf("config env = %q, want just the header (%q)", got, execAddrConfigHeader) |
| } |
| } |
| |
| @@ -908,8 +1045,9 @@ func TestSyncExecMassRemovalGuardUnaffectedByOptedInAddrAppWithContent(t *testin |
| if err != nil { |
| t.Fatalf("titogo's config file wasn't written: %v", err) |
| } |
| - if string(got) != "A=1\n" { |
| - t.Fatalf("titogo config env = %q, want A=1", got) |
| + want := execAddrConfigHeader + "A=1\n" |
| + if string(got) != want { |
| + t.Fatalf("titogo config env = %q, want %q", got, want) |
| } |
| if _, err := os.Stat(pathEleven); !os.IsNotExist(err) { |
| t.Fatalf("eleven's config file survived its manifest's genuine absence, stat err = %v (guard incorrectly disarmed convergence, or incorrectly held it back)", err) |
| @@ -928,6 +1066,178 @@ func TestSyncExecMassRemovalGuardUnaffectedByOptedInAddrAppWithContent(t *testin |
| } |
| } |
| |
| +// TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass is IMPORTANT 2 from |
| +// the 2026-08-03 review's ruling: turning exec_delivery off does NOT |
| +// delete an already-materialized <host>.config.env (deleting it would |
| +// change what the app's own supervisor layers in on its next respawn, at |
| +// a moment carlos does not control and cannot announce — worse than a |
| +// stale file, and consistent with every other ineligibility transition in |
| +// this file). But the file holds DECRYPTED secrets and nothing will ever |
| +// update it again once opted out, so the silence itself was the bug: |
| +// every pass must name the stale file and the manual remedy. |
| +func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) { |
| + ctx, s, reg, dd, priv, _ := execKit(t) |
| + pk, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"TITOGO_DEMO_BANNER": "1"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", ExecDeliveryFile); err != nil { |
| + t.Fatalf("SetExecDelivery: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "demo.go.tito.io", Kind: "instance", App: "titogo", Account: "bab", |
| + Status: "ready", Addr: "127.0.0.1:8444", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil { |
| + t.Fatalf("SyncExec (opted in): %v", err) |
| + } |
| + path := filepath.Join(dd, "demo.go.tito.io.config.env") |
| + staged, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read staged config env: %v", err) |
| + } |
| + |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", ""); err != nil { |
| + t.Fatalf("SetExecDelivery (off): %v", err) |
| + } |
| + var bounced []string |
| + bounce := func(h string) error { bounced = append(bounced, h); return nil } |
| + var syncErr error |
| + out := captureStdout(t, func() { |
| + syncErr = SyncExec(ctx, reg, s, pk, dd, bounce) |
| + }) |
| + if syncErr != nil { |
| + t.Fatalf("SyncExec (opted out): %v", syncErr) |
| + } |
| + got, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("stale config env was deleted on opt-out, want it left in place: %v", err) |
| + } |
| + if string(got) != string(staged) { |
| + t.Fatalf("stale config env changed on opt-out: got %q, want the untouched original %q", got, staged) |
| + } |
| + if len(bounced) != 0 { |
| + t.Fatalf("bounced = %v, want none", bounced) |
| + } |
| + if !strings.Contains(out, "demo.go.tito.io") || !strings.Contains(out, path) { |
| + t.Fatalf("stdout = %q, want a line naming the host and the stale file's path", out) |
| + } |
| + if !strings.Contains(strings.ToLower(out), "secret") { |
| + t.Fatalf("stdout = %q, want the warning to say the file holds secrets", out) |
| + } |
| + |
| + // The warning is PER PASS, not one-shot: a second pass with nothing |
| + // changed must repeat it, since nothing about the risk (stale |
| + // plaintext secrets, forever) has gone away. |
| + out2 := captureStdout(t, func() { |
| + syncErr = SyncExec(ctx, reg, s, pk, dd, bounce) |
| + }) |
| + if syncErr != nil { |
| + t.Fatalf("SyncExec (opted out, second pass): %v", syncErr) |
| + } |
| + if !strings.Contains(out2, "demo.go.tito.io") { |
| + t.Fatalf("second pass stdout = %q, want the warning repeated", out2) |
| + } |
| + if len(bounced) != 0 { |
| + t.Fatalf("bounced = %v after the second pass, want none", bounced) |
| + } |
| +} |
| + |
| +// TestSyncExecNoOptOutWarningWhenNeverOptedIn: the stale-file warning |
| +// fires only when a file actually exists — an app that was simply NEVER |
| +// opted in must stay exactly as quiet as it was before issue #18 existed, |
| +// not gain a new per-pass line for a file it never had. |
| +func TestSyncExecNoOptOutWarningWhenNeverOptedIn(t *testing.T) { |
| + ctx, s, reg, dd, priv, _ := execKit(t) |
| + pk, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"K": "1"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + // No SetExecDelivery call at all. |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "demo.go.tito.io", Kind: "instance", App: "titogo", Account: "bab", |
| + Status: "ready", Addr: "127.0.0.1:8444", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + var syncErr error |
| + out := captureStdout(t, func() { |
| + syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }) |
| + }) |
| + if syncErr != nil { |
| + t.Fatalf("SyncExec: %v", syncErr) |
| + } |
| + if strings.Contains(out, "demo.go.tito.io") { |
| + t.Fatalf("stdout = %q, want no mention of a route that never had a file", out) |
| + } |
| +} |
| + |
| +// TestSyncExecAddrFileCarriesHeaderSocketFileDoesNot is IMPORTANT 3 from |
| +// the 2026-08-03 review: the issue #18 design comment's contract line — |
| +// "the file's header comment says so" — pinned directly. The addr shape's |
| +// file must carry it; the socket shape's file (same function, same pass) |
| +// must NOT, so an existing fleet's socket-exec files never churn or bounce |
| +// on upgrade (see execAddrConfigHeader's doc comment for the full reason). |
| +func TestSyncExecAddrFileCarriesHeaderSocketFileDoesNot(t *testing.T) { |
| + ctx, s, reg, dd, priv, _ := execKit(t) |
| + pk, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"A": "1"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", ExecDeliveryFile); err != nil { |
| + t.Fatalf("SetExecDelivery: %v", err) |
| + } |
| + if err := SetEnv(ctx, s, "bab", "eleven", map[string]string{"B": "1"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + routes := []registry.Route{ |
| + {Host: "demo.go.tito.io", Kind: "instance", App: "titogo", Account: "bab", Status: "ready", Addr: "127.0.0.1:8444"}, |
| + {Host: "inst.example.com", Kind: "instance", App: "eleven", Account: "bab", Status: "ready", Socket: "/run/carlos/inst.sock"}, |
| + } |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("Upsert %s: %v", rt.Host, err) |
| + } |
| + } |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + addrGot, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| + if err != nil { |
| + t.Fatalf("read addr config env: %v", err) |
| + } |
| + if !strings.HasPrefix(string(addrGot), "#") { |
| + t.Fatalf("addr config env = %q, want it to start with a header comment", addrGot) |
| + } |
| + if !strings.Contains(string(addrGot), "carlos") { |
| + t.Fatalf("addr config env = %q, want the header to say carlos wrote it", addrGot) |
| + } |
| + if !strings.Contains(strings.ToLower(string(addrGot)), "never") || !strings.Contains(strings.ToLower(string(addrGot)), "restart") { |
| + t.Fatalf("addr config env = %q, want the header to say carlos never restarts this process", addrGot) |
| + } |
| + if !strings.Contains(strings.ToLower(string(addrGot)), "supervisor") { |
| + t.Fatalf("addr config env = %q, want the header to say the app's supervisor owns restarts", addrGot) |
| + } |
| + socketGot, err := os.ReadFile(filepath.Join(dd, "inst.example.com.config.env")) |
| + if err != nil { |
| + t.Fatalf("read socket config env: %v", err) |
| + } |
| + if want := "B=1\n"; string(socketGot) != want { |
| + t.Fatalf("socket config env = %q, want exactly %q — no header on this shape, ever", socketGot, want) |
| + } |
| +} |
| + |
| // TestSyncExecConvergesToEmptyFileWhenAllVarsUnset covers the lifecycle |
| // F8 flagged as missing: a manifest object that still EXISTS but has had |
| // every var unset must converge its route to an EMPTY file, not have the |