From 5cc005a4237bf7e5367e4570cce5e5f90748d155 Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Thu, 21 Aug 2025 08:18:59 +1000 Subject: [PATCH] feat: validate and append custom per-service Caddy configs to generated Caddyfile --- internal/machine/caddyconfig/caddyfile.go | 113 +++- .../machine/caddyconfig/caddyfile_test.go | 615 +++++++++++++++++- internal/machine/caddyconfig/jsonconfig.go | 2 +- internal/machine/caddyconfig/mocks_test.go | 28 +- internal/machine/caddyconfig/template.go | 77 +++ 5 files changed, 795 insertions(+), 40 deletions(-) create mode 100644 internal/machine/caddyconfig/template.go diff --git a/internal/machine/caddyconfig/caddyfile.go b/internal/machine/caddyconfig/caddyfile.go index 95a17600..9020e7a1 100644 --- a/internal/machine/caddyconfig/caddyfile.go +++ b/internal/machine/caddyconfig/caddyfile.go @@ -17,6 +17,7 @@ import ( "github.com/psviderski/uncloud/pkg/api" ) +// TODO: change upstreams from 'to' to the directive arguments. const caddyfileTemplate = `http:// { handle {{.VerifyPath}} { respond "{{.VerifyResponse}}" 200 @@ -75,6 +76,7 @@ func NewCaddyfileGenerator(machineID string, validator CaddyfileValidator, log * } // Generate creates a Caddyfile configuration based on the provided service containers. +// The Caddyfile is generated from the service ports of the healthy containers. // If a 'caddy' service container is running on this machine and defines a custom Caddy config (x-caddy) in its service // spec, it will be validated and prepended to the generated Caddyfile. Custom Caddy configs (x-caddy) defined in other // service specs are validated and appended to the generated Caddyfile. Invalid configs are logged and skipped to ensure @@ -92,11 +94,11 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta for i, cr := range records { containers[i] = cr.Container } - // Sort containers by service name and container ID to generate a stable Caddyfile. - slices.SortFunc(containers, func(a, b api.ServiceContainer) int { + // Sort containers by service name and creation time to generate a stable Caddyfile. + slices.SortStableFunc(containers, func(a, b api.ServiceContainer) int { return cmp.Or( strings.Compare(a.ServiceName(), b.ServiceName()), - strings.Compare(a.ID, b.ID), + a.CreatedTime().Compare(b.CreatedTime()), ) }) @@ -105,26 +107,38 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta return "", fmt.Errorf("generate base Caddyfile from service ports: %w", err) } + upstreams := serviceUpstreams(containers) + // Find the 'caddy' service container on this machine. Use the most recent one if multiple exist. var caddyCtr *api.ServiceContainer for _, cr := range records { if cr.MachineID == g.machineID && cr.Container.ServiceName() == CaddyServiceName && - (caddyCtr == nil || cr.Container.Created > caddyCtr.Created) { + (caddyCtr == nil || cr.Container.CreatedTime().Compare(caddyCtr.CreatedTime()) > 0) { caddyCtr = &cr.Container } } - // If the caddy container is running on this machine and has a custom Caddy config, prepend it to the generated - // Caddyfile and validate it. + // If the caddy container is running on this machine and has a custom Caddy config (global), + // prepend it to the generated Caddyfile and validate it. if caddyCtr != nil && caddyCtr.ServiceSpec.CaddyConfig() != "" { - // TODO: render the template actions in the Caddy config. - caddyfileCandidate := caddyCtr.ServiceSpec.CaddyConfig() + "\n\n" + caddyfile - - if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil { - g.log.Error("Custom Caddy config on the caddy container on this machine is invalid, skipping it.", + // Render the custom global Caddy config as a Go template with the upstreams. + tmplCtx := templateContext{ + Name: caddyCtr.ServiceName(), + Upstreams: upstreams, + } + renderedConfig, err := renderCaddyfile(tmplCtx, caddyCtr.ServiceSpec.CaddyConfig()) + if err != nil { + g.log.Error("Failed to render template directives in custom global Caddy config, skipping it.", "service", caddyCtr.ServiceName(), "container", caddyCtr.ID, "err", err) } else { - caddyfile = caddyfileCandidate + caddyfileCandidate := renderedConfig + "\n\n" + caddyfile + + if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil { + g.log.Error("Custom global Caddy config is invalid, skipping it.", + "service", caddyCtr.ServiceName(), "container", caddyCtr.ID, "err", err) + } else { + caddyfile = caddyfileCandidate + } } } @@ -134,7 +148,7 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta latestServiceContainers := make(map[string]api.ServiceContainer, len(containers)) for _, ctr := range containers { if latest, ok := latestServiceContainers[ctr.ServiceName()]; ok { - if ctr.Created > latest.Created { + if ctr.CreatedTime().Compare(latest.CreatedTime()) > 0 { latestServiceContainers[ctr.ServiceName()] = ctr } } else { @@ -143,6 +157,8 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta } sortedServiceNames := slices.Sorted(maps.Keys(latestServiceContainers)) + // Append a custom Caddy config for each service to the Caddyfile and validate it. If the config for a service + // is invalid, skip it but continue processing other services to ensure the Caddyfile remains valid. for _, serviceName := range sortedServiceNames { // Skip the caddy container as we already processed it. if serviceName == CaddyServiceName { @@ -154,29 +170,37 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta continue } - // TODO: render the template actions in the Caddy config. - caddyfileCandidate := fmt.Sprintf("%s\n# Service: %s\n%s\n", - caddyfile, ctr.ServiceName(), ctr.ServiceSpec.CaddyConfig()) - - if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil { - g.log.Error("Custom Caddy config for service is invalid, skipping it.", - "service", ctr.ServiceName(), "err", err) + // Render the template actions in the service's Caddy config. + tmplCtx := templateContext{ + Name: serviceName, + Upstreams: upstreams, + } + renderedConfig, err := renderCaddyfile(tmplCtx, ctr.ServiceSpec.CaddyConfig()) + if err != nil { + g.log.Error("Failed to render template directives in custom Caddy config for service, skipping it.", + "service", serviceName, "err", err) continue } - caddyfile = caddyfileCandidate + caddyfileCandidate := fmt.Sprintf("%s\n# Service: %s\n%s\n", caddyfile, serviceName, renderedConfig) + if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil { + g.log.Error("Custom Caddy config for service is invalid, skipping it.", + "service", serviceName, "err", err) + } else { + caddyfile = caddyfileCandidate + } } return caddyfile, nil } func (g *CaddyfileGenerator) generateBaseFromPorts(containers []api.ServiceContainer) (string, error) { - httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers) + httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromPorts(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) + return "", fmt.Errorf("parse Caddyfile template: %w", err) } data := struct { @@ -193,15 +217,15 @@ func (g *CaddyfileGenerator) generateBaseFromPorts(containers []api.ServiceConta var buf bytes.Buffer if err = tmpl.Execute(&buf, data); err != nil { - return "", fmt.Errorf("failed to execute Caddyfile template: %w", err) + return "", fmt.Errorf("execute Caddyfile template: %w", err) } return buf.String(), nil } -// httpUpstreamsFromContainers extracts upstreams for HTTP and HTTPS protocols from the published ports of the provided +// httpUpstreamsFromPorts extracts upstreams for HTTP and HTTPS protocols from the published ports of the provided // service containers. It's expected that all containers are healthy. -func httpUpstreamsFromContainers(containers []api.ServiceContainer) (map[string][]string, map[string][]string) { +func httpUpstreamsFromPorts(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) @@ -241,3 +265,40 @@ func httpUpstreamsFromContainers(containers []api.ServiceContainer) (map[string] return httpHostUpstreams, httpsHostUpstreams } + +// serviceUpstreams creates a map of service names to their container IPs. +// Only includes containers connected to the uncloud Docker network. +func serviceUpstreams(containers []api.ServiceContainer) map[string][]string { + upstreams := make(map[string][]string) + for _, ctr := range containers { + ip := ctr.UncloudNetworkIP() + if !ip.IsValid() { + // Container is not connected to the uncloud Docker network (could be host network). + continue + } + + serviceName := ctr.ServiceName() + upstreams[serviceName] = append(upstreams[serviceName], ip.String()) + } + + return upstreams +} + +// renderCaddyfile renders a Caddyfile template with the upstreams function and data. +func renderCaddyfile(tmplCtx templateContext, caddyfile string) (string, error) { + funcs := template.FuncMap{ + "upstreams": upstreamsTemplateFn(tmplCtx), + } + + tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfile) + if err != nil { + return "", fmt.Errorf("parse config as Go template: %w", err) + } + + var buf bytes.Buffer + if err = tmpl.Execute(&buf, tmplCtx); err != nil { + return "", fmt.Errorf("execute template: %w", err) + } + + return buf.String(), nil +} diff --git a/internal/machine/caddyconfig/caddyfile_test.go b/internal/machine/caddyconfig/caddyfile_test.go index 7e171e1e..56c02824 100644 --- a/internal/machine/caddyconfig/caddyfile_test.go +++ b/internal/machine/caddyconfig/caddyfile_test.go @@ -2,11 +2,19 @@ package caddyconfig import ( "context" + "errors" + "strings" "testing" + "time" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/psviderski/uncloud/internal/machine/docker" "github.com/psviderski/uncloud/internal/machine/store" "github.com/psviderski/uncloud/pkg/api" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -26,9 +34,6 @@ func TestCaddyfileGenerator(t *testing.T) { } ` - // TODO: mock validator - generator := NewCaddyfileGenerator("test-machine-id", nil, nil) - tests := []struct { name string containers []store.ContainerRecord @@ -169,6 +174,541 @@ https://secure.example.com { ctx := context.Background() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Validator is not expected to be called in these tests. + generator := NewCaddyfileGenerator("test-machine-id", nil, nil) + + config, err := generator.Generate(ctx, tt.containers) + + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + + assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match") + }) + } +} + +func TestCaddyfileGeneratorWithCustomConfigs(t *testing.T) { + caddyfileBase := `http:// { + handle /.uncloud-verify { + respond "test-machine-id" 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 []store.ContainerRecord + want string + wantErr bool + }{ + { + name: "caddy service with valid global config", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "caddy", + "10.210.0.2", + `# Global Caddy configuration +{ + global directive +}`, + "test-machine-id", + time.Now(), + ), + }, + want: `# Global Caddy configuration +{ + global directive +} + +` + caddyfileBase, + }, + { + name: "regular service with valid custom config", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "web", + "10.210.0.2", + `# Custom config for web service +web.example.com { + reverse_proxy web:3000 +}`, + "test-machine-id", + time.Now(), + ), + }, + want: caddyfileBase + ` +# Service: web +# Custom config for web service +web.example.com { + reverse_proxy web:3000 +} +`, + }, + { + name: "service with invalid config is skipped", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "bad-service", + "10.210.0.2", + `# test:invalid +bad.config.com { + respond "This config is invalid" +}`, + "test-machine-id", + time.Now(), + ), + }, + want: caddyfileBase, + }, + { + name: "service with invalid config template is skipped", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "bad-template", + "10.210.0.2", + ` +bad.template.com { + reverse_proxy {{upstreams +}`, + "test-machine-id", + time.Now(), + ), + }, + want: caddyfileBase, + }, + { + name: "caddy service with invalid global config is skipped", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "caddy", + "10.210.0.2", + `# test:invalid +localhost { + respond "Invalid global config" +}`, + "test-machine-id", + time.Now(), + ), + }, + want: caddyfileBase, + }, + { + name: "caddy service on different machine is ignored", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "caddy", + "10.210.0.2", + `# Global config from other machine +{ + global directive +}`, + "other-machine-id", + time.Now(), + ), + }, + want: caddyfileBase, + }, + { + name: "multiple services with mixed valid and invalid configs", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "api", + "10.210.0.2", + `api.example.com { + reverse_proxy api:8080 +}`, + "test-machine-id", + time.Now(), + ), + newContainerRecordWithCaddyConfig( + "invalid-svc", + "10.210.0.3", + `# test:invalid +bad.example.com { + respond "Invalid" +}`, + "test-machine-id", + time.Now(), + ), + newContainerRecordWithCaddyConfig( + "web", + "10.210.0.4", + `web.example.com { + reverse_proxy web:3000 +}`, + "test-machine-id", + time.Now(), + ), + }, + want: caddyfileBase + ` +# Service: api +api.example.com { + reverse_proxy api:8080 +} + +# Service: web +web.example.com { + reverse_proxy web:3000 +} +`, + }, + { + name: "combined: caddy global config + service configs + ports", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "caddy", + "10.210.0.1", + `# Global config +{ + global directive +}`, + "test-machine-id", + time.Now(), + ), + newContainerRecordWithPorts( + "app", + "10.210.0.2", + []string{"app.example.com:8080/http"}, + "test-machine-id", + ), + newContainerRecordWithCaddyConfig( + "api", + "10.210.0.3", + `api.example.com { + reverse_proxy api:8000 +}`, + "other-machine-id", + time.Now(), + ), + }, + want: `# Global config +{ + global directive +} + +` + caddyfileBase + ` +http://app.example.com { + reverse_proxy { + to 10.210.0.2:8080 + import common_proxy + } + log +} + +# Service: api +api.example.com { + reverse_proxy api:8000 +} +`, + }, + { + name: "service with template directives using upstreams", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "web", + "10.210.0.2", + `web.example.com { + reverse_proxy {{upstreams}} +}`, + "test-machine-id", + time.Now(), + ), + newContainerRecordWithPorts( + "api", + "10.210.0.3", + []string{"api.example.com:8080/http"}, + "test-machine-id", + ), + }, + want: caddyfileBase + ` +http://api.example.com { + reverse_proxy { + to 10.210.0.3:8080 + import common_proxy + } + log +} + +# Service: web +web.example.com { + reverse_proxy 10.210.0.2 +} +`, + }, + { + name: "only most recent container config is used per service", + containers: []store.ContainerRecord{ + newContainerRecordWithCaddyConfig( + "web", + "10.210.0.2", + `# Old config +old.example.com { + respond "Old" +}`, + "test-machine-id", + time.Now().Add(-1*time.Hour), + ), + newContainerRecordWithCaddyConfig( + "web", + "10.210.0.3", + `# New config +new.example.com { + respond "New" +}`, + "test-machine-id", + time.Now(), + ), + }, + want: caddyfileBase + ` +# Service: web +# New config +new.example.com { + respond "New" +} +`, + }, + { + name: "compound test: upstreams variants, global caddy, and multi-machine services", + containers: []store.ContainerRecord{ + // Global Caddy service on test-machine-id + newContainerRecordWithCaddyConfig( + "caddy", + "10.210.1.1", + `# Global config from test machine +{ + admin off +} + +localhost:8080 { + respond "Admin panel" +}`, + "test-machine-id", + time.Now(), + ), + // Another caddy on different machine (should be ignored) + newContainerRecordWithCaddyConfig( + "caddy", + "10.210.2.1", + `# Should be ignored +{ + debug +}`, + "machine-2", + time.Now(), + ), + + // API service containers across different machines + newContainerRecordWithPorts("api", "10.210.1.2", []string{"api.example.com:8080/http"}, + "test-machine-id"), + newContainerRecordWithPorts("api", "10.210.2.2", []string{"api.example.com:8080/http"}, "machine-2"), + newContainerRecordWithPorts("api", "10.210.3.2", []string{"api.example.com:8080/http"}, "machine-3"), + + // Web service with different versions on different machines + newContainerRecordWithCaddyConfig( + "web", + "10.210.1.3", + `# Web service config v1 (older) +web-v1.example.com { + reverse_proxy web:3000 +}`, + "test-machine-id", + time.Now().Add(-2*time.Hour), + ), + newContainerRecordWithPorts("web", "10.210.3.3", []string{"web.example.com:3000/http"}, "machine-3"), + newContainerRecordWithCaddyConfig( + "web", + "10.210.2.3", + `# Web service config v2 (most recent) +web-v2.example.com { + reverse_proxy {{upstreams 8080}} +}`, + "machine-2", + time.Now().Add(1*time.Second), + ), + + // DB service with custom config + newContainerRecordWithCaddyConfig( + "db", + "10.210.1.4", + `# DB admin panel +dbadmin.example.com { + basicauth { + admin $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG + } + reverse_proxy {{upstreams 5432}} +}`, + "test-machine-id", + time.Now(), + ), + + // Gateway service with various upstream template usages + newContainerRecordWithCaddyConfig( + "gateway", + "10.210.1.5", + `# Testing different upstream template functions +gateway.example.com { + # Current service upstreams (gateway) + handle /self { + reverse_proxy {{upstreams}} + } + + # Named service upstreams without port + handle /api { + reverse_proxy {{upstreams "api"}} + } + + # Named service upstreams with port + handle /api-custom { + reverse_proxy {{upstreams "api" 9000}} + } + + # Current service with name and port + handle /self-port { + reverse_proxy {{upstreams .Name 8888}} + } + + # Service with mixed containers (web) and advanced template + handle /web { + reverse_proxy {{- range $up := index .Upstreams "web"}} {{$up}}{{end}} + } + + # Non-existent service + handle /missing { + reverse_proxy {{upstreams "nonexistent"}} + } +}`, + "test-machine-id", + time.Now(), + ), + + // App service with just ports (no custom config) + newContainerRecordWithPorts("app", "10.210.1.6", []string{"app.example.com:3000/http"}, + "test-machine-id"), + newContainerRecordWithPorts("app", "10.210.2.6", []string{"app.example.com:3000/http"}, "machine-2"), + + // Service with invalid config (should be ignored) + newContainerRecordWithCaddyConfig( + "invalid", + "10.210.1.7", + `# test:invalid +badconfig.com { + respond "This config is invalid" +}`, + "test-machine-id", + time.Now(), + ), + }, + want: `# Global config from test machine +{ + admin off +} + +localhost:8080 { + respond "Admin panel" +} + +` + caddyfileBase + ` +http://api.example.com { + reverse_proxy { + to 10.210.1.2:8080 10.210.2.2:8080 10.210.3.2:8080 + import common_proxy + } + log +} + +http://app.example.com { + reverse_proxy { + to 10.210.1.6:3000 10.210.2.6:3000 + import common_proxy + } + log +} + +http://web.example.com { + reverse_proxy { + to 10.210.3.3:3000 + import common_proxy + } + log +} + +# Service: db +# DB admin panel +dbadmin.example.com { + basicauth { + admin $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG + } + reverse_proxy 10.210.1.4:5432 +} + +# Service: gateway +# Testing different upstream template functions +gateway.example.com { + # Current service upstreams (gateway) + handle /self { + reverse_proxy 10.210.1.5 + } + + # Named service upstreams without port + handle /api { + reverse_proxy 10.210.1.2 10.210.2.2 10.210.3.2 + } + + # Named service upstreams with port + handle /api-custom { + reverse_proxy 10.210.1.2:9000 10.210.2.2:9000 10.210.3.2:9000 + } + + # Current service with name and port + handle /self-port { + reverse_proxy 10.210.1.5:8888 + } + + # Service with mixed containers (web) and advanced template + handle /web { + reverse_proxy 10.210.1.3 10.210.3.3 10.210.2.3 + } + + # Non-existent service + handle /missing { +` + "\t\treverse_proxy " + ` + } +} + +# Service: web +# Web service config v2 (most recent) +web-v2.example.com { + reverse_proxy 10.210.1.3:8080 10.210.3.3:8080 10.210.2.3:8080 +} +`, + }, + } + + ctx := context.Background() + validator := NewMockCaddyfileValidator(t) + validator.EXPECT().Validate(mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, caddyfile string) error { + if strings.Contains(caddyfile, "# test:invalid") { + return errors.New("invalid config detected") + } + return nil + }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + generator := NewCaddyfileGenerator("test-machine-id", validator, nil) + config, err := generator.Generate(ctx, tt.containers) if tt.wantErr { @@ -188,3 +728,72 @@ func newContainerRecord(ctr api.ServiceContainer, machineID string) store.Contai MachineID: machineID, } } + +func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID string, created time.Time) store.ContainerRecord { + return store.ContainerRecord{ + Container: api.ServiceContainer{ + Container: api.Container{ + ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + ID: serviceName + "-" + ip, // Add ID for stable sorting + State: &types.ContainerState{ + Running: true, + }, + Created: created.UTC().Format(time.RFC3339Nano), + }, + NetworkSettings: &types.NetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + docker.NetworkName: { + IPAddress: ip, + }, + }, + }, + Config: &container.Config{ + Labels: map[string]string{ + api.LabelServiceName: serviceName, + }, + }, + }, + }, + ServiceSpec: api.ServiceSpec{ + Caddy: &api.CaddySpec{ + Config: caddyConfig, + }, + }, + }, + MachineID: machineID, + } +} + +func newContainerRecordWithPorts(serviceName, ip string, ports []string, machineID string) store.ContainerRecord { + portsLabel := strings.Join(ports, ",") + return store.ContainerRecord{ + Container: api.ServiceContainer{ + Container: api.Container{ + ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + ID: serviceName + "-" + ip, // Add ID for stable sorting + State: &types.ContainerState{ + Running: true, + }, + Created: time.Now().UTC().Format(time.RFC3339Nano), + }, + NetworkSettings: &types.NetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + docker.NetworkName: { + IPAddress: ip, + }, + }, + }, + Config: &container.Config{ + Labels: map[string]string{ + api.LabelServiceName: serviceName, + api.LabelServicePorts: portsLabel, + }, + }, + }, + }, + }, + MachineID: machineID, + } +} diff --git a/internal/machine/caddyconfig/jsonconfig.go b/internal/machine/caddyconfig/jsonconfig.go index e8874ac4..30670bb3 100644 --- a/internal/machine/caddyconfig/jsonconfig.go +++ b/internal/machine/caddyconfig/jsonconfig.go @@ -18,7 +18,7 @@ import ( ) func GenerateJSONConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) { - httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers) + httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromPorts(containers) var warnings []caddyconfig.Warning servers := make(map[string]*caddyhttp.Server) diff --git a/internal/machine/caddyconfig/mocks_test.go b/internal/machine/caddyconfig/mocks_test.go index bdb2ccdc..ca0f73f4 100644 --- a/internal/machine/caddyconfig/mocks_test.go +++ b/internal/machine/caddyconfig/mocks_test.go @@ -5,6 +5,8 @@ package caddyconfig import ( + "context" + mock "github.com/stretchr/testify/mock" ) @@ -36,16 +38,16 @@ func (_m *MockCaddyfileValidator) EXPECT() *MockCaddyfileValidator_Expecter { } // Validate provides a mock function for the type MockCaddyfileValidator -func (_mock *MockCaddyfileValidator) Validate(caddyfile string) error { - ret := _mock.Called(caddyfile) +func (_mock *MockCaddyfileValidator) Validate(ctx context.Context, caddyfile string) error { + ret := _mock.Called(ctx, caddyfile) if len(ret) == 0 { panic("no return value specified for Validate") } var r0 error - if returnFunc, ok := ret.Get(0).(func(string) error); ok { - r0 = returnFunc(caddyfile) + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, caddyfile) } else { r0 = ret.Error(0) } @@ -58,19 +60,25 @@ type MockCaddyfileValidator_Validate_Call struct { } // Validate is a helper method to define mock.On call +// - ctx context.Context // - caddyfile string -func (_e *MockCaddyfileValidator_Expecter) Validate(caddyfile interface{}) *MockCaddyfileValidator_Validate_Call { - return &MockCaddyfileValidator_Validate_Call{Call: _e.mock.On("Validate", caddyfile)} +func (_e *MockCaddyfileValidator_Expecter) Validate(ctx interface{}, caddyfile interface{}) *MockCaddyfileValidator_Validate_Call { + return &MockCaddyfileValidator_Validate_Call{Call: _e.mock.On("Validate", ctx, caddyfile)} } -func (_c *MockCaddyfileValidator_Validate_Call) Run(run func(caddyfile string)) *MockCaddyfileValidator_Validate_Call { +func (_c *MockCaddyfileValidator_Validate_Call) Run(run func(ctx context.Context, caddyfile string)) *MockCaddyfileValidator_Validate_Call { _c.Call.Run(func(args mock.Arguments) { - var arg0 string + var arg0 context.Context if args[0] != nil { - arg0 = args[0].(string) + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) } run( arg0, + arg1, ) }) return _c @@ -81,7 +89,7 @@ func (_c *MockCaddyfileValidator_Validate_Call) Return(err error) *MockCaddyfile return _c } -func (_c *MockCaddyfileValidator_Validate_Call) RunAndReturn(run func(caddyfile string) error) *MockCaddyfileValidator_Validate_Call { +func (_c *MockCaddyfileValidator_Validate_Call) RunAndReturn(run func(ctx context.Context, caddyfile string) error) *MockCaddyfileValidator_Validate_Call { _c.Call.Return(run) return _c } diff --git a/internal/machine/caddyconfig/template.go b/internal/machine/caddyconfig/template.go new file mode 100644 index 00000000..c9618fbd --- /dev/null +++ b/internal/machine/caddyconfig/template.go @@ -0,0 +1,77 @@ +package caddyconfig + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +// templateContext holds the data available to Caddyfile templates. +type templateContext struct { + // Name is the current service name. + Name string + // Upstreams maps service names to their container IPs. + Upstreams map[string][]string +} + +// upstreamsTemplateFn returns a template function that generates a space separated string of upstreams for the service. +// It optionally accepts a service name and a port number: {{upstreams [service-name] [port]}}. +func upstreamsTemplateFn(tmplCtx templateContext) func(args ...any) (string, error) { + return func(args ...any) (string, error) { + var serviceName string + var port int + + // Parse arguments. + switch len(args) { + case 0: + // Current service, default port. + serviceName = tmplCtx.Name + case 1: + // Either port (int) for current service or service name (string). + switch arg := args[0].(type) { + case int: + serviceName = tmplCtx.Name + port = arg + case string: + serviceName = arg + port = 0 + default: + return "", fmt.Errorf("upstreams function: invalid argument type: %T", arg) + } + case 2: + // Service name and port. + name, ok := args[0].(string) + if !ok { + return "", fmt.Errorf("upstreams function: first argument must be service name (string)") + } + serviceName = name + + p, ok := args[1].(int) + if !ok { + return "", fmt.Errorf("upstreams function: second argument must be port (int)") + } + port = p + default: + return "", fmt.Errorf("upstreams function: too many arguments; expected 0-2, got %d", len(args)) + } + + ips, ok := tmplCtx.Upstreams[serviceName] + if !ok || len(ips) == 0 { + // No upstreams available. + return "", nil + } + + // Build the space separated upstreams string. + var upstreams []string + for _, ip := range ips { + if port > 0 { + upstreams = append(upstreams, net.JoinHostPort(ip, strconv.Itoa(port))) + } else { + upstreams = append(upstreams, ip) + } + } + + return strings.Join(upstreams, " "), nil + } +}