carlos / platform Public

appconfig: deliver to multi-upstream exec routes (#391)

Paul Campbell pushed by paul@keymail.dev 8e16153d144296099ed68a9fbe942eb640bb5bba parent a8f0d04
3 files changed, +53 −4
  • internal/appconfig/appconfig.go +1 −1
  • internal/appconfig/syncexec.go +6 −3
  • internal/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)