appconfig: socket-exec acks carry the bounce result; status never claims adoption on empty identities (review fix)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+297
−45
internal/appconfig/configack_test.go+49 −0internal/appconfig/syncexec.go+41 −17internal/appconfig/syncexec_test.go+94 −0internal/console/apirelay.go+33 −9internal/console/apirelay_test.go+62 −14internal/console/relayops.go+18 −5
diff --git a/internal/appconfig/configack_test.go b/internal/appconfig/configack_test.go| index cdc681ad3..9a8a419b5 100644 |
| --- a/internal/appconfig/configack_test.go |
| +++ b/internal/appconfig/configack_test.go |
| @@ -178,3 +178,52 @@ func TestSyncAckWithoutRelayHasNilSuffixes(t *testing.T) { |
| t.Fatalf("ack.RelaySuffixes = %+v, want nil (no CARLOS_RELAY entry)", acks[0].RelaySuffixes) |
| } |
| } |
| + |
| +// TestSyncAckHibernatingRouteReportsNoRestart is spec §5's "hibernating, |
| +// delivered on wake" case: try-restart on an inactive unit exits 0 having |
| +// restarted nothing (sync.go's own long-standing comment on the restart |
| +// call), so a hibernating route's ack must not read as an ordinary |
| +// Restarted:true adoption — it has to say the config landed but nothing |
| +// woke to run it. |
| +func TestSyncAckHibernatingRouteReportsNoRestart(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := testRegistry(t) |
| + envDir := t.TempDir() |
| + |
| + priv, pub := syncRelayTestKeys(t) |
| + rec := &relay.Record{Enabled: true, Incarnation: "inc00005", Regions: []string{"eu-west-1"}} |
| + masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}} |
| + sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters) |
| + |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready", |
| + Backing: registry.BackingUnit, Hibernate: true, |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + |
| + var acks []ConfigAck |
| + ack := func(a ConfigAck) { acks = append(acks, a) } |
| + // try-restart on a hibernating (inactive) unit exits 0 having done |
| + // nothing — the no-op restart callers everywhere in this package rely |
| + // on returning nil for. |
| + restart := func(host string) error { return nil } |
| + if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + |
| + if len(acks) != 1 { |
| + t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) |
| + } |
| + got := acks[0] |
| + if !got.Hibernating { |
| + t.Fatalf("ack.Hibernating = false, want true") |
| + } |
| + if got.Restarted { |
| + t.Fatalf("ack.Restarted = true, want false — the route was asleep, nothing actually restarted") |
| + } |
| + if got.RestartError != "" { |
| + t.Fatalf("ack.RestartError = %q, want empty", got.RestartError) |
| + } |
| +} |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index 32eca0b6d..be90b85d9 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -63,10 +63,12 @@ const configEnvSuffix = ".config.env" |
| // environments resolve to different manifests, and keying on (acct, app) |
| // alone would render whichever route was seen first into both files. |
| // |
| -// ack, if non-nil, is called once per route this pass actually converges |
| -// (wrote a changed or removed file) with that route's config-adoption |
| -// acknowledgement (spec §5, configack.go) — nil means no acks, same |
| -// contract as Sync's. |
| +// ack, if non-nil, is called once per route this pass actually WRITES a |
| +// changed file for, with that route's config-adoption acknowledgement |
| +// (spec §5, configack.go) — nil means no acks, same contract as Sync's. A |
| +// route whose file is REMOVED (manifest absent) gets no ack: there is |
| +// nothing for a route to have adopted once its config is gone, and a |
| +// removal is not one of spec §5's three delivery shapes. |
| func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, appsDomain string, ack func(ConfigAck)) error { |
| routes, err := reg.List() |
| if err != nil { |
| @@ -851,13 +853,14 @@ const execAddrConfigHeader = `# Written by carlos (SyncExec, issue #18). Carlos |
| // themselves, so a header-only change (e.g. its text edited in a future |
| // release) converges exactly like a var change would, once. |
| // ack, if non-nil, is called once this route's file has actually changed |
| -// (written or removed) with its config-adoption acknowledgement (spec §5). |
| -// Restarted is always false and RestartError always empty here — unlike |
| -// syncHost's unit path, this shape's bounce is best-effort and |
| -// fire-and-forget (bounceLogged swallows its error), and a tcp-exec-file |
| -// route's process belongs to an external supervisor this platform never |
| -// restarts at all; that shape's cutover is a member acknowledgement |
| -// (`carlos relay adopted`) instead. |
| +// (written, never on removal — see SyncExec's own doc comment) with its |
| +// config-adoption acknowledgement (spec §5). Restarted/RestartError come |
| +// from bounceLogged's own outcome when bounce is non-nil (the socket-exec |
| +// shape: spec §5 step 5 counts that bounce as the cutover, "since the |
| +// platform restarted it") and are always false/empty when bounce is nil |
| +// (the tcp-exec-file shape: its process belongs to an external supervisor |
| +// this platform never restarts at all, so that shape's cutover is a |
| +// member acknowledgement, `carlos relay adopted`, instead). |
| func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header string, ack func(ConfigAck)) { |
| if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) { |
| fmt.Printf(" %q: not a bare hostname, refusing to derive a config file path from it\n", rt.Host) |
| @@ -923,7 +926,7 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private |
| return |
| } |
| fmt.Printf(" %s: config updated\n", rt.Host) |
| - bounceLogged(rt.Host, bounce) |
| + bounceErr := bounceLogged(rt.Host, bounce) |
| if ack != nil { |
| a := ConfigAck{Host: rt.Host, Shape: shapeOf(rt), At: time.Now()} |
| if suffixes, err := relaySuffixesFromRelayVar(m, priv); err != nil { |
| @@ -931,20 +934,41 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private |
| } else { |
| a.RelaySuffixes = suffixes |
| } |
| + // bounce == nil (the tcp-exec-file/addr shape) leaves Restarted |
| + // false and RestartError empty — see this function's own doc |
| + // comment for why. bounce != nil is the socket-exec shape, and its |
| + // bounce IS this pass's restart (spec §5 step 5: a socket-exec |
| + // route's cutover is the ack "since the platform restarted it"), |
| + // so the ack has to carry what bounceLogged actually returned |
| + // rather than assume success the way the removal branch above may. |
| + if bounce != nil { |
| + if bounceErr != nil { |
| + a.RestartError = bounceErr.Error() |
| + } else { |
| + a.Restarted = true |
| + } |
| + } |
| ack(a) |
| } |
| } |
| -// bounceLogged: bounce is best-effort — a failure is logged, never |
| -// propagated; the file on disk is already correct and the next natural |
| -// wake picks it up. |
| -func bounceLogged(host string, bounce func(host string) error) { |
| +// bounceLogged calls bounce — best-effort in the sense that a failure is |
| +// logged rather than aborting the pass, the file on disk is already |
| +// correct and the next natural wake picks it up — and returns what bounce |
| +// itself returned, so a caller that also writes a config-ack (syncExecHost |
| +// above) can report whether THIS pass's restart actually succeeded rather |
| +// than silently discarding that fact. bounce == nil (the tcp-exec-file/ |
| +// addr shape, whose process this platform does not own) is a no-op |
| +// returning nil. |
| +func bounceLogged(host string, bounce func(host string) error) error { |
| if bounce == nil { |
| - return |
| + return nil |
| } |
| if err := bounce(host); err != nil { |
| fmt.Printf(" %s: bounce after config change: %v\n", host, err) |
| + return err |
| } |
| + return nil |
| } |
| // reapConfigEnv deletes *.config.env (and stale *.config.env.tmp) for |
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go| index e97709589..ae4fa0414 100644 |
| --- a/internal/appconfig/syncexec_test.go |
| +++ b/internal/appconfig/syncexec_test.go |
| @@ -13,6 +13,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/relay" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -1896,3 +1897,96 @@ func TestSyncExecHoldsConfigWhenHostNamesADifferentAccount(t *testing.T) { |
| t.Fatalf("agreeing host's stale file survived, stat err = %v", err) |
| } |
| } |
| + |
| +// TestSyncExecWritesConfigAckForSocketExecRoute is the review's finding 1 |
| +// (2026-09-07): syncExecHost DOES bounce a socket-exec route (bounce is |
| +// non-nil for it, only a tcp-exec-file/addr route gets nil), so its ack |
| +// must carry that bounce's own result — this pins Restarted true and |
| +// RelaySuffixes naming what was actually sealed, the same way syncHost's |
| +// unit-path ack does. |
| +func TestSyncExecWritesConfigAckForSocketExecRoute(t *testing.T) { |
| + ctx, s, reg, dd, _, _ := execKit(t) |
| + priv, pub := syncRelayTestKeys(t) |
| + |
| + rec := &relay.Record{Enabled: true, Incarnation: "inc00003", Regions: []string{"eu-west-1"}} |
| + masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}} |
| + wantSuffixes := sealRelayConfig(t, ctx, s, pub, "bab", "eleven", rec, masters) |
| + |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "inst.example.com", Kind: "instance", App: "eleven", Account: "bab", |
| + Status: "ready", Socket: "/run/carlos/inst.sock", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + |
| + var acks []ConfigAck |
| + ack := func(a ConfigAck) { acks = append(acks, a) } |
| + bounce := func(h string) error { return nil } |
| + if err := SyncExec(ctx, reg, s, priv, dd, bounce, "", ack); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + |
| + if len(acks) != 1 { |
| + t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) |
| + } |
| + got := acks[0] |
| + if got.Shape != "socket-exec" { |
| + t.Fatalf("ack.Shape = %q, want socket-exec", got.Shape) |
| + } |
| + if !got.Restarted { |
| + t.Fatalf("ack.Restarted = false, want true — bounce succeeded and IS this shape's restart (spec §5 step 5)") |
| + } |
| + if got.RestartError != "" { |
| + t.Fatalf("ack.RestartError = %q, want empty", got.RestartError) |
| + } |
| + if got.RelaySuffixes["eu-west-1"] != wantSuffixes["eu-west-1"] { |
| + t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want %q", got.RelaySuffixes["eu-west-1"], wantSuffixes["eu-west-1"]) |
| + } |
| +} |
| + |
| +// TestSyncExecWritesConfigAckForTCPExecFileRoute is the addr shape's other |
| +// half of the same ack path: bounce is nil for it (this platform never |
| +// restarts a process it does not own), so Restarted/RestartError must stay |
| +// false/empty even though the file itself landed and named real |
| +// identities. |
| +func TestSyncExecWritesConfigAckForTCPExecFileRoute(t *testing.T) { |
| + ctx, s, reg, dd, _, _ := execKit(t) |
| + priv, pub := syncRelayTestKeys(t) |
| + |
| + if err := SetExecDelivery(ctx, s, "bab", "titogo", ExecDeliveryFile); err != nil { |
| + t.Fatalf("SetExecDelivery: %v", err) |
| + } |
| + rec := &relay.Record{Enabled: true, Incarnation: "inc00004", Regions: []string{"eu-west-1"}} |
| + masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("fedcba9876543210fedcba9876543210")}} |
| + wantSuffixes := sealRelayConfig(t, ctx, s, pub, "bab", "titogo", rec, masters) |
| + |
| + 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 acks []ConfigAck |
| + ack := func(a ConfigAck) { acks = append(acks, a) } |
| + if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", ack); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + |
| + if len(acks) != 1 { |
| + t.Fatalf("acks = %d, want 1: %+v", len(acks), acks) |
| + } |
| + got := acks[0] |
| + if got.Shape != "tcp-exec-file" { |
| + t.Fatalf("ack.Shape = %q, want tcp-exec-file", got.Shape) |
| + } |
| + if got.Restarted { |
| + t.Fatalf("ack.Restarted = true, want false — this platform never restarts this shape's process") |
| + } |
| + if got.RestartError != "" { |
| + t.Fatalf("ack.RestartError = %q, want empty", got.RestartError) |
| + } |
| + if got.RelaySuffixes["eu-west-1"] != wantSuffixes["eu-west-1"] { |
| + t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want %q", got.RelaySuffixes["eu-west-1"], wantSuffixes["eu-west-1"]) |
| + } |
| +} |
diff --git a/internal/console/apirelay.go b/internal/console/apirelay.go| index b3a941747..2a152c026 100644 |
| --- a/internal/console/apirelay.go |
| +++ b/internal/console/apirelay.go |
| @@ -295,7 +295,20 @@ func relayCutoverFor(rec *relay.Record, host string) *relay.RouteCutover { |
| // has never actually seen. "Freshest" (latest At) only matters if more than |
| // one box's ack somehow matches, which config sync's own per-(box,host) key |
| // makes rare in practice but not impossible across a route migration. |
| -func relayMatchingAck(acks []appconfig.ConfigAck, host string, issued map[string]string) *appconfig.ConfigAck { |
| +// |
| +// phase and issued are checked FIRST, and deliberately: maps.Equal(nil, |
| +// nil) is true, so without this guard an enabled record still at phase |
| +// "config" — IssuedSuffixes not written yet, a state an interrupted enable |
| +// can persist in indefinitely — would match ANY route's ordinary pre-relay |
| +// ack (RelaySuffixes nil, no CARLOS_RELAY entry at all) and print an |
| +// adoption for identities the console has never actually issued. Only a |
| +// record that finished an operation (phase done) with something to show |
| +// for it (len(issued) > 0) can have an ack legitimately match it at all |
| +// (review finding 2026-09-07). |
| +func relayMatchingAck(acks []appconfig.ConfigAck, host string, issued map[string]string, phase string) *appconfig.ConfigAck { |
| + if phase != relayPhaseDone || len(issued) == 0 { |
| + return nil |
| + } |
| var best *appconfig.ConfigAck |
| for i := range acks { |
| a := &acks[i] |
| @@ -322,13 +335,24 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec |
| if err != nil { |
| return apiRelayReply{}, err |
| } |
| - // Read once for the whole reply, not once per route: there is no |
| - // per-host box list to narrow this by (see the file's own doc comment), |
| - // so every route below filters this same slice by Host instead of each |
| - // paying for its own List+Get pass over every box's acks. |
| - acks, err := s.relayConfigAcks(ctx) |
| - if err != nil { |
| - return apiRelayReply{}, err |
| + // Read once for the whole reply, not once per route — and skipped |
| + // entirely when there is nothing for an ack to say anything about: a |
| + // disabled record has no key delivered (relayReply's own switch below |
| + // never reads an ack for it), and no routes means no hosts to match. |
| + // relayConfigAcks itself narrows the underlying List to just THIS |
| + // app's route hosts before issuing any Get (review finding |
| + // 2026-09-07: this used to cost a Get per ack in the whole fleet on |
| + // every status call). |
| + var acks []appconfig.ConfigAck |
| + if rec.Enabled && len(routes) > 0 { |
| + hosts := make(map[string]bool, len(routes)) |
| + for _, rt := range routes { |
| + hosts[rt.Host] = true |
| + } |
| + acks, err = s.relayConfigAcks(ctx, hosts) |
| + if err != nil { |
| + return apiRelayReply{}, err |
| + } |
| } |
| for _, rt := range routes { |
| if rt.Kind == "static" { |
| @@ -373,7 +397,7 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec |
| // or a stale ack left over from before a rotation) leaves the |
| // route at the base "written, awaiting delivery" rather than |
| // reporting an adoption under keys the route may never have run. |
| - if ca := relayMatchingAck(acks, rt.Host, rec.IssuedSuffixes); ca != nil { |
| + if ca := relayMatchingAck(acks, rt.Host, rec.IssuedSuffixes, rec.Phase); ca != nil { |
| row.ConfigAckAt = ca.At.UTC().Format(time.RFC3339) |
| row.ConfigAckShape = ca.Shape |
| switch { |
diff --git a/internal/console/apirelay_test.go b/internal/console/apirelay_test.go| index b39fc9557..64b245b4e 100644 |
| --- a/internal/console/apirelay_test.go |
| +++ b/internal/console/apirelay_test.go |
| @@ -14,6 +14,7 @@ package console |
| import ( |
| "context" |
| + "maps" |
| "net/http" |
| "strings" |
| "testing" |
| @@ -456,7 +457,7 @@ func TestAPIRelayStatusNamesEachRouteShapeAndWhoseMoveItIs(t *testing.T) { |
| issued := relayRecord(t, fx, "titogo").IssuedSuffixes |
| if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{ |
| Box: "box1", Host: "c.titogo.oncarlos.com", Shape: "tcp-exec-file", At: time.Now(), |
| - RelaySuffixes: cloneSuffixes(issued), |
| + RelaySuffixes: maps.Clone(issued), |
| }); err != nil { |
| t.Fatalf("seed ack c: %v", err) |
| } |
| @@ -545,17 +546,6 @@ func TestAPIRelayRotateStagingRefusalsAreClientErrors(t *testing.T) { |
| } |
| } |
| -// cloneSuffixes copies a suffix map so a test can hand one ack the record's |
| -// real IssuedSuffixes and another a value that merely LOOKS like it without |
| -// aliasing the record's own map. |
| -func cloneSuffixes(m map[string]string) map[string]string { |
| - out := make(map[string]string, len(m)) |
| - for k, v := range m { |
| - out[k] = v |
| - } |
| - return out |
| -} |
| - |
| // TestAPIRelayStatusReadsConfigAcks is Task 12's console half: three |
| // unit-backed routes, one config-ack each, seeded exactly as config sync |
| // itself writes them (control/config-ack/<box>/<host>.json, |
| @@ -578,6 +568,8 @@ func TestAPIRelayStatusReadsConfigAcks(t *testing.T) { |
| Kind: "service", Unit: "titogo-b.service", Status: "ready"}) |
| fx.addRoute(t, registry.Route{Host: "c.titogo.oncarlos.com", App: "titogo", |
| Kind: "service", Unit: "titogo-c.service", Status: "ready"}) |
| + fx.addRoute(t, registry.Route{Host: "d.titogo.oncarlos.com", App: "titogo", |
| + Kind: "service", Unit: "titogo-d.service", Status: "ready"}) |
| if status, out := postJSON(t, relayURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| t.Fatalf("enable = %d %v, want 200", status, out) |
| @@ -590,14 +582,14 @@ func TestAPIRelayStatusReadsConfigAcks(t *testing.T) { |
| now := time.Now() |
| if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{ |
| Box: "box1", Host: "a.titogo.oncarlos.com", Shape: "unit", At: now, |
| - Restarted: true, RelaySuffixes: cloneSuffixes(issued), |
| + Restarted: true, RelaySuffixes: maps.Clone(issued), |
| }); err != nil { |
| t.Fatalf("seed ack a: %v", err) |
| } |
| if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{ |
| Box: "box1", Host: "b.titogo.oncarlos.com", Shape: "unit", At: now, |
| RestartError: "systemctl try-restart carlos-app@b.titogo.oncarlos.com: exit status 1", |
| - RelaySuffixes: cloneSuffixes(issued), |
| + RelaySuffixes: maps.Clone(issued), |
| }); err != nil { |
| t.Fatalf("seed ack b: %v", err) |
| } |
| @@ -608,6 +600,12 @@ func TestAPIRelayStatusReadsConfigAcks(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("seed ack c: %v", err) |
| } |
| + if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{ |
| + Box: "box1", Host: "d.titogo.oncarlos.com", Shape: "unit", At: now, |
| + Hibernating: true, RelaySuffixes: maps.Clone(issued), |
| + }); err != nil { |
| + t.Fatalf("seed ack d: %v", err) |
| + } |
| _, out := getJSON(t, relayURL(fx, "titogo"), token) |
| rows := relayRouteRows(t, out) |
| @@ -620,4 +618,54 @@ func TestAPIRelayStatusReadsConfigAcks(t *testing.T) { |
| if got := str(t, rows["c.titogo.oncarlos.com"], "delivery"); got != "written, awaiting delivery" { |
| t.Fatalf("c delivery = %q, want the base written-awaiting-delivery sentence", got) |
| } |
| + wantRoute(t, rows, "d.titogo.oncarlos.com", "unit", "hibernating, delivered on wake") |
| +} |
| + |
| +// TestAPIRelayStatusNeverAdoptsOnEmptyIssuedSuffixes is the review's finding |
| +// 2 (2026-09-07): maps.Equal(nil, nil) is true, so without an explicit |
| +// guard an enabled record still at phase "config" — IssuedSuffixes not |
| +// written yet, the state an enable interrupted between spec §5 steps 4 and |
| +// 5 persists in until the reconcile finishes it — would match ANY route's |
| +// ordinary pre-relay ack (no CARLOS_RELAY entry at all, RelaySuffixes nil) |
| +// and print an adoption for identities the console has never actually |
| +// issued. |
| +func TestAPIRelayStatusNeverAdoptsOnEmptyIssuedSuffixes(t *testing.T) { |
| + fx, _ := relayFixture(t, "code-relay-empty-issued") |
| + ctx := context.Background() |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-relay-empty-issued-2") |
| + |
| + fx.addRoute(t, registry.Route{Host: "a.titogo.oncarlos.com", App: "titogo", |
| + Kind: "service", Unit: "titogo-a.service", Status: "ready"}) |
| + |
| + if status, out := postJSON(t, relayURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("enable = %d %v, want 200", status, out) |
| + } |
| + |
| + // Roll the finished enable back to the interrupted-mid-walk shape by |
| + // hand: still enabled, phase "config", nothing issued yet. |
| + rec, etag, err := relay.GetRecord(ctx, fx.rel, fx.sqid, "titogo") |
| + if err != nil { |
| + t.Fatalf("GetRecord: %v", err) |
| + } |
| + rec.Phase = "config" |
| + rec.IssuedSuffixes = nil |
| + if _, err := relay.PutRecord(ctx, fx.rel, fx.sqid, "titogo", rec, etag); err != nil { |
| + t.Fatalf("PutRecord: %v", err) |
| + } |
| + |
| + // An ordinary ack — the shape a route synced before this app ever |
| + // enabled the relay carries: RelaySuffixes nil, no error naming |
| + // anything wrong with it. |
| + if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{ |
| + Box: "box1", Host: "a.titogo.oncarlos.com", Shape: "unit", At: time.Now(), |
| + Restarted: true, |
| + }); err != nil { |
| + t.Fatalf("seed ack: %v", err) |
| + } |
| + |
| + _, out := getJSON(t, relayURL(fx, "titogo"), token) |
| + rows := relayRouteRows(t, out) |
| + if got := str(t, rows["a.titogo.oncarlos.com"], "delivery"); strings.Contains(got, "adopted") { |
| + t.Fatalf("delivery = %q, an empty-IssuedSuffixes record must never read as adopted", got) |
| + } |
| } |
diff --git a/internal/console/relayops.go b/internal/console/relayops.go| index 57c199aa5..8de68407b 100644 |
| --- a/internal/console/relayops.go |
| +++ b/internal/console/relayops.go |
| @@ -1163,11 +1163,17 @@ func (s *Server) relayHeartbeats(ctx context.Context) ([]relay.Heartbeat, error) |
| } |
| // relayConfigAcks reads every per-route config-adoption acknowledgement |
| -// under configAckPrefix, across every box — there is no per-host box list |
| -// to narrow the read by, the same limit relayHeartbeats accepts for |
| -// control/relay-ack/, so relayReply reads all of them and filters by Host |
| -// itself (relayMatchingAck, apirelay.go). |
| -func (s *Server) relayConfigAcks(ctx context.Context) ([]appconfig.ConfigAck, error) { |
| +// under configAckPrefix whose HOST — a key's basename, ConfigAckKey's own |
| +// "<box>/<host>.json" shape — is in hosts. There is no per-host box list |
| +// to narrow the LISTING by, the same limit relayHeartbeats accepts for |
| +// control/relay-ack/, so this still lists every key under the prefix; but |
| +// hosts filters that listing down to this app's own routes before a |
| +// single Get is issued, so a status call costs one List plus a Get per |
| +// THIS APP's route, never a Get per ack in the whole fleet (review |
| +// finding 2026-09-07 — relayReply used to pass every ack for every app on |
| +// the deployment through this filter instead of narrowing the read |
| +// itself). |
| +func (s *Server) relayConfigAcks(ctx context.Context, hosts map[string]bool) ([]appconfig.ConfigAck, error) { |
| keys, err := s.cfg.Releases.List(ctx, configAckPrefix) |
| if err != nil { |
| return nil, err |
| @@ -1177,6 +1183,13 @@ func (s *Server) relayConfigAcks(ctx context.Context) ([]appconfig.ConfigAck, er |
| if !strings.HasSuffix(key, ".json") { |
| continue |
| } |
| + host := strings.TrimSuffix(key, ".json") |
| + if idx := strings.LastIndexByte(host, '/'); idx >= 0 { |
| + host = host[idx+1:] |
| + } |
| + if !hosts[host] { |
| + continue |
| + } |
| body, _, err := s.cfg.Releases.Get(ctx, key) |
| if err != nil { |
| if errors.Is(err, os.ErrNotExist) { |