From 491f0a5580f2e357b1971a77e32f344d3b07963a Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 12:36:05 -0300 Subject: [PATCH] feat(generate,proxy): emit Caddy proxy labels for routed services (N5 part 1, spec 05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the N1 route table into generation: proxy.LabelsForService(m, project, service) returns the caddy-docker-proxy labels (caddy=..localhost, caddy.reverse_proxy={{upstreams }}, caddy.tls=internal when httpsLocal) when the workspace declares a Caddy proxy and the service exposes a port; buildProjectService merges them onto the service via maps.Copy. No-op (nil) when the proxy is disabled — so the existing golden + determinism are unchanged and `up` is unaffected unless the user opts in with network.proxy.engine: caddy. This makes the proxy routes actually take effect in compose (adding/removing a service reloads Caddy with no central-config edit). The remaining N5 piece is the saga trust phase + doctor trust/dns probes. Test: a proxy-enabled workspace's generated compose carries the caddy labels. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/generate/compose.go | 9 +++++++- internal/generate/health_test.go | 35 ++++++++++++++++++++++++++++++++ internal/proxy/proxy.go | 22 ++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/internal/generate/compose.go b/internal/generate/compose.go index 267208c..1dc12c0 100644 --- a/internal/generate/compose.go +++ b/internal/generate/compose.go @@ -3,6 +3,7 @@ package generate import ( "context" "fmt" + "maps" "sort" "strings" @@ -11,6 +12,7 @@ import ( "github.com/goccy/go-yaml" "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/proxy" "github.com/open-source-cloud/devstack/internal/template" ) @@ -92,7 +94,12 @@ func buildProjectService(res *graphResolver, m *config.Model, project, service s } out["networks"] = map[string]any{"default": nil, SharedNetwork: nil} - out["labels"] = b.labels(map[string]string{LabelProject: project, LabelService: service}) + svcLabels := map[string]string{LabelProject: project, LabelService: service} + // spec 05 — when a reverse proxy is configured, emit the caddy-docker-proxy + // route labels onto the service so adding/removing it reloads Caddy with no + // central-config edit. No-op (nil) when the proxy is disabled. + maps.Copy(svcLabels, proxy.LabelsForService(m, project, service)) + out["labels"] = b.labels(svcLabels) if exp := exposeList(svc.Ports); len(exp) > 0 { out["expose"] = exp diff --git a/internal/generate/health_test.go b/internal/generate/health_test.go index 5774560..cc0fef6 100644 --- a/internal/generate/health_test.go +++ b/internal/generate/health_test.go @@ -161,3 +161,38 @@ services: t.Errorf("compose should reference the cache dependency:\n%s", compose) } } + +// TestProxyLabelsEmitted verifies the caddy-docker-proxy labels are merged onto +// a routed service when the workspace declares a proxy (spec 05 / N5). +func TestProxyLabelsEmitted(t *testing.T) { + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("workspace.yaml", "apiVersion: devstack/v1\nkind: Workspace\nname: shop\nnetwork: { proxy: { engine: caddy, httpsLocal: true } }\nprojects:\n - { name: api, path: api }\n") + write("api/devstack.yaml", "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n web: { template: node.vite, ports: { http: 8080 } }\n") + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load: %v", err) + } + g, err := New(m, template.NewFSSource(templates.FS), WithEnv(map[string]string{})) + if err != nil { + t.Fatalf("New: %v", err) + } + st, err := g.GenerateProject("api") + if err != nil { + t.Fatalf("GenerateProject: %v", err) + } + compose := string(st.Compose) + for _, want := range []string{"caddy: web.api.localhost", "caddy.reverse_proxy", "caddy.tls: internal"} { + if !strings.Contains(compose, want) { + t.Errorf("compose missing proxy label %q:\n%s", want, compose) + } + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 375c8d8..db1931a 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -71,6 +71,28 @@ func HostFor(service, project string) string { return service + "." + project + "." + LocalDomain } +// LabelsForService returns the Caddy labels a generated project service should +// carry when the proxy is enabled and the service exposes a port; nil otherwise. +// This is the single seam generate uses to emit routing (no central config). +func LabelsForService(m *config.Model, project, service string) map[string]string { + if !Enabled(m) { + return nil + } + p, ok := m.Projects[project] + if !ok { + return nil + } + port := primaryPort(p.Services[service].Ports) + if port == 0 { + return nil + } + return CaddyLabels(Route{ + Project: project, Service: service, + Host: HostFor(service, project), Port: port, + TLS: m.Workspace.Network.Proxy.HTTPSLocal, + }) +} + // 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).