| new file mode 100644 |
| index 000000000..c27053362 |
| --- /dev/null |
| +++ b/internal/edge/fleet_route_test.go |
| @@ -0,0 +1,255 @@ |
| +package edge |
| + |
| +// Task 4: edge fleet-route dispatch — the 1-2-3 binding/fallback/dark rule |
| +// (task-4-brief.md): |
| +// |
| +// 1. Route.FleetBox != "" and channel "<fleet>/<FleetBox>" connected -> that |
| +// channel. |
| +// 2. else any connected channel of the fleet -> that one (deterministic: |
| +// ConnectedOfFleet's sorted order, first entry). |
| +// 3. else 503 with Retry-After: 2 (the fleet is dark). |
| +// |
| +// The bound/fallback cases need REAL h2 channels (a fake conn can't answer a |
| +// distinguishable response), so this uses the same tunnelclient.Loop-backed |
| +// fake remote host as tunnel_e2e_test.go rather than net.Pipe stand-ins. |
| + |
| +import ( |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// namedHandler answers every request with name, so a test can tell which |
| +// fake remote host actually served a proxied request. |
| +func namedHandler(name string) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(name)) |
| + }) |
| +} |
| + |
| +// fleetTestEdge builds an Edge + loopback server + armed hub over a registry |
| +// carrying fleet "acme" with the given boxes (label -> token) and the one |
| +// route, then wires fleet channel auth into the hub (Reload, same as |
| +// production's Watch loop). Returns the server URL and the hub so a test can |
| +// start fake remote hosts and wait on connection state. |
| +func fleetTestEdge(t *testing.T, boxes map[string]string, rt registry.Route) (string, *TunnelHub) { |
| + t.Helper() |
| + reg := openReg(t) |
| + if err := reg.UpsertFleet(registry.Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatalf("UpsertFleet: %v", err) |
| + } |
| + for label, token := range boxes { |
| + if err := reg.AddFleetBox(registry.FleetBox{Fleet: "acme", Label: label, TokenHash: sha256Hex(token), Status: "active"}); err != nil { |
| + t.Fatalf("AddFleetBox(%s): %v", label, err) |
| + } |
| + } |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert route: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + u, err := url.Parse(srv.URL) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + hub := NewTunnelHub(u.Hostname(), nil) |
| + e.EnableTunnels(hub) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload (wire fleet auth): %v", err) |
| + } |
| + return srv.URL, hub |
| +} |
| + |
| +// getHostFull is getHost (edge_test.go) but returns the whole response, so a |
| +// test can also inspect headers (Retry-After). |
| +func getHostFull(t *testing.T, edgeURL, host string) *http.Response { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", edgeURL+"/", nil) |
| + if err != nil { |
| + t.Fatalf("new request: %v", err) |
| + } |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("GET %s (host %s): %v", edgeURL, host, err) |
| + } |
| + return resp |
| +} |
| + |
| +// TestFleetRouteProxiesToBoundBox: rule 1 — a route bound (FleetBox) to a |
| +// box whose channel is connected rides that exact channel, even though a |
| +// second box of the same fleet is also connected and would sort first. |
| +func TestFleetRouteProxiesToBoundBox(t *testing.T) { |
| + edgeURL, hub := fleetTestEdge(t, map[string]string{"pi-1": "tok1", "pi-2": "tok2"}, registry.Route{ |
| + Host: "app.example", Kind: "instance", Status: "ready", |
| + Region: registry.PlacementTarget("acme"), FleetBox: "pi-1", |
| + }) |
| + stop1 := startFakeRemoteHost(t, edgeURL, "acme/pi-1", "tok1", namedHandler("pi-1")) |
| + defer stop1() |
| + stop2 := startFakeRemoteHost(t, edgeURL, "acme/pi-2", "tok2", namedHandler("pi-2")) |
| + defer stop2() |
| + waitForTunnelState(t, hub, "acme/pi-1", "connected", e2eReconnectBound) |
| + waitForTunnelState(t, hub, "acme/pi-2", "connected", e2eReconnectBound) |
| + |
| + code, body := getHost(t, edgeURL, "app.example") |
| + if code != http.StatusOK || body != "pi-1" { |
| + t.Fatalf("got %d %q, want 200 %q (the bound box, not the fallback)", code, body, "pi-1") |
| + } |
| +} |
| + |
| +// TestFleetRouteFallsOverWhenBoundBoxIsDark: rule 2 — the bound box (pi-1) |
| +// never connects at all; the only connected channel of the fleet (pi-2) |
| +// serves the request instead of a 502/503. |
| +func TestFleetRouteFallsOverWhenBoundBoxIsDark(t *testing.T) { |
| + edgeURL, hub := fleetTestEdge(t, map[string]string{"pi-1": "tok1", "pi-2": "tok2"}, registry.Route{ |
| + Host: "app.example", Kind: "instance", Status: "ready", |
| + Region: registry.PlacementTarget("acme"), FleetBox: "pi-1", |
| + }) |
| + // pi-1 is never dialed — bound but dark. |
| + stop2 := startFakeRemoteHost(t, edgeURL, "acme/pi-2", "tok2", namedHandler("pi-2")) |
| + defer stop2() |
| + waitForTunnelState(t, hub, "acme/pi-2", "connected", e2eReconnectBound) |
| + |
| + if hub.State("acme/pi-1") == "connected" { |
| + t.Fatal("test setup: pi-1 must not be connected for this case") |
| + } |
| + code, body := getHost(t, edgeURL, "app.example") |
| + if code != http.StatusOK || body != "pi-2" { |
| + t.Fatalf("got %d %q, want 200 %q (fallover to the only connected box)", code, body, "pi-2") |
| + } |
| +} |
| + |
| +// TestFleetRouteDarkFleetAnswers503: rule 3 — no channel of the fleet is |
| +// connected at all (bound or otherwise): a clean 503 + Retry-After: 2, not |
| +// the tunnel path's 502. |
| +func TestFleetRouteDarkFleetAnswers503(t *testing.T) { |
| + edgeURL, _ := fleetTestEdge(t, map[string]string{"pi-1": "tok1"}, registry.Route{ |
| + Host: "app.example", Kind: "instance", Status: "ready", |
| + Region: registry.PlacementTarget("acme"), FleetBox: "pi-1", |
| + }) |
| + // Nothing ever dials in. |
| + resp := getHostFull(t, edgeURL, "app.example") |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("got %d, want 503", resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("Retry-After"); got != "2" { |
| + t.Fatalf("Retry-After = %q, want %q", got, "2") |
| + } |
| +} |
| + |
| +// TestFleetRouteWithNoBoundBoxUsesFallback: FleetBox can legitimately be "" |
| +// (placement wants a fleet but no box has been assigned yet) — that must |
| +// skip straight to the fallback rule rather than erroring on an empty bound |
| +// channel name. |
| +func TestFleetRouteWithNoBoundBoxUsesFallback(t *testing.T) { |
| + edgeURL, hub := fleetTestEdge(t, map[string]string{"pi-1": "tok1"}, registry.Route{ |
| + Host: "app.example", Kind: "instance", Status: "ready", |
| + Region: registry.PlacementTarget("acme"), FleetBox: "", |
| + }) |
| + stop1 := startFakeRemoteHost(t, edgeURL, "acme/pi-1", "tok1", namedHandler("pi-1")) |
| + defer stop1() |
| + waitForTunnelState(t, hub, "acme/pi-1", "connected", e2eReconnectBound) |
| + |
| + code, body := getHost(t, edgeURL, "app.example") |
| + if code != http.StatusOK || body != "pi-1" { |
| + t.Fatalf("got %d %q, want 200 %q", code, body, "pi-1") |
| + } |
| +} |
| + |
| +// TestExplicitTunnelPrecedesFleetPlacement: a route can carry both an |
| +// explicit Tunnel binding and a fleet Region ("carlos route -tunnel" doesn't |
| +// clear Region, mirroring the existing Tunnel-vs-Socket/Addr precedence) — |
| +// the explicit Tunnel wins, per the brief's "Explicit rt.Tunnel keeps |
| +// precedence (legacy/tito path unchanged)". |
| +func TestExplicitTunnelPrecedesFleetPlacement(t *testing.T) { |
| + reg := openReg(t) |
| + if err := reg.UpsertFleet(registry.Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.AddFleetBox(registry.FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: sha256Hex("tok1"), Status: "active"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "app.example", Kind: "instance", Status: "ready", |
| + Region: registry.PlacementTarget("acme"), FleetBox: "pi-1", |
| + Tunnel: "legacy-box", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + u, err := url.Parse(srv.URL) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + hub := NewTunnelHub(u.Hostname(), map[string]string{"legacy-box": "sekrit"}) |
| + e.EnableTunnels(hub) |
| + if err := e.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + stop := startFakeRemoteHost(t, srv.URL, "legacy-box", "sekrit", namedHandler("legacy")) |
| + defer stop() |
| + waitForTunnelState(t, hub, "legacy-box", "connected", e2eReconnectBound) |
| + // The fleet channel (acme/pi-1) is never dialed at all — if the fleet |
| + // path were somehow taken, this would 503, not serve "legacy". |
| + code, body := getHost(t, srv.URL, "app.example") |
| + if code != http.StatusOK || body != "legacy" { |
| + t.Fatalf("got %d %q, want 200 %q (explicit Tunnel must win over fleet placement)", code, body, "legacy") |
| + } |
| +} |
| + |
| +// TestNonFleetRoutesUntouchedByFleetDispatch: an ordinary Addr-backed route |
| +// with no fleet placement at all (Region == "") must be completely unaffected |
| +// by the new dispatch case — same behavior as before Task 4. |
| +func TestNonFleetRoutesUntouchedByFleetDispatch(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "plain backend") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "plain.example", Kind: "service", Status: "ready", |
| + Addr: backend.Listener.Addr().String(), |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + code, body := getHost(t, srv.URL, "plain.example") |
| + if code != http.StatusOK || body != "plain backend" { |
| + t.Fatalf("got %d %q, want 200 %q", code, body, "plain backend") |
| + } |
| +} |
| + |
| +// TestConnectedOfFleetSortedAndFiltered pins ConnectedOfFleet directly: only |
| +// names under "<fleet>/" come back, sorted, and a same-prefixed different |
| +// fleet ("acme2") is never mistaken for a member of "acme". |
| +func TestConnectedOfFleetSortedAndFiltered(t *testing.T) { |
| + hub := NewTunnelHub("tunnel.test", nil) |
| + for _, name := range []string{"acme/pi-2", "acme/pi-1", "acme2/pi-1", "other/pi-1"} { |
| + c1, c2 := net.Pipe() |
| + t.Cleanup(func() { c2.Close() }) |
| + hub.mu.Lock() |
| + hub.conns[name] = &tunnelConn{raw: c1} |
| + hub.mu.Unlock() |
| + } |
| + got := hub.ConnectedOfFleet("acme") |
| + want := []string{"acme/pi-1", "acme/pi-2"} |
| + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { |
| + t.Fatalf("ConnectedOfFleet(acme) = %v, want %v", got, want) |
| + } |
| +} |