diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..375c8d8 --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,122 @@ +// Package proxy is the single source of truth for local (and, later, public) +// HTTP routing (spec 05). It builds a []Route from the workspace config; that one +// table renders BOTH the Caddy reverse-proxy labels (caddy-docker-proxy, emitted +// onto each project service so adding/removing a project never edits central +// config) AND — later, in internal/tunnel — the cloudflared ingress block, so +// local and public routing can never drift. +// +// Routing is opt-in: routes exist only when the workspace declares a proxy engine +// (network.proxy.engine: caddy). A broken/absent proxy never blocks `up`. +package proxy + +import ( + "fmt" + "sort" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// Engine names. Only Caddy ships in v1; Traefik/nginx stay pluggable. +const EngineCaddy = "caddy" + +// Domain suffix for local routing (spec 05). `.test` is opt-in behind `dns setup`. +const LocalDomain = "localhost" + +// Route is one host→service mapping. Upstream resolution for Caddy is implicit +// (caddy-docker-proxy discovers the container from its own labels via +// {{upstreams }}); Project/Service identify the owning container and Port +// is the in-container port. TLS requests an internal-CA cert (httpsLocal). +type Route struct { + Project string `json:"project"` + Service string `json:"service"` + Host string `json:"host"` // ..localhost + Port int `json:"port"` + TLS bool `json:"tls"` +} + +// Enabled reports whether the workspace has a (supported) reverse proxy declared. +func Enabled(m *config.Model) bool { + return m.Workspace.Network.Proxy.Engine == EngineCaddy +} + +// BuildRoutes derives the deterministic route table from every project service +// that exposes a port, when the proxy is enabled. Returns nil when disabled. +func BuildRoutes(m *config.Model) []Route { + if !Enabled(m) { + return nil + } + tls := m.Workspace.Network.Proxy.HTTPSLocal + var routes []Route + for _, pname := range sortedKeys(m.Projects) { + p := m.Projects[pname] + for _, sname := range sortedKeys(p.Services) { + port := primaryPort(p.Services[sname].Ports) + if port == 0 { + continue + } + routes = append(routes, Route{ + Project: pname, + Service: sname, + Host: HostFor(sname, pname), + Port: port, + TLS: tls, + }) + } + } + return routes +} + +// HostFor returns the local host for a service: ..localhost. +func HostFor(service, project string) string { + return service + "." + project + "." + LocalDomain +} + +// CaddyLabels renders the caddy-docker-proxy labels for a route. These are merged +// onto the project service in the generated compose so caddy reloads on the +// Docker event with no central-config edit (spec 05). +func CaddyLabels(r Route) map[string]string { + out := map[string]string{ + "caddy": r.Host, + "caddy.reverse_proxy": fmt.Sprintf("{{upstreams %d}}", r.Port), + } + if r.TLS { + out["caddy.tls"] = "internal" + } + return out +} + +// URLs returns the user-facing URLs for a route (scheme by TLS). +func (r Route) URL() string { + scheme := "http" + if r.TLS { + scheme = "https" + } + return scheme + "://" + r.Host +} + +// primaryPort picks a deterministic port for a service: the "http" port if named, +// else the lowest port number. 0 when the service exposes none. +func primaryPort(ports map[string]int) int { + if len(ports) == 0 { + return 0 + } + if p, ok := ports["http"]; ok && p > 0 { + return p + } + best := 0 + for _, p := range ports { + if p > 0 && (best == 0 || p < best) { + best = p + } + } + return best +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go new file mode 100644 index 0000000..85508b1 --- /dev/null +++ b/internal/proxy/proxy_test.go @@ -0,0 +1,90 @@ +package proxy + +import ( + "testing" + + "github.com/open-source-cloud/devstack/internal/config" +) + +func modelWith(proxy config.Proxy, services map[string]map[string]int) *config.Model { + projects := map[string]config.Project{} + svcs := map[string]config.Service{} + for sname, ports := range services { + svcs[sname] = config.Service{Template: "t", Ports: ports} + } + projects["shop"] = config.Project{Name: "shop", Services: svcs} + return &config.Model{ + Workspace: config.Workspace{Name: "ws", Network: config.Network{Proxy: proxy}}, + Projects: projects, + } +} + +func TestEnabled(t *testing.T) { + if Enabled(modelWith(config.Proxy{}, nil)) { + t.Error("no engine → disabled") + } + if !Enabled(modelWith(config.Proxy{Engine: "caddy"}, nil)) { + t.Error("caddy engine → enabled") + } + if Enabled(modelWith(config.Proxy{Engine: "traefik"}, nil)) { + t.Error("unsupported engine → disabled in v1") + } +} + +func TestBuildRoutesDisabled(t *testing.T) { + m := modelWith(config.Proxy{}, map[string]map[string]int{"api": {"http": 8080}}) + if r := BuildRoutes(m); r != nil { + t.Errorf("disabled proxy → no routes, got %v", r) + } +} + +func TestBuildRoutesPrimaryPortAndTLS(t *testing.T) { + m := modelWith(config.Proxy{Engine: "caddy", HTTPSLocal: true}, map[string]map[string]int{ + "api": {"http": 8080, "metrics": 9090}, + "web": {"x": 5173}, + "job": {}, // no ports → no route + }) + routes := BuildRoutes(m) + if len(routes) != 2 { + t.Fatalf("routes = %d, want 2 (api, web; job has no port)", len(routes)) + } + // Deterministic order (sorted by service): api then web. + if routes[0].Service != "api" || routes[0].Port != 8080 { + t.Errorf("route[0] = %+v, want api:8080 (http port preferred)", routes[0]) + } + if routes[0].Host != "api.shop.localhost" { + t.Errorf("host = %q, want api.shop.localhost", routes[0].Host) + } + if !routes[0].TLS { + t.Error("httpsLocal → TLS true") + } + if routes[1].Service != "web" || routes[1].Port != 5173 { + t.Errorf("route[1] = %+v, want web:5173 (lowest port)", routes[1]) + } +} + +func TestCaddyLabels(t *testing.T) { + l := CaddyLabels(Route{Host: "api.shop.localhost", Port: 8080, TLS: true}) + if l["caddy"] != "api.shop.localhost" { + t.Errorf("caddy = %q", l["caddy"]) + } + if l["caddy.reverse_proxy"] != "{{upstreams 8080}}" { + t.Errorf("reverse_proxy = %q", l["caddy.reverse_proxy"]) + } + if l["caddy.tls"] != "internal" { + t.Errorf("tls = %q, want internal", l["caddy.tls"]) + } + // No TLS → no caddy.tls label. + if _, ok := CaddyLabels(Route{Host: "h", Port: 1})["caddy.tls"]; ok { + t.Error("non-TLS route should not emit caddy.tls") + } +} + +func TestRouteURL(t *testing.T) { + if (Route{Host: "h", TLS: true}).URL() != "https://h" { + t.Error("TLS route URL should be https") + } + if (Route{Host: "h"}).URL() != "http://h" { + t.Error("non-TLS route URL should be http") + } +}