carlos / platform Public

provision: addrs + health on the record shape (additive, #152)

Paul Campbell 2c88cad062b2efc89d1960372e90075b56efa1ac parent efcb360
2 files changed, +198 −14
  • internal/provision/provision.go +124 −14
  • internal/provision/provision_test.go +74 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index b2ed5342d..d2c6655e1 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -78,6 +78,59 @@ type Record struct {
// route an operator opted in with `carlos add -status-named`. nil =
// StatusNamedOrDefault's answer.
StatusNamed *bool `json:"status_named,omitempty"`
+
+ // --- multi-upstream fields (2026-08-12, HA design, issue #152) --------
+
+ // Addrs is a route's upstream set, host:port each, for a tenant fronting
+ // more than one process. Mutually exclusive with Addr — a record picks
+ // one shape and ValidateShape refuses both set. Empty means "no
+ // upstreams of its own" (AddrsOrDefault folds Addr in for the legacy
+ // single-upstream case).
+ Addrs []string `json:"addrs,omitempty"`
+ // Health is the route's HTTP probe config. nil means unset;
+ // HealthOrDefault derives the answer (probed once Addrs names more than
+ // one upstream, unprobed otherwise — today's behavior for every record
+ // that predates this field). Wire key is "probe", not "health": the
+ // console's instance-list API already ships "health" for a computed
+ // one-word status string (internal/console/apiinstances.go's
+ // instanceListRecord.Health) that a deployed CLI parses — that surface
+ // is shipped and keeps the name, this field is new and yields it.
+ Health *Health `json:"probe,omitempty"`
+}
+
+// Health is the per-route probe config (HA spec Part II). Zero value =
+// unset; WithDefaults fills in the platform's defaults for whatever the
+// caller left at zero.
+type Health struct {
+ Path string `json:"path,omitempty"` // default "/"
+ Status int `json:"status,omitempty"` // default 200
+ HostHeader string `json:"host_header,omitempty"` // default: the route host
+ IntervalMS int `json:"interval_ms,omitempty"` // default 2000
+ FailAfter int `json:"fail_after,omitempty"` // default 3
+ RiseAfter int `json:"rise_after,omitempty"` // default 2
+}
+
+// WithDefaults fills the five zero-valued fields above with the platform's
+// defaults. HostHeader is deliberately left as-is (including empty) — the
+// edge substitutes the route's own host for an unset HostHeader, and that
+// substitution needs to see "unset", not a value this method invented.
+func (h Health) WithDefaults() Health {
+ if h.Path == "" {
+ h.Path = "/"
+ }
+ if h.Status == 0 {
+ h.Status = 200
+ }
+ if h.IntervalMS == 0 {
+ h.IntervalMS = 2000
+ }
+ if h.FailAfter == 0 {
+ h.FailAfter = 3
+ }
+ if h.RiseAfter == 0 {
+ h.RiseAfter = 2
+ }
+ return h
}
// Manifest is the wire shape at apps/<sqid>/<app>/instances.json — the app's
@@ -198,7 +251,38 @@ func (r Record) KindOrDefault() string {
// needs only a valid shape), and what hibernation defaults to (only a route
// with a socket has a process for the activator to wake).
func (r Record) DerivesPaths() bool {
- return r.KindOrDefault() == "instance" && r.Addr == ""
+ return r.KindOrDefault() == "instance" && len(r.AddrsOrDefault()) == 0
+}
+
+// AddrsOrDefault resolves the record's upstream set: Addrs if set, else the
+// single-element fold of the legacy Addr field, else nil. Every caller that
+// used to branch on Addr != "" reads this instead, so a record minted
+// before Addrs existed proxies to exactly the upstream it always did.
+func (r Record) AddrsOrDefault() []string {
+ if len(r.Addrs) > 0 {
+ return r.Addrs
+ }
+ if r.Addr != "" {
+ return []string{r.Addr}
+ }
+ return nil
+}
+
+// HealthOrDefault resolves the record's probe config: Health if set, else
+// the platform defaults once AddrsOrDefault names more than one upstream
+// (a route fronting several processes needs to know which ones are up),
+// else nil. A single-upstream record — Addrs unset, Addr alone or one
+// element of Addrs — stays unprobed, the behavior every record had before
+// Health existed.
+func (r Record) HealthOrDefault() *Health {
+ if r.Health != nil {
+ return r.Health
+ }
+ if len(r.AddrsOrDefault()) > 1 {
+ h := Health{}.WithDefaults()
+ return &h
+ }
+ return nil
}
// HibernateOrDefault resolves the tri-state. An explicit value wins. Absent
@@ -249,17 +333,23 @@ func ValidateShape(rec Record) error {
return fmt.Errorf("kind %q: want instance, service or static (empty means instance)", rec.Kind)
}
if rec.Addr != "" {
- h, p, err := net.SplitHostPort(rec.Addr)
- if err != nil || h == "" || p == "" {
- return fmt.Errorf("addr %q: want a TCP upstream as host:port (e.g. 127.0.0.1:8090)", rec.Addr)
+ if err := validAddr(rec.Addr); err != nil {
+ return err
}
- // Parse port as integer and validate range 1..65535. net.SplitHostPort
- // accepts garbage ports (e.g. "127.0.0.1:abc") — we need to refuse them
- // here at the door, not discover them months later as a route that never
- // connects (the -backing unti class of incident).
- portNum, err := strconv.ParseUint(p, 10, 16)
- if err != nil || portNum == 0 {
- return fmt.Errorf("addr %q: port must be an integer in the range 1..65535", rec.Addr)
+ }
+ if rec.Addr != "" && len(rec.Addrs) > 0 {
+ return errors.New("addr and addrs both set: a record picks one upstream shape — the single legacy addr or the addrs set — drop one of the two")
+ }
+ if len(rec.Addrs) > 0 {
+ seen := make(map[string]bool, len(rec.Addrs))
+ for _, a := range rec.Addrs {
+ if err := validAddr(a); err != nil {
+ return err
+ }
+ if seen[a] {
+ return fmt.Errorf("addrs: duplicate upstream %q", a)
+ }
+ seen[a] = true
}
}
if rec.Unit != "" && !registry.ValidUnitName(rec.Unit) {
@@ -269,7 +359,7 @@ func ValidateShape(rec Record) error {
return fmt.Errorf("environment %q: want [a-z0-9-]{1,32}", rec.Environment)
}
if rec.KindOrDefault() == "static" {
- if rec.Addr != "" {
+ if len(rec.AddrsOrDefault()) > 0 {
return errors.New("kind static with addr: a static route serves a file tree and proxies to nothing — drop one of the two")
}
if rec.Unit != "" {
@@ -279,7 +369,7 @@ func ValidateShape(rec Record) error {
return errors.New("kind static with hibernate: a static route has no process to wake, so arming the activator for it would wedge the host — drop hibernate")
}
}
- if rec.KindOrDefault() == "service" && rec.Addr == "" {
+ if rec.KindOrDefault() == "service" && len(rec.AddrsOrDefault()) == 0 {
return errors.New("kind service with no addr: the platform only proxies to a service, so the record must name the upstream it proxies to")
}
// Same wedge as static+hibernate, arriving from the other side. Addr does
@@ -289,12 +379,32 @@ func ValidateShape(rec Record) error {
// no socket for and whose process the platform never spawned.
// HibernateOrDefault's own derivation already states the invariant ("an
// addr-backed tenant is always on"); this is the door that enforces it.
- if rec.Addr != "" && rec.Hibernate != nil && *rec.Hibernate {
+ if len(rec.AddrsOrDefault()) > 0 && rec.Hibernate != nil && *rec.Hibernate {
return errors.New("addr with hibernate: an addr-backed route proxies to a process the platform does not spawn and has no socket to wake, so arming the activator for it would wedge the host — drop hibernate")
}
return nil
}
+// validAddr checks one upstream's shape: host:port, with the port an
+// integer in 1..65535. Factored out of the single-Addr check so Addrs runs
+// the identical rail per element — one bad element among many upstreams is
+// refused just as loudly as a solitary bad Addr always was.
+func validAddr(a string) error {
+ h, p, err := net.SplitHostPort(a)
+ if err != nil || h == "" || p == "" {
+ return fmt.Errorf("addr %q: want a TCP upstream as host:port (e.g. 127.0.0.1:8090)", a)
+ }
+ // Parse port as integer and validate range 1..65535. net.SplitHostPort
+ // accepts garbage ports (e.g. "127.0.0.1:abc") — we need to refuse them
+ // here at the door, not discover them months later as a route that never
+ // connects (the -backing unti class of incident).
+ portNum, err := strconv.ParseUint(p, 10, 16)
+ if err != nil || portNum == 0 {
+ return fmt.Errorf("addr %q: port must be an integer in the range 1..65535", a)
+ }
+ return nil
+}
+
// ValidRecordHost applies the host rail this record actually needs (design
// §3.2). ValidHost is path-traversal defence: it is what keeps a hostile
// host from reaching the <host>.sock and <host>.db path segments as anything
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index 94f6fc1b4..527f6e11d 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -583,3 +583,77 @@ func TestValidRecordHostFollowsPathDerivation(t *testing.T) {
t.Error("a hostile host passed the shape check")
}
}
+
+func TestAddrsOrDefaultFoldsLegacyAddr(t *testing.T) {
+ one := Record{Addr: "10.0.0.1:3000"}
+ if got := one.AddrsOrDefault(); len(got) != 1 || got[0] != "10.0.0.1:3000" {
+ t.Fatalf("legacy fold = %v", got)
+ }
+ many := Record{Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"}}
+ if got := many.AddrsOrDefault(); len(got) != 2 {
+ t.Fatalf("addrs = %v", got)
+ }
+ if got := (Record{}).AddrsOrDefault(); got != nil {
+ t.Fatalf("empty = %v, want nil", got)
+ }
+}
+
+func TestHealthDefaults(t *testing.T) {
+ h := Health{}.WithDefaults()
+ if h.Path != "/" || h.Status != 200 || h.IntervalMS != 2000 || h.FailAfter != 3 || h.RiseAfter != 2 {
+ t.Fatalf("defaults = %+v", h)
+ }
+ // Multi-addr with no explicit health implies default probing.
+ if (Record{Addrs: []string{"a:1", "b:1"}}).HealthOrDefault() == nil {
+ t.Fatal("multi-addr record must default to probed")
+ }
+ // Single legacy addr with no health stays unprobed (today's behavior).
+ if (Record{Addr: "a:1"}).HealthOrDefault() != nil {
+ t.Fatal("legacy single-addr record must stay unprobed")
+ }
+}
+
+func TestValidateShapeMultiAddr(t *testing.T) {
+ base := Record{Host: "x", Kind: "service"}
+ ok := base
+ ok.Addrs = []string{"10.0.0.1:3000", "10.0.0.2:3000"}
+ if err := ValidateShape(ok); err != nil {
+ t.Fatalf("valid multi-addr rejected: %v", err)
+ }
+ bad := base
+ bad.Addrs = []string{"10.0.0.1:3000", "not-an-addr"}
+ if err := ValidateShape(bad); err == nil {
+ t.Fatal("bad element accepted")
+ }
+ both := base
+ both.Addr, both.Addrs = "10.0.0.1:3000", []string{"10.0.0.2:3000"}
+ if err := ValidateShape(both); err == nil {
+ t.Fatal("addr AND addrs together must be rejected — one shape per record")
+ }
+ hib := true
+ sleepy := base
+ sleepy.Addrs, sleepy.Hibernate = []string{"10.0.0.1:3000"}, &hib
+ if err := ValidateShape(sleepy); err == nil {
+ t.Fatal("addrs with hibernate must be rejected (wake-safety invariant)")
+ }
+ dup := base
+ dup.Addrs = []string{"10.0.0.1:3000", "10.0.0.1:3000"}
+ if err := ValidateShape(dup); err == nil {
+ t.Fatal("duplicate upstream must be rejected")
+ }
+}
+
+func TestLegacyRecordStillParses(t *testing.T) {
+ // A record written before addrs/health existed — byte-for-byte old wire shape.
+ raw := []byte(`{"schema":1,"host":"a.example.com","slug":"a","app":"web","account":"bab","channel":"stable","created_at":"2026-08-01T00:00:00Z","kind":"service","addr":"10.0.0.1:3000"}`)
+ var rec Record
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ t.Fatal(err)
+ }
+ if rec.Addrs != nil || rec.Health != nil {
+ t.Fatalf("legacy record grew fields: %+v", rec)
+ }
+ if got := rec.AddrsOrDefault(); len(got) != 1 || got[0] != "10.0.0.1:3000" {
+ t.Fatalf("legacy fold = %v", got)
+ }
+}