From 455174ccb0d54903d6d0157d15a6dd1b9e45dabe Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Fri, 15 Aug 2025 18:29:23 +1000 Subject: [PATCH] chore: generate sites in Caddyfile from x-ports alongside caddy.json --- internal/machine/caddyconfig/caddyfile.go | 115 +++++++++- .../machine/caddyconfig/caddyfile_test.go | 196 ++++++++++++++++++ internal/machine/caddyconfig/jsonconfig.go | 43 +--- .../machine/caddyconfig/jsonconfig_test.go | 2 +- 4 files changed, 308 insertions(+), 48 deletions(-) create mode 100644 internal/machine/caddyconfig/caddyfile_test.go diff --git a/internal/machine/caddyconfig/caddyfile.go b/internal/machine/caddyconfig/caddyfile.go index c56bdcb7..e72fe2a4 100644 --- a/internal/machine/caddyconfig/caddyfile.go +++ b/internal/machine/caddyconfig/caddyfile.go @@ -1,17 +1,122 @@ package caddyconfig import ( + "bytes" "fmt" + "log/slog" + "net" + "strconv" + "strings" + "text/template" "github.com/psviderski/uncloud/pkg/api" ) -func GenerateCaddyfile(containers []api.ServiceContainer, verifyResponse string) (string, error) { - return fmt.Sprintf(`http:// { - handle %s { - respond "%s" 200 +const caddyfileTemplate = `http:// { + handle {{.VerifyPath}} { + respond "{{.VerifyResponse}}" 200 } log } -`, VerifyPath, verifyResponse), nil + +(common_proxy) { + # Retry failed requests up to lb_retries times against other available upstreams. + lb_retries 3 + # Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking). + fail_duration 30s +} +{{- range $hostname, $upstreams := .HTTPHostUpstreams}} + +http://{{$hostname}} { + reverse_proxy { + to {{join $upstreams " "}} + import common_proxy + } + log +}{{end}} +{{- range $hostname, $upstreams := .HTTPSHostUpstreams}} + +https://{{$hostname}} { + reverse_proxy { + to {{join $upstreams " "}} + import common_proxy + } + log +}{{end}} +` + +func GenerateCaddyfile(containers []api.ServiceContainer, verifyResponse string) (string, error) { + httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers) + + funcs := template.FuncMap{"join": strings.Join} + tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfileTemplate) + if err != nil { + return "", fmt.Errorf("failed to parse Caddyfile template: %w", err) + } + + data := struct { + VerifyPath string + VerifyResponse string + HTTPHostUpstreams map[string][]string + HTTPSHostUpstreams map[string][]string + }{ + VerifyPath: VerifyPath, + VerifyResponse: verifyResponse, + HTTPHostUpstreams: httpHostUpstreams, + HTTPSHostUpstreams: httpsHostUpstreams, + } + + var buf bytes.Buffer + if err = tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute Caddyfile template: %w", err) + } + + return buf.String(), nil +} + +// httpUpstreamsFromContainers extracts upstreams for HTTP and HTTPS protocols from the published ports of the provided +// service containers. +func httpUpstreamsFromContainers(containers []api.ServiceContainer) (map[string][]string, map[string][]string) { + // Maps hostnames to lists of upstreams (container IP:port pairs). + httpHostUpstreams := make(map[string][]string) + httpsHostUpstreams := make(map[string][]string) + for _, ctr := range containers { + if !ctr.Healthy() { + continue + } + + ip := ctr.UncloudNetworkIP() + if !ip.IsValid() { + // Container is not connected to the uncloud Docker network (could be host network). + continue + } + log := slog.With("container", ctr.ID) + + ports, err := ctr.ServicePorts() + if err != nil { + log.Error("Failed to parse service ports for container.", "err", err) + continue + } + + for _, port := range ports { + if port.Mode != api.PortModeIngress { + continue + } + + switch port.Protocol { + case api.ProtocolHTTP: + upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort))) + httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream) + case api.ProtocolHTTPS: + upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort))) + httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream) + default: + // TODO: implement L4 ingress routing for TCP and UDP. + log.Error("Unsupported protocol for ingress port.", "port", port) + continue + } + } + } + + return httpHostUpstreams, httpsHostUpstreams } diff --git a/internal/machine/caddyconfig/caddyfile_test.go b/internal/machine/caddyconfig/caddyfile_test.go new file mode 100644 index 00000000..46516223 --- /dev/null +++ b/internal/machine/caddyconfig/caddyfile_test.go @@ -0,0 +1,196 @@ +package caddyconfig + +import ( + "testing" + + "github.com/psviderski/uncloud/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateCaddyfile(t *testing.T) { + caddyfileHeader := `http:// { + handle /.uncloud-verify { + respond "verification-response-body" 200 + } + log +} + +(common_proxy) { + # Retry failed requests up to lb_retries times against other available upstreams. + lb_retries 3 + # Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking). + fail_duration 30s +} +` + + tests := []struct { + name string + containers []api.ServiceContainer + want string + wantErr bool + }{ + { + name: "empty containers", + containers: []api.ServiceContainer{}, + want: caddyfileHeader, + }, + { + name: "HTTP container", + containers: []api.ServiceContainer{ + newContainer("10.210.0.2", "app.example.com:8080/http"), + }, + want: caddyfileHeader + ` +http://app.example.com { + reverse_proxy { + to 10.210.0.2:8080 + import common_proxy + } + log +} +`, + }, + { + name: "load balancing multiple containers", + containers: []api.ServiceContainer{ + newContainer("10.210.0.2", "app.example.com:8080/http"), + newContainer("10.210.0.3", "app.example.com:8080/http"), + }, + want: caddyfileHeader + ` +http://app.example.com { + reverse_proxy { + to 10.210.0.2:8080 10.210.0.3:8080 + import common_proxy + } + log +} +`, + }, + { + name: "HTTPS container", + containers: []api.ServiceContainer{ + newContainer("10.210.0.2", "secure.example.com:8000/https"), + }, + want: caddyfileHeader + ` +https://secure.example.com { + reverse_proxy { + to 10.210.0.2:8000 + import common_proxy + } + log +} +`, + }, + { + name: "mixed HTTP and HTTPS", + containers: []api.ServiceContainer{ + newContainer("10.210.0.2", + "app.example.com:8080/http", + "web.example.com:8000/http"), + newContainer("10.210.0.3", + "app.example.com:8080/http", + "secure.example.com:8888/https"), + newContainer("10.210.0.4", + "web.example.com:8000/http", + "secure.example.com:8888/https"), + newContainer("10.210.0.5", + "app.example.com:8080/http", + "web.example.com:8000/http", + "secure.example.com:8888/https"), + }, + want: caddyfileHeader + ` +http://app.example.com { + reverse_proxy { + to 10.210.0.2:8080 10.210.0.3:8080 10.210.0.5:8080 + import common_proxy + } + log +} + +http://web.example.com { + reverse_proxy { + to 10.210.0.2:8000 10.210.0.4:8000 10.210.0.5:8000 + import common_proxy + } + log +} + +https://secure.example.com { + reverse_proxy { + to 10.210.0.3:8888 10.210.0.4:8888 10.210.0.5:8888 + import common_proxy + } + log +} +`, + }, + { + name: "container without uncloud network ignored", + containers: []api.ServiceContainer{ + newContainerWithoutNetwork("ignored.example.com:8080/http"), + }, + want: caddyfileHeader, + }, + { + name: "container with invalid port ignored", + containers: []api.ServiceContainer{ + newContainer("10.210.0.2", "invalid-port"), + }, + want: caddyfileHeader, + }, + { + name: "containers with unsupported protocols and host mode ignored", + containers: []api.ServiceContainer{ + newContainer("10.210.0.2", "5000/tcp"), + newContainer("10.210.0.3", "5000/udp"), + newContainer("10.210.0.4", "80:8080/tcp@host"), + }, + want: caddyfileHeader, + }, + { + name: "restarting container ignored", + containers: []api.ServiceContainer{ + newRestartingContainer("10.210.0.2", "app.example.com:8080/http"), + }, + want: caddyfileHeader, + }, + { + name: "stopped container ignored", + containers: []api.ServiceContainer{ + newStoppedContainer("10.210.0.2", "app.example.com:8080/http"), + }, + want: caddyfileHeader, + }, + { + name: "mix of running, restarting, and stopped containers", + containers: []api.ServiceContainer{ + newContainer("10.210.0.2", "app.example.com:8080/http"), + newRestartingContainer("10.210.0.3", "app.example.com:8080/http"), + newStoppedContainer("10.210.0.4", "app.example.com:8080/http"), + }, + want: caddyfileHeader + ` +http://app.example.com { + reverse_proxy { + to 10.210.0.2:8080 + import common_proxy + } + log +} +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := GenerateCaddyfile(tt.containers, "verification-response-body") + + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + + assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match") + }) + } +} diff --git a/internal/machine/caddyconfig/jsonconfig.go b/internal/machine/caddyconfig/jsonconfig.go index 00b8965d..e8874ac4 100644 --- a/internal/machine/caddyconfig/jsonconfig.go +++ b/internal/machine/caddyconfig/jsonconfig.go @@ -4,9 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" "maps" - "net" "net/http" "slices" "strconv" @@ -20,46 +18,7 @@ import ( ) func GenerateJSONConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) { - // Maps hostnames to lists of upstreams (container IP:port pairs). - httpHostUpstreams := make(map[string][]string) - httpsHostUpstreams := make(map[string][]string) - for _, ctr := range containers { - if !ctr.Healthy() { - continue - } - - ip := ctr.UncloudNetworkIP() - if !ip.IsValid() { - // Container is not connected to the uncloud Docker network (could be host network). - continue - } - log := slog.With("container", ctr.ID) - - ports, err := ctr.ServicePorts() - if err != nil { - log.Error("Failed to parse service ports for container.", "err", err) - continue - } - - for _, port := range ports { - if port.Mode != api.PortModeIngress { - continue - } - - switch port.Protocol { - case api.ProtocolHTTP: - upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort))) - httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream) - case api.ProtocolHTTPS: - upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort))) - httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream) - default: - // TODO: implement L4 ingress routing for TCP and UDP. - log.Error("Unsupported protocol for ingress port.", "port", port) - continue - } - } - } + httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers) var warnings []caddyconfig.Warning servers := make(map[string]*caddyhttp.Server) diff --git a/internal/machine/caddyconfig/jsonconfig_test.go b/internal/machine/caddyconfig/jsonconfig_test.go index aae03aa7..a3c928d3 100644 --- a/internal/machine/caddyconfig/jsonconfig_test.go +++ b/internal/machine/caddyconfig/jsonconfig_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestGenerateConfig(t *testing.T) { +func TestGenerateJSONConfig(t *testing.T) { configWithoutServices := `{ "servers": { "http": {