Merge remote-tracking branch 'origin/main' into analytics-step2
3 files changed,
+53
−4
internal/appconfig/appconfig.go+1 −1internal/appconfig/syncexec.go+6 −3internal/appconfig/syncexec_test.go+46 −0
CI passed — run details
diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go| index 7d2521c2a..139f12ce5 100644 |
| --- a/internal/appconfig/appconfig.go |
| +++ b/internal/appconfig/appconfig.go |
| @@ -24,7 +24,7 @@ type Manifest struct { |
| Vars map[string]Var `json:"vars"` |
| // ExecDelivery is the app's opt-in for issue #18's third SyncExec |
| // eligibility case: a TCP-addr exec route (Backing == "", Socket == "", |
| - // Addr != "") whose process carlos does not spawn or own — an |
| + // Addr or Addrs set) whose process carlos does not spawn or own — an |
| // app-supervised instance family (titogo's router fleet is the |
| // motivating case). "" (the zero value, omitted from JSON) means not |
| // opted in; ExecDeliveryFile ("file") means SyncExec writes this app's |
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go| index 0f0f8e5b6..239c03e76 100644 |
| --- a/internal/appconfig/syncexec.go |
| +++ b/internal/appconfig/syncexec.go |
| @@ -9,7 +9,7 @@ |
| // 2026-08-02-exec-config-delivery-design.md. |
| // |
| // A third eligibility case (issue #18): a TCP-addr exec route (Backing == |
| -// "", Socket == "", Addr != "") whose BOUND BUNDLE has opted in via the |
| +// "", Socket == "", Addr or Addrs set) whose BOUND BUNDLE has opted in via |
| // additive "exec_delivery": "file" manifest field (Manifest.ExecDelivery) |
| // gets the identical file, materialized with the same layering guarantees, |
| // environments-awareness, reap discipline, and mass-removal/path-traversal |
| @@ -495,7 +495,10 @@ func execSyncable(rt registry.Route) bool { |
| // owns the process of (titogo's router fleet is the motivating case: |
| // titogo-router + controld own the instance processes, carlos just |
| // proxies to 127.0.0.1:<port>). Narrows execRouteBase to an addr upstream |
| -// with no socket. |
| +// with no socket. Both registry spellings are accepted: Addr is the legacy |
| +// single-upstream field, while Addrs is the HA field used even when a route |
| +// currently has only one upstream. Treating only Addr as eligible silently |
| +// skipped config delivery when an existing route migrated to Addrs. |
| // |
| // This predicate is the ROUTE-SHAPE half only — it says nothing about |
| // whether the route is actually in scope this pass. Unlike execSyncable, |
| @@ -507,7 +510,7 @@ func execSyncable(rt registry.Route) bool { |
| // signal, an unwanted bounce at worst. See SyncExec's checkOptedIn/ |
| // inScope closures, which gate this predicate on that opt-in. |
| func execAddrCandidate(rt registry.Route) bool { |
| - return execRouteBase(rt) && rt.Socket == "" && rt.Addr != "" |
| + return execRouteBase(rt) && rt.Socket == "" && (rt.Addr != "" || rt.Addrs != "") |
| } |
| // ExecConfigApps is the distinct (account, app) set the agent's fast |
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go| index 372e83818..1fe50a6e9 100644 |
| --- a/internal/appconfig/syncexec_test.go |
| +++ b/internal/appconfig/syncexec_test.go |
| @@ -677,6 +677,48 @@ func TestSyncExecOptedInAddrRouteMaterializesFile(t *testing.T) { |
| } |
| } |
| +// TestSyncExecOptedInMultiAddrRouteMaterializesFile pins the HA registry |
| +// spelling of the externally managed TCP route. Addrs replaced Addr for HA |
| +// routes, including one-upstream routes, and must enter the same opt-in path: |
| +// carlos writes their file but never claims to own or bounce the process. |
| +func TestSyncExecOptedInMultiAddrRouteMaterializesFile(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", "woodstar", map[string]string{"PROVISION_TOKEN": "sealed-value"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if err := SetExecDelivery(ctx, s, "bab", "woodstar", ExecDeliveryFile); err != nil { |
| + t.Fatalf("SetExecDelivery: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "woodstar.example.com", Kind: "instance", App: "woodstar", Account: "bab", |
| + Status: "ready", Addrs: "127.0.0.1:8451", |
| + }); err != nil { |
| + t.Fatalf("Upsert: %v", err) |
| + } |
| + bounces := 0 |
| + if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { |
| + bounces++ |
| + return nil |
| + }, ""); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + got, err := os.ReadFile(filepath.Join(dd, "woodstar.example.com.config.env")) |
| + if err != nil { |
| + t.Fatalf("opted-in multi-addr route got no config file: %v", err) |
| + } |
| + want := execAddrConfigHeader + "PROVISION_TOKEN=sealed-value\n" |
| + if string(got) != want { |
| + t.Fatalf("config env = %q, want %q", got, want) |
| + } |
| + if bounces != 0 { |
| + t.Fatalf("multi-addr route bounced %d times, want zero: its supervisor owns restarts", bounces) |
| + } |
| +} |
| + |
| // 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 |
| @@ -1523,6 +1565,9 @@ func TestExecConfigApps(t *testing.T) { |
| // In: the TCP-addr shape (issue #18), opt-in unknown and unread. |
| {Host: "addr.example.com", Kind: "instance", App: "titogo", Account: "zed", |
| Status: "ready", Addr: "127.0.0.1:9000"}, |
| + // In: the HA spelling is the same externally managed TCP shape. |
| + {Host: "multi-addr.example.com", Kind: "instance", App: "woodstar", Account: "zed", |
| + Status: "ready", Addrs: "127.0.0.1:8451"}, |
| // In, and deduped with sock.example.com: one app, two routes, one |
| // generation object between them. |
| {Host: "sock2.example.com", Kind: "instance", App: "eleven", Account: "bab", |
| @@ -1556,6 +1601,7 @@ func TestExecConfigApps(t *testing.T) { |
| {"bab", "eleven"}, |
| {"bab", "kass"}, |
| {"zed", "titogo"}, |
| + {"zed", "woodstar"}, |
| } |
| if !slices.Equal(got, want) { |
| t.Fatalf("ExecConfigApps = %v, want %v (distinct, sorted by account then app)", got, want) |