carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 2731de70ed9a0040ed8a093c62445a85eee49a3d parent d2ff494
2 files changed, +122 −0
  • internal/host/unitsettle.go +62 −0
  • internal/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")
+ }
+}