| index 1c863c076..118cb3f8e 100644 |
| --- a/internal/appconfig/syncexec_test.go |
| +++ b/internal/appconfig/syncexec_test.go |
| @@ -479,6 +479,61 @@ func TestSyncExecReapsOnlyItsOwnNamespace(t *testing.T) { |
| } |
| } |
| |
| +// TestSyncExecReapsOnlyItsOwnNamespaceWithAddrShapeInTheMix is the reap |
| +// test's issue #18 twin: an opted-in addr route's own file survives reap |
| +// (it has a route row, same as any in-scope route), and a stray |
| +// *.config.env with NO route row at all is reaped regardless of which |
| +// exec shape produced it in some earlier pass — reap keys purely on |
| +// (namespace suffix, route existence), never on shape or opt-in. |
| +func TestSyncExecReapsOnlyItsOwnNamespaceWithAddrShapeInTheMix(t *testing.T) { |
| + ctx, s, reg, dd, priv, _ := execKit(t) |
| + pk, _ := ParsePrivateKey(priv, "test") |
| + if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"X": "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: "live.go.tito.io", Kind: "instance", App: "titogo", Account: "bab", |
| + Status: "ready", Addr: "127.0.0.1:8444", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + files := map[string]bool{ // name -> should survive |
| + "live.go.tito.io.config.env": true, // opted-in addr route, has a route row |
| + "gone.go.tito.io.config.env": false, // no route row at all: reaped, whatever shape wrote it |
| + } |
| + for name := range files { |
| + if err := os.WriteFile(filepath.Join(dd, name), []byte("X=1\n"), 0o600); err != nil { |
| + t.Fatalf("stage %s: %v", name, err) |
| + } |
| + } |
| + var bounced []string |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| + bounced = append(bounced, h) |
| + return nil |
| + }); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + for name, keep := range files { |
| + _, err := os.Stat(filepath.Join(dd, name)) |
| + if keep && err != nil { |
| + t.Fatalf("%s: want kept, got %v", name, err) |
| + } |
| + if !keep && !os.IsNotExist(err) { |
| + t.Fatalf("%s: want reaped, stat err = %v", name, err) |
| + } |
| + } |
| + // The reap pass itself must never bounce — reap's log line says "no |
| + // route on this box", never "bounce"; and the addr route's own |
| + // convergence (a fresh, unchanged-content write above) must not |
| + // bounce either. |
| + if len(bounced) != 0 { |
| + t.Fatalf("bounced = %v, want none", bounced) |
| + } |
| +} |
| + |
| func TestSyncExecEmptyRegistryRemovesNothing(t *testing.T) { |
| ctx, s, reg, dd, priv, _ := execKit(t) |
| pk, _ := ParsePrivateKey(priv, "test") |
| @@ -510,6 +565,369 @@ func TestSyncExecRefusesNonBareHostname(t *testing.T) { |
| } |
| } |
| |
| +// TestSyncExecOptedInAddrRouteMaterializesFile is issue #18's core case: |
| +// a TCP-addr route (Socket == "", Addr != "") whose app opted in via |
| +// ExecDeliveryFile in its default bundle gets the identical |
| +// <host>.config.env a socket-exec route would — same rendering, same |
| +// layering guarantees — even though carlos neither spawned nor owns the |
| +// process behind it. |
| +func TestSyncExecOptedInAddrRouteMaterializesFile(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: %v", err) |
| + } |
| + got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| + 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) |
| + } |
| +} |
| + |
| +// TestSyncExecUnoptedAddrRouteIsSkipped pins the un-opted case: the exact |
| +// same TCP-addr route shape, but its app never set exec_delivery, is |
| +// skipped exactly as before issue #18's third case existed — no file, no |
| +// error, nothing to converge or remove. |
| +func TestSyncExecUnoptedAddrRouteIsSkipped(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) |
| + } |
| + // Deliberately no SetExecDelivery call. |
| + 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: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(dd, "demo.go.tito.io.config.env")); !os.IsNotExist(err) { |
| + t.Fatalf("un-opted TCP-addr route got a config file, stat err = %v", err) |
| + } |
| +} |
| + |
| +// TestSyncExecUnrecognizedExecDeliveryValueIgnoredNotFatal covers additive |
| +// discipline (CLAUDE.md): a manifest a NEWER carlos wrote, carrying an |
| +// exec_delivery value this binary doesn't understand, must not brick this |
| +// box's sync — the whole pass must still succeed, the unknown value must |
| +// be treated as "not opted in" (not a fatal error, not silently treated |
| +// as "file"), and OTHER apps' routes in the same pass must converge |
| +// normally. |
| +func TestSyncExecUnrecognizedExecDeliveryValueIgnoredNotFatal(t *testing.T) { |
| + ctx, s, reg, dd, priv, _ := execKit(t) |
| + pk, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + // Written directly, bypassing SetExecDelivery's set-time refusal — the |
| + // shape of a manifest a newer binary produced. |
| + raw := []byte(`{"schema":1,"app":"titogo","vars":{"K":{"kind":"env","value":"1"}},"exec_delivery":"grpc"}`) |
| + if _, err := s.Put(ctx, Key("bab", "titogo"), raw, ""); err != nil { |
| + t.Fatalf("Put: %v", err) |
| + } |
| + // A second, ordinary socket-exec app in the same pass, to prove the |
| + // unknown value doesn't abort or otherwise disturb the rest of the |
| + // sweep. |
| + if err := SetEnv(ctx, s, "bab", "eleven", map[string]string{"LOG_LEVEL": "debug"}); 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) |
| + } |
| + 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) |
| + } |
| + 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) |
| + } |
| +} |
| + |
| +// TestSyncExecOptedInAddrRouteIsNeverBounced is the structural pin the |
| +// issue's design comment demands: carlos does not own an app-supervised |
| +// process, so an opted-in TCP-addr route's bounce callback must not even |
| +// be invoked — not "invoked and then skipped by a flag", genuinely never |
| +// called. The recorder below would append on any call at all; it must |
| +// stay empty across a pass that DOES change the route's file content |
| +// (proving the file write path ran) and a second pass where content is |
| +// unchanged (proving the still-quiet path also never bounces). |
| +func TestSyncExecOptedInAddrRouteIsNeverBounced(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) |
| + } |
| + var bounced []string |
| + bounce := func(h string) error { |
| + bounced = append(bounced, h) |
| + return nil |
| + } |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil { |
| + t.Fatalf("SyncExec (initial write): %v", err) |
| + } |
| + if len(bounced) != 0 { |
| + t.Fatalf("bounced = %v, want none — carlos does not own this process", bounced) |
| + } |
| + // Change the config so the file's content changes on the next pass — |
| + // the socket-exec equivalent of this WOULD bounce. |
| + if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"TITOGO_DEMO_BANNER": "2"}); err != nil { |
| + t.Fatalf("SetEnv (change): %v", err) |
| + } |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil { |
| + t.Fatalf("SyncExec (changed content): %v", err) |
| + } |
| + if len(bounced) != 0 { |
| + t.Fatalf("bounced = %v after a content change, want none", bounced) |
| + } |
| + got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| + 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) |
| + } |
| +} |
| + |
| +// TestSyncExecSocketRouteStillBouncesAlongsideOptedInAddrRoute proves the |
| +// addr shape's no-bounce rule is scoped to addr routes ONLY: a socket-exec |
| +// route for a DIFFERENT app in the same pass must still bounce on change, |
| +// unaffected by the addr route sharing the sweep. |
| +func TestSyncExecSocketRouteStillBouncesAlongsideOptedInAddrRoute(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) |
| + } |
| + } |
| + var bounced []string |
| + bounce := func(h string) error { |
| + bounced = append(bounced, h) |
| + return nil |
| + } |
| + if err := SyncExec(ctx, reg, s, pk, dd, bounce); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + if !slices.Contains(bounced, "inst.example.com") { |
| + t.Fatalf("bounced = %v, want the socket-exec route bounced (unaffected by the addr route sharing the pass)", bounced) |
| + } |
| + if slices.Contains(bounced, "demo.go.tito.io") { |
| + t.Fatalf("bounced = %v, the opted-in addr route must never be bounced", bounced) |
| + } |
| + if len(bounced) != 1 { |
| + t.Fatalf("bounced = %v, want exactly one bounce", bounced) |
| + } |
| +} |
| + |
| +// TestSyncExecOptedInAddrRouteHonorsBoundEnvironment: environments- |
| +// awareness applies identically to the addr shape — a route bound to a |
| +// named environment resolves via LoadMerged (default overlaid by the |
| +// environment, environment winning per key), exactly like a socket-exec |
| +// route bound the same way. |
| +func TestSyncExecOptedInAddrRouteHonorsBoundEnvironment(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{ |
| + "LOG_LEVEL": "info", |
| + "BASE_ONLY": "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 := SaveEnvironment(ctx, s, "bab", "titogo", "demo", map[string]Var{ |
| + "LOG_LEVEL": {Kind: "env", Value: "debug"}, |
| + "DEMO_ONLY": {Kind: "env", Value: "yes"}, |
| + }, nil); err != nil { |
| + t.Fatalf("SaveEnvironment: %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", Environment: "demo", |
| + }); 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: %v", err) |
| + } |
| + got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| + if err != nil { |
| + t.Fatalf("read config env: %v", err) |
| + } |
| + want := "BASE_ONLY=1\nDEMO_ONLY=yes\nLOG_LEVEL=debug\n" |
| + if string(got) != want { |
| + t.Fatalf("config env = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// TestSyncExecOptedInAddrAppWithNoVarsConvergesToEmptyFile: opting in |
| +// (SetExecDelivery alone, no SetEnv) creates the default bundle with an |
| +// empty Vars map — found=true, so this is the "configured, then nothing |
| +// set" state, not "absent". The route must get an EMPTY file, not have |
| +// none written at all and not have the mass-removal guard's absent |
| +// bucket confused by it. This is also the reason |
| +// "opted-in AND the group resolves absent" is unreachable by |
| +// construction: checkOptedIn requires the default bundle to exist |
| +// (found=true) before an addr route is ever added to resolved, and a |
| +// present-but-empty bundle is exactly resolution{m: <empty manifest>}, |
| +// never resolution{} (nil, absent). |
| +func TestSyncExecOptedInAddrAppWithNoVarsConvergesToEmptyFile(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 := 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: %v", err) |
| + } |
| + 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) |
| + } |
| + if string(got) != "" { |
| + t.Fatalf("config env = %q, want empty", got) |
| + } |
| +} |
| + |
| +// TestSyncExecMassRemovalGuardUnaffectedByOptedInAddrAppWithContent |
| +// exercises the mass-removal guard sharing its EXACT code path with the |
| +// addr shape (no addr-specific carve-out): an opted-in addr app WITH |
| +// content sits in the pass alongside a socket app that is genuinely |
| +// never configured. Only the socket app's group is absent — the guard |
| +// must not fire (only one of two eligible groups is absent, same as |
| +// TestSyncExecMassRemovalGuardDoesNotBlockNormalConvergence pins for two |
| +// socket apps), so the socket app's stale file is removed normally while |
| +// the addr app's file converges with its content, unaffected by sharing |
| +// the pass. |
| +func TestSyncExecMassRemovalGuardUnaffectedByOptedInAddrAppWithContent(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) |
| + } |
| + // eleven is never configured at all: genuinely absent. |
| + routes := []registry.Route{ |
| + {Host: "demo.go.tito.io", Kind: "instance", App: "titogo", Account: "bab", Status: "ready", Addr: "127.0.0.1:8444"}, |
| + {Host: "b.example.com", Kind: "instance", App: "eleven", Account: "bab", Status: "ready", Socket: "/run/carlos/b.sock"}, |
| + } |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("Upsert %s: %v", rt.Host, err) |
| + } |
| + } |
| + pathEleven := filepath.Join(dd, "b.example.com.config.env") |
| + if err := os.WriteFile(pathEleven, []byte("OLD=2\n"), 0o600); err != nil { |
| + t.Fatalf("stage eleven: %v", err) |
| + } |
| + var bounced []string |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error { |
| + bounced = append(bounced, h) |
| + return nil |
| + }); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env")) |
| + 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) |
| + } |
| + 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) |
| + } |
| + // eleven's file is a removal (bounceLogged fires); titogo's is a |
| + // fresh write, socket-shape rules would bounce it too, but titogo is |
| + // the addr shape — it must never appear here. |
| + if !slices.Contains(bounced, "b.example.com") { |
| + t.Fatalf("bounced = %v, want eleven's removal bounce", bounced) |
| + } |
| + if slices.Contains(bounced, "demo.go.tito.io") { |
| + t.Fatalf("bounced = %v, the opted-in addr route must never be bounced", bounced) |
| + } |
| + if len(bounced) != 1 { |
| + t.Fatalf("bounced = %v, want exactly one bounce", bounced) |
| + } |
| +} |
| + |
| // 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 |