Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
@@ -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 <port>}}); 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"` // <service>.<project>.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: <service>.<project>.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
}
90 changes: 90 additions & 0 deletions internal/proxy/proxy_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading