package caddyconfig import ( "context" "errors" "strings" "testing" "time" "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" ) const testCaddyfileHeader = `# This file is autogenerated by Uncloud based on the configuration of running services. # Do not edit manually. Any manual changes will be overwritten on the next update. # Health check endpoint to verify Caddy reachability on this machine. 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 } ` func TestCaddyfileGenerator(t *testing.T) { tests := []struct { name string containers []store.ContainerRecord want string wantErr bool }{ { name: "empty containers", containers: []store.ContainerRecord{}, want: testCaddyfileHeader, }, { name: "HTTP container", containers: []store.ContainerRecord{ newContainerRecord(newContainer("10.210.0.2", "app.example.com:8080/http"), "mach1"), }, want: testCaddyfileHeader + ` # Sites generated from service ports. http://app.example.com { reverse_proxy 10.210.0.2:8080 { import common_proxy } log } `, }, { name: "load balancing multiple containers", containers: []store.ContainerRecord{ newContainerRecord(newContainer("10.210.0.2", "app.example.com:8080/http"), "mach1"), newContainerRecord(newContainer("10.210.0.3", "app.example.com:8080/http"), "mach1"), }, want: testCaddyfileHeader + ` # Sites generated from service ports. http://app.example.com { reverse_proxy 10.210.0.2:8080 10.210.0.3:8080 { import common_proxy } log } `, }, { name: "HTTPS container", containers: []store.ContainerRecord{ newContainerRecord(newContainer("10.210.0.2", "secure.example.com:8000/https"), "mach1"), }, want: testCaddyfileHeader + ` # Sites generated from service ports. https://secure.example.com { reverse_proxy 10.210.0.2:8000 { import common_proxy } log } `, }, { name: "mixed HTTP and HTTPS", containers: []store.ContainerRecord{ newContainerRecord( newContainer("10.210.0.2", "app.example.com:8080/http", "web.example.com:8000/http"), "mach1", ), newContainerRecord( newContainer("10.210.0.3", "app.example.com:8080/http", "secure.example.com:8888/https"), "mach1", ), newContainerRecord( newContainer("10.210.0.4", "web.example.com:8000/http", "secure.example.com:8888/https"), "mach1", ), newContainerRecord( newContainer("10.210.0.5", "app.example.com:8080/http", "web.example.com:8000/http", "secure.example.com:8888/https"), "mach1", ), }, want: testCaddyfileHeader + ` # Sites generated from service ports. http://app.example.com { reverse_proxy 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 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 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: []store.ContainerRecord{ newContainerRecord(newContainerWithoutNetwork("ignored.example.com:8080/http"), "mach1"), }, want: testCaddyfileHeader, }, { name: "container with invalid port ignored", containers: []store.ContainerRecord{ newContainerRecord(newContainer("10.210.0.2", "invalid-port"), "mach1"), }, want: testCaddyfileHeader, }, { name: "containers with unsupported protocols and host mode ignored", containers: []store.ContainerRecord{ newContainerRecord(newContainer("10.210.0.2", "5000/tcp"), "mach1"), newContainerRecord(newContainer("10.210.0.3", "5000/udp"), "mach1"), newContainerRecord(newContainer("10.210.0.4", "80:8080/tcp@host"), "mach1"), }, want: testCaddyfileHeader, }, } 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, true) 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) { 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: `# This file is autogenerated by Uncloud based on the configuration of running services. # Do not edit manually. Any manual changes will be overwritten on the next update. # User-defined global config from service 'caddy'. # Global Caddy configuration { global directive } # Health check endpoint to verify Caddy reachability on this machine. 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 } `, }, { 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: testCaddyfileHeader + ` # User-defined config for 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: testCaddyfileHeader + ` # Skipped invalid user-defined configs: # - service 'bad-service': validation failed: invalid config detected `, }, { 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: testCaddyfileHeader + ` # Skipped invalid user-defined configs: # - service 'bad-template': failed to render template: parse config as Go template: template: Caddyfile:3: unexpected "}" in operand `, }, { 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: testCaddyfileHeader + ` # Skipped invalid user-defined configs: # - service 'caddy': validation failed: invalid config detected `, }, { 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: testCaddyfileHeader, }, { 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: testCaddyfileHeader + ` # User-defined config for service 'api'. api.example.com { reverse_proxy api:8080 } # User-defined config for service 'web'. web.example.com { reverse_proxy web:3000 } # Skipped invalid user-defined configs: # - service 'invalid-svc': validation failed: invalid config detected `, }, { 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: `# This file is autogenerated by Uncloud based on the configuration of running services. # Do not edit manually. Any manual changes will be overwritten on the next update. # User-defined global config from service 'caddy'. # Global config { global directive } # Health check endpoint to verify Caddy reachability on this machine. 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 } # Sites generated from service ports. http://app.example.com { reverse_proxy 10.210.0.2:8080 { import common_proxy } log } # User-defined config for 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: testCaddyfileHeader + ` # Sites generated from service ports. http://api.example.com { reverse_proxy 10.210.0.3:8080 { import common_proxy } log } # User-defined config for 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: testCaddyfileHeader + ` # User-defined config for 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 $ip := index .Upstreams "web"}} https://{{$ip}}{{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: `# This file is autogenerated by Uncloud based on the configuration of running services. # Do not edit manually. Any manual changes will be overwritten on the next update. # User-defined global config from service 'caddy'. # Global config from test machine { admin off } localhost:8080 { respond "Admin panel" } # Health check endpoint to verify Caddy reachability on this machine. 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 } # Sites generated from service ports. http://api.example.com { reverse_proxy 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 10.210.1.6:3000 10.210.2.6:3000 { import common_proxy } log } http://web.example.com { reverse_proxy 10.210.3.3:3000 { import common_proxy } log } # User-defined config for service 'db'. # DB admin panel dbadmin.example.com { basicauth { admin $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG } reverse_proxy 10.210.1.4:5432 } # User-defined config for 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 https://10.210.1.3 https://10.210.3.3 https://10.210.2.3 } # Non-existent service handle /missing { ` + "\t\treverse_proxy " + ` } } # User-defined config for 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 } # Skipped invalid user-defined configs: # - service 'invalid': validation failed: invalid config detected `, }, { name: "multiple errors: invalid global, template error, and validation error", containers: []store.ContainerRecord{ newContainerRecordWithCaddyConfig( "caddy", "10.210.0.1", `# test:invalid { invalid global }`, "test-machine-id", time.Now(), ), newContainerRecordWithCaddyConfig( "broken-template", "10.210.0.2", `broken.example.com { reverse_proxy {{upstreams "missing }`, "test-machine-id", time.Now(), ), newContainerRecordWithCaddyConfig( "invalid", "10.210.0.3", `# test:invalid invalid.example.com { respond "Invalid config" }`, "test-machine-id", time.Now(), ), newContainerRecordWithCaddyConfig( "valid", "10.210.0.4", `valid.example.com { respond "Valid config" }`, "test-machine-id", time.Now(), ), }, want: testCaddyfileHeader + ` # User-defined config for service 'valid'. valid.example.com { respond "Valid config" } # Skipped invalid user-defined configs: # - service 'caddy': validation failed: invalid config detected # - service 'broken-template': failed to render template: parse config as Go template: template: Caddyfile:2: unterminated quoted string # - service 'invalid': validation failed: invalid config detected `, }, } 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, true) if tt.wantErr { assert.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match") }) } } func newContainerRecord(ctr api.ServiceContainer, machineID string) store.ContainerRecord { return store.ContainerRecord{ Container: ctr, MachineID: machineID, } } func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID string, created time.Time) store.ContainerRecord { return store.ContainerRecord{ Container: api.ServiceContainer{ Container: api.Container{ InspectResponse: container.InspectResponse{ ContainerJSONBase: &container.ContainerJSONBase{ ID: serviceName + "-" + ip, // Add ID for stable sorting State: &container.State{ Running: true, }, Created: created.UTC().Format(time.RFC3339Nano), }, NetworkSettings: &container.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 TestCaddyfileGeneratorWithoutCustomConfigs(t *testing.T) { // Test that when includeCustom is false (Caddy not available), x-caddy configs are skipped. tests := []struct { name string containers []store.ContainerRecord want string }{ { name: "x-caddy configs are skipped", containers: []store.ContainerRecord{ newContainerRecordWithCaddyConfig( "caddy", "10.210.0.1", `# Global config { global directive }`, "test-machine-id", time.Now(), ), newContainerRecordWithCaddyConfig( "web", "10.210.0.2", `web.example.com { reverse_proxy web:3000 }`, "test-machine-id", time.Now(), ), newContainerRecordWithPorts( "api", "10.210.0.3", []string{"api.example.com:8080/http"}, "test-machine-id", ), }, want: testCaddyfileHeader + ` # Sites generated from service ports. http://api.example.com { reverse_proxy 10.210.0.3:8080 { import common_proxy } log } # NOTE: User-defined configs for services were skipped because Caddy is not running on this machine # or the latest generated config is invalid. Please check the Caddy logs if it's running. `, }, { name: "no containers with x-caddy configs", containers: []store.ContainerRecord{ newContainerRecordWithPorts( "api", "10.210.0.3", []string{"api.example.com:8080/http"}, "test-machine-id", ), }, want: testCaddyfileHeader + ` # Sites generated from service ports. http://api.example.com { reverse_proxy 10.210.0.3:8080 { import common_proxy } log } # NOTE: User-defined configs for services were skipped because Caddy is not running on this machine # or the latest generated config is invalid. Please check the Caddy logs if it's running. `, }, } 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, false) require.NoError(t, err) assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match") }) } } func newContainerRecordWithPorts(serviceName, ip string, ports []string, machineID string) store.ContainerRecord { portsLabel := strings.Join(ports, ",") return store.ContainerRecord{ Container: api.ServiceContainer{ Container: api.Container{ InspectResponse: container.InspectResponse{ ContainerJSONBase: &container.ContainerJSONBase{ ID: serviceName + "-" + ip, // Add ID for stable sorting State: &container.State{ Running: true, }, Created: time.Now().UTC().Format(time.RFC3339Nano), }, NetworkSettings: &container.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, } }