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>
3 files changed,
+117
−0
internal/provision/provision_test.go+19 −0internal/provision/reconcile.go+25 −0internal/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") |
| + } |
| +} |