host: readiness on a route's effective upstream, socket before addr
Implements routeReady for Task 5, which returns a readiness check on a route's effective upstream. Socket takes precedence over Addr. With a configured health probe, it performs an HTTP GET; without one, just a TCP/Unix socket connect. Returns nil for routes with no upstream, so the settle function applies its Hold duration instead.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2 files changed,
+122
−0
internal/host/unitsettle.go+62 −0internal/host/unitsettle_test.go+60 −0
diff --git a/internal/host/unitsettle.go b/internal/host/unitsettle.go| index f860258be..414f2d35d 100644 |
| --- a/internal/host/unitsettle.go |
| +++ b/internal/host/unitsettle.go |
| @@ -11,9 +11,14 @@ |
| package host |
| import ( |
| + "context" |
| + "encoding/json" |
| "errors" |
| "fmt" |
| + "io" |
| "log" |
| + "net" |
| + "net/http" |
| "os" |
| "os/exec" |
| "regexp" |
| @@ -21,6 +26,8 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -198,3 +205,58 @@ func unitSettleTimeout(getenv func(string) string) time.Duration { |
| } |
| return d |
| } |
| + |
| +// routeReady is the readiness check for rt's effective upstream (spec §2.4): |
| +// Socket wins over Addr. With a configured probe it is an HTTP GET; without |
| +// one, a connect. nil when the route has no upstream: settle then requires |
| +// the unit to stay active for its Hold. |
| +func routeReady(rt registry.Route, timeout time.Duration) func() error { |
| + network, address := "unix", rt.Socket |
| + if address == "" { |
| + network, address = "tcp", rt.Addr |
| + } |
| + if address == "" { |
| + return nil |
| + } |
| + if rt.Health == "" { |
| + return func() error { |
| + c, err := net.DialTimeout(network, address, timeout) |
| + if err != nil { |
| + return fmt.Errorf("connect %s: %w", address, err) |
| + } |
| + return c.Close() |
| + } |
| + } |
| + var h provision.Health |
| + if err := json.Unmarshal([]byte(rt.Health), &h); err != nil { |
| + return func() error { return fmt.Errorf("route health probe does not parse: %w", err) } |
| + } |
| + h = h.WithDefaults() |
| + hostHdr := h.HostHeader |
| + if hostHdr == "" { |
| + hostHdr = rt.Host |
| + } |
| + client := &http.Client{Timeout: timeout, Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + var d net.Dialer |
| + return d.DialContext(ctx, network, address) |
| + }, |
| + }} |
| + return func() error { |
| + req, err := http.NewRequest(http.MethodGet, "http://upstream"+h.Path, nil) |
| + if err != nil { |
| + return err |
| + } |
| + req.Host = hostHdr |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + return fmt.Errorf("dial: %v", err) |
| + } |
| + io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) |
| + resp.Body.Close() |
| + if resp.StatusCode != h.Status { |
| + return fmt.Errorf("status %d (want %d)", resp.StatusCode, h.Status) |
| + } |
| + return nil |
| + } |
| +} |
diff --git a/internal/host/unitsettle_test.go b/internal/host/unitsettle_test.go| index 36f62d42b..8bdfb7d58 100644 |
| --- a/internal/host/unitsettle_test.go |
| +++ b/internal/host/unitsettle_test.go |
| @@ -3,11 +3,17 @@ package host |
| import ( |
| "errors" |
| "io/fs" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| "strings" |
| "syscall" |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -198,3 +204,57 @@ func TestUnitSettleTimeoutFloor(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestRouteReadyProbesTheUnixSocket(t *testing.T) { |
| + dir, err := os.MkdirTemp("", "rr") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + sock := filepath.Join(dir, "s.sock") |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path == "/healthz" && r.Host == "console.example.com" { |
| + w.WriteHeader(200) |
| + return |
| + } |
| + w.WriteHeader(500) |
| + })} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + rt := registry.Route{Host: "console.example.com", Socket: sock, Addr: "127.0.0.1:1", |
| + Health: `{"path":"/healthz","status":200}`} |
| + if err := routeReady(rt, time.Second)(); err != nil { |
| + t.Fatalf("ready over the socket = %v (Addr must be ignored when Socket is set)", err) |
| + } |
| +} |
| + |
| +func TestRouteReadyWrongStatus(t *testing.T) { |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503) })) |
| + t.Cleanup(srv.Close) |
| + rt := registry.Route{Host: "x.example.com", Addr: strings.TrimPrefix(srv.URL, "http://"), |
| + Health: `{"path":"/healthz","status":200}`} |
| + if err := routeReady(rt, time.Second)(); err == nil || !strings.Contains(err.Error(), "503") { |
| + t.Fatalf("ready = %v, want a 503 failure", err) |
| + } |
| +} |
| + |
| +func TestRouteReadyNoProbeIsAConnect(t *testing.T) { |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { ln.Close() }) |
| + if err := routeReady(registry.Route{Host: "x", Addr: ln.Addr().String()}, time.Second)(); err != nil { |
| + t.Fatalf("connect readiness = %v", err) |
| + } |
| +} |
| + |
| +func TestRouteReadyNoUpstreamIsNil(t *testing.T) { |
| + if routeReady(registry.Route{Host: "x"}, time.Second) != nil { |
| + t.Fatal("a route with no upstream got a ready check; settle must use the hold instead") |
| + } |
| +} |