carlos / platform Public

provision: mint/adopt multi-upstream shapes through routeFor + shapeMismatch

routeFor now carries a record's explicit Addrs (comma-joined) and Health
(marshaled through WithDefaults, so a partial probe config still mints
fully-materialized IntervalMS/FailAfter/RiseAfter — an un-defaulted
interval_ms:0 reaching the edge would tight-loop-probe a customer
upstream) onto the routes it mints. A legacy Addr-only record mints a
byte-identical route to before these fields existed: only rec.Addrs is
read, never AddrsOrDefault's Addr-folding.

shapeMismatch gains Addrs and Health cases beside Addr, so adoption
refuses a hand-added route whose upstream set or probe config disagrees
with the record.

This closes the hazard Task 2 flagged: until routeFor carried
Addrs/Health, every reconcile tick's Upsert blanked SetUpstreams writes
(registry.go:647-654, 904-923).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f4ac9a4670db6077803897c4624bc9ce80420706 parent a3e02f5
3 files changed, +117 −0
  • internal/provision/provision_test.go +19 −0
  • internal/provision/reconcile.go +25 −0
  • internal/provision/reconcile_test.go +73 −0
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index 527f6e11d..98449faeb 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -657,3 +657,22 @@ func TestLegacyRecordStillParses(t *testing.T) {
t.Fatalf("legacy fold = %v", got)
}
}
+
+// TestHealthWireTagIsProbeNotHealth pins Record.Health's wire key to
+// "probe": the console's instance-list API already ships "health" for a
+// computed one-word status string (instanceListRecord.Health, parsed by a
+// deployed CLI), so this field must never collide with it. A well-meaning
+// rename back to "health" would silently break that shipped surface.
+func TestHealthWireTagIsProbeNotHealth(t *testing.T) {
+ rec := Record{Host: "x", App: "web", Account: "bab", Channel: "stable", Health: &Health{Path: "/up"}}
+ b, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(b), `"probe":`) {
+ t.Fatalf("marshal dropped the probe key: %s", b)
+ }
+ if strings.Contains(string(b), `"health":`) {
+ t.Fatalf("marshal used the collided health key: %s", b)
+ }
+}
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index df3d0f866..a93e5aed7 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -16,6 +16,7 @@ import (
"log"
"os"
"slices"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/domains"
@@ -78,6 +79,26 @@ func routeFor(rec Record, dataDir, seed string) registry.Route {
rt.Socket = DefaultSocket(rec.Host)
rt.DBPath = DefaultDBPath(dataDir, rec.Host)
}
+ // Addrs/Health are read from the record's EXPLICIT fields only, never
+ // AddrsOrDefault/HealthOrDefault's Addr-folding — a legacy Addr-only
+ // record must mint a byte-identical route to before these fields
+ // existed (registry.Route.Addrs docs the AddrsOrDefault fold-back that
+ // keeps single-upstream callers working off Addr alone).
+ if len(rec.Addrs) > 0 {
+ rt.Addrs = strings.Join(rec.Addrs, ",")
+ }
+ // WithDefaults, not the raw pointer: a record with explicit-but-partial
+ // probe config (e.g. only Path set) must still mint a route whose
+ // Health JSON carries the platform's fully-materialized defaults for
+ // every field it left at zero. An un-defaulted interval_ms of 0
+ // reaching the edge would mean a tight-loop probe against a customer
+ // upstream.
+ if h := rec.HealthOrDefault(); h != nil {
+ withDefaults := h.WithDefaults()
+ if b, err := json.Marshal(withDefaults); err == nil {
+ rt.Health = string(b)
+ }
+ }
return rt
}
@@ -160,6 +181,10 @@ func shapeMismatch(existing, want registry.Route) string {
return fmt.Sprintf("kind %q/%q", existing.Kind, want.Kind)
case existing.Addr != want.Addr:
return fmt.Sprintf("addr %q/%q", existing.Addr, want.Addr)
+ case existing.Addrs != want.Addrs:
+ return fmt.Sprintf("addrs %q/%q", existing.Addrs, want.Addrs)
+ case existing.Health != want.Health:
+ return fmt.Sprintf("health %q/%q", existing.Health, want.Health)
case existing.Unit != want.Unit:
return fmt.Sprintf("unit %q/%q", existing.Unit, want.Unit)
case existing.Slug != want.Slug:
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index 4153d849d..f8eaf88a3 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -1496,3 +1496,76 @@ func TestReconcileNeverRemovesUnadoptedRoute(t *testing.T) {
t.Errorf("a hand-added route with no record was modified: %+v", got)
}
}
+
+// TestRouteForCarriesAddrsAndHealth is Task 3's mint-side proof: a record
+// with an explicit multi-upstream Addrs set mints a route whose Addrs is the
+// comma-joined string and whose Health carries a fully-materialized default
+// probe (design §Part II). A legacy single-Addr record mints a
+// byte-identical route to before this field existed — routeFor only ever
+// reads the record's EXPLICIT Addrs, never AddrsOrDefault's Addr-folding.
+func TestRouteForCarriesAddrsAndHealth(t *testing.T) {
+ rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable",
+ Kind: "service", Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"}}
+ rt := routeFor(rec, "/data", "seed")
+ if rt.Addrs != "10.0.0.1:3000,10.0.0.2:3000" {
+ t.Fatalf("Addrs = %q", rt.Addrs)
+ }
+ if rt.Health == "" {
+ t.Fatal("multi-addr record must mint default health JSON")
+ }
+ var h Health
+ if err := json.Unmarshal([]byte(rt.Health), &h); err != nil || h.Path != "/" || h.FailAfter != 3 {
+ t.Fatalf("health JSON = %q (%v)", rt.Health, err)
+ }
+ // Legacy single-addr record: byte-identical route to before this change.
+ old := Record{Host: "s.example.com", App: "web", Account: "bab", Channel: "stable", Kind: "service", Addr: "10.0.0.9:3000"}
+ ort := routeFor(old, "/data", "seed")
+ if ort.Addrs != "" || ort.Health != "" || ort.Addr != "10.0.0.9:3000" {
+ t.Fatalf("legacy record's route changed shape: %+v", ort)
+ }
+}
+
+// TestRouteForMaterializesPartialHealthDefaults is the controller amendment
+// from Task 1's review: a record with EXPLICIT-but-partial probe config
+// (only Path set here) must still mint a route whose Health JSON carries the
+// platform's fully-materialized defaults for every field the record left at
+// zero. An un-defaulted interval_ms of 0 reaching the edge would mean a
+// tight-loop probe against a customer upstream.
+func TestRouteForMaterializesPartialHealthDefaults(t *testing.T) {
+ rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable",
+ Kind: "service", Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"},
+ Health: &Health{Path: "/healthz"}}
+ rt := routeFor(rec, "/data", "seed")
+ var h Health
+ if err := json.Unmarshal([]byte(rt.Health), &h); err != nil {
+ t.Fatalf("health JSON = %q (%v)", rt.Health, err)
+ }
+ if h.Path != "/healthz" {
+ t.Fatalf("explicit Path lost: %+v", h)
+ }
+ if h.IntervalMS != 2000 {
+ t.Fatalf("partial probe config minted an un-defaulted interval: %+v", h)
+ }
+ if h.FailAfter != 3 {
+ t.Fatalf("partial probe config minted an un-defaulted fail_after: %+v", h)
+ }
+}
+
+// TestShapeMismatchComparesAddrsAndHealth is Task 3's adopt-side proof:
+// shapeMismatch must refuse to adopt a hand-added route whose Addrs or
+// Health disagree with what the record describes, the same guard it already
+// applies to the legacy Addr column.
+func TestShapeMismatchComparesAddrsAndHealth(t *testing.T) {
+ a := registry.Route{Kind: "service", Addrs: "x:1,y:2"}
+ b := registry.Route{Kind: "service", Addrs: "x:1,z:3"}
+ if shapeMismatch(a, b) == "" {
+ t.Fatal("differing addrs adopted silently")
+ }
+ c := registry.Route{Kind: "service", Addrs: "x:1,y:2", Health: `{"path":"/up"}`}
+ if shapeMismatch(a, c) == "" {
+ t.Fatal("differing health adopted silently")
+ }
+ if shapeMismatch(a, registry.Route{Kind: "service", Addrs: "x:1,y:2"}) != "" {
+ t.Fatal("equal shapes must match")
+ }
+}