| 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 |