diff --git a/pkg/api/caddy.go b/pkg/api/caddy.go new file mode 100644 index 00000000..bf18c66b --- /dev/null +++ b/pkg/api/caddy.go @@ -0,0 +1,8 @@ +package api + +// CaddySpec is the Caddy reverse proxy configuration for a service. +type CaddySpec struct { + // Config contains the Caddy config (Caddyfile) content. It must not conflict with the Caddy configs + // of other services. + Config string +} diff --git a/pkg/api/service.go b/pkg/api/service.go index d2e2ff91..cc52002e 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -6,6 +6,7 @@ import ( "maps" "regexp" "slices" + "strings" "github.com/distribution/reference" "github.com/google/go-cmp/cmp" @@ -42,6 +43,10 @@ func ValidateServiceID(id string) bool { // ServiceSpec defines the desired state of a service. // ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated. type ServiceSpec struct { + // Caddy is the optional Caddy reverse proxy configuration for the service. + // Caddy and Ports cannot be specified simultaneously. + Caddy *CaddySpec `json:",omitempty"` + // Container defines the desired state of each container in the service. Container ContainerSpec // Mode is the replication mode of the service. Default is ServiceModeReplicated if empty. Mode string @@ -49,6 +54,7 @@ type ServiceSpec struct { // Placement defines the placement constraints for the service. Placement Placement // Ports defines what service ports to publish to make the service accessible outside the cluster. + // Caddy and Ports cannot be specified simultaneously. Ports []PortSpec // Replicas is the number of containers to run for the service. Only valid for a replicated service. Replicas uint `json:",omitempty"` @@ -112,10 +118,17 @@ func (s *ServiceSpec) Validate() error { return fmt.Errorf("service name too long (max 63 characters): %q", s.Name) } if !dnsLabelRegexp.MatchString(s.Name) { - return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, and dashes only; must start and end with a letter or number", s.Name) + return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, "+ + "and dashes only; must start and end with a letter or number", s.Name) } } + // Validate that Caddy and Ports are not used together. + if s.Caddy != nil && strings.TrimSpace(s.Caddy.Config) != "" && len(s.Ports) > 0 { + return fmt.Errorf("ports and Caddy configuration cannot be specified simultaneously: " + + "Caddy config is auto-generated from ports, use only one of them") + } + for _, p := range s.Ports { if (p.Mode == "" || p.Mode == PortModeIngress) && p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS { @@ -151,11 +164,16 @@ func (s *ServiceSpec) Validate() error { func (s *ServiceSpec) Clone() ServiceSpec { spec := *s + if s.Caddy != nil { + caddyCopy := *s.Caddy + spec.Caddy = &caddyCopy + } + spec.Container = s.Container.Clone() + if s.Ports != nil { spec.Ports = make([]PortSpec, len(s.Ports)) copy(spec.Ports, s.Ports) } - spec.Container = s.Container.Clone() if s.Volumes != nil { spec.Volumes = make([]VolumeSpec, len(s.Volumes)) diff --git a/pkg/api/service_test.go b/pkg/api/service_test.go new file mode 100644 index 00000000..3cafd79c --- /dev/null +++ b/pkg/api/service_test.go @@ -0,0 +1,104 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) { + tests := []struct { + name string + spec ServiceSpec + wantErr string + }{ + { + name: "valid with neither Caddy nor Ports", + spec: ServiceSpec{ + Name: "test", + Container: ContainerSpec{ + Image: "nginx:latest", + }, + }, + wantErr: "", + }, + { + name: "valid with Caddy only", + spec: ServiceSpec{ + Name: "test", + Container: ContainerSpec{ + Image: "nginx:latest", + }, + Caddy: &CaddySpec{ + Config: "example.com {\n reverse_proxy :8080\n}", + }, + }, + wantErr: "", + }, + { + name: "valid with Ports only", + spec: ServiceSpec{ + Name: "test", + Container: ContainerSpec{ + Image: "nginx:latest", + }, + Ports: []PortSpec{ + { + ContainerPort: 80, + Protocol: ProtocolHTTP, + }, + }, + }, + wantErr: "", + }, + { + name: "valid with empty Caddy config and Ports", + spec: ServiceSpec{ + Name: "test", + Container: ContainerSpec{ + Image: "nginx:latest", + }, + Caddy: &CaddySpec{ + Config: "", + }, + Ports: []PortSpec{ + { + ContainerPort: 80, + Protocol: ProtocolHTTP, + }, + }, + }, + wantErr: "", + }, + { + name: "invalid with both Caddy and Ports", + spec: ServiceSpec{ + Name: "test", + Container: ContainerSpec{ + Image: "nginx:latest", + }, + Caddy: &CaddySpec{ + Config: "example.com {\n reverse_proxy :8080\n}", + }, + Ports: []PortSpec{ + { + ContainerPort: 80, + Protocol: ProtocolHTTP, + }, + }, + }, + wantErr: "ports and Caddy configuration cannot be specified simultaneously", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.spec.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} diff --git a/pkg/client/compose/caddy_test.go b/pkg/client/compose/caddy_test.go index acf59c72..a4652669 100644 --- a/pkg/client/compose/caddy_test.go +++ b/pkg/client/compose/caddy_test.go @@ -12,7 +12,7 @@ func TestCaddyExtension(t *testing.T) { name string composeYAML string wantConfig string - wantErr bool + wantErr string }{ { name: "x-caddy as string", @@ -93,7 +93,7 @@ services: } unknown_field: "should cause error" `, - wantErr: true, + wantErr: "invalid keys: unknown_field", }, { name: "x-caddy with non-string config field should fail", @@ -104,7 +104,22 @@ services: x-caddy: config: 123 `, - wantErr: true, + wantErr: "expected type 'string'", + }, + { + name: "x-caddy with x-ports conflict", + composeYAML: ` +services: + web: + image: nginx + x-caddy: | + example.com { + reverse_proxy web:80 + } + x-ports: + - example.com:80/http +`, + wantErr: "cannot specify both 'x-caddy' and 'x-ports'", }, } @@ -112,8 +127,8 @@ services: t.Run(tt.name, func(t *testing.T) { project, err := loadProjectFromContent(t, tt.composeYAML) - if tt.wantErr { - require.Error(t, err, "expected error for test case with invalid extension") + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) return } diff --git a/pkg/client/compose/project.go b/pkg/client/compose/project.go index fcbf4e7f..41c0a85a 100644 --- a/pkg/client/compose/project.go +++ b/pkg/client/compose/project.go @@ -49,5 +49,10 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project return nil, err } + // Validate extension combinations after all transformations. + if err = validateServicesExtensions(project); err != nil { + return nil, err + } + return project, nil } diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index 7d2eff1b..928e511f 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -57,6 +57,12 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser Mode: api.ServiceModeReplicated, } + // Map x-caddy extension to spec.Caddy if specified. + if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" { + spec.Caddy = &api.CaddySpec{ + Config: caddy.Config, + } + } if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok { spec.Ports = ports } @@ -242,3 +248,26 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol return spec } + +// validateServicesExtensions validates extension combinations across all services in the project. +func validateServicesExtensions(project *types.Project) error { + for _, service := range project.Services { + // Check for x-caddy and x-ports conflict. + hasCaddy := false + if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" { + hasCaddy = true + } + + hasPorts := false + if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok && len(ports) > 0 { + hasPorts = true + } + + if hasCaddy && hasPorts { + return fmt.Errorf("service '%s' cannot specify both 'x-caddy' and 'x-ports': "+ + "Caddy config is auto-generated from ports, use only one of them", service.Name) + } + } + + return nil +} diff --git a/pkg/client/compose/service_test.go b/pkg/client/compose/service_test.go index b591303e..946c9258 100644 --- a/pkg/client/compose/service_test.go +++ b/pkg/client/compose/service_test.go @@ -2,6 +2,7 @@ package compose import ( "context" + "net/netip" "path/filepath" "slices" "strings" @@ -54,6 +55,11 @@ func loadProjectFromContent(t *testing.T, content string) (*types.Project, error return nil, err } + // Validate extension combinations after all transformations. + if err = validateServicesExtensions(project); err != nil { + return nil, err + } + return project, nil } @@ -186,6 +192,25 @@ func TestServiceSpecFromCompose(t *testing.T) { }, }, }, + Ports: []api.PortSpec{ + { + Hostname: "test.example.com", + ContainerPort: 80, + Protocol: api.ProtocolHTTPS, + Mode: api.PortModeIngress, + }, + { + ContainerPort: 8000, + Protocol: api.ProtocolHTTP, + Mode: api.PortModeIngress, + }, + { + ContainerPort: 3000, + PublishedPort: 5000, + Protocol: "tcp", + Mode: api.PortModeHost, + }, + }, Replicas: 3, Volumes: []api.VolumeSpec{ { @@ -229,6 +254,20 @@ func TestServiceSpecFromCompose(t *testing.T) { }, }, }, + "test-caddy-config": { + Name: "test-caddy-config", + Mode: api.ServiceModeReplicated, + Container: api.ContainerSpec{ + Image: "myapp:1.2.3", + PullPolicy: api.PullPolicyMissing, + }, + Caddy: &api.CaddySpec{ + Config: `test-caddy-config.example.com { + reverse_proxy {{ upstreams 80 }} +} +`, + }, + }, }, }, } @@ -249,13 +288,115 @@ func TestServiceSpecFromCompose(t *testing.T) { return strings.Compare(a.Name, b.Name) }) - assert.True(t, cmp.Equal(spec, expectedSpec, cmpopts.EquateEmpty()), - cmp.Diff(spec, expectedSpec, cmpopts.EquateEmpty())) + cmpOpts := cmp.Options{cmpopts.EquateEmpty(), cmpopts.EquateComparable(netip.Addr{})} + assert.True(t, cmp.Equal(spec, expectedSpec, cmpOpts...), cmp.Diff(spec, expectedSpec, cmpOpts...)) } }) } } +func TestServiceSpecFromCompose_Caddy(t *testing.T) { + tests := []struct { + name string + composeYAML string + want *api.CaddySpec + }{ + { + name: "x-caddy as string", + composeYAML: ` +services: + web: + image: nginx + x-caddy: | + example.com { + reverse_proxy web:80 + } +`, + want: &api.CaddySpec{ + Config: `example.com { + reverse_proxy web:80 +} +`, + }, + }, + { + name: "x-caddy as object with config field", + composeYAML: ` +services: + web: + image: nginx + x-caddy: + config: | + example.com { + reverse_proxy web:80 + } +`, + want: &api.CaddySpec{ + Config: `example.com { + reverse_proxy web:80 +} +`, + }, + }, + { + name: "x-caddy with path to Caddyfile", + composeYAML: ` +services: + web: + image: nginx + x-caddy: testdata/Caddyfile +`, + want: &api.CaddySpec{ + Config: `test.example.com { + reverse_proxy test:8000 +} +`, + }, + }, + { + name: "x-caddy with empty string", + composeYAML: ` +services: + web: + image: nginx + x-caddy: "" +`, + want: nil, + }, + { + name: "no x-caddy extension", + composeYAML: ` +services: + web: + image: nginx +`, + want: nil, + }, + { + name: "x-caddy with empty object", + composeYAML: ` +services: + web: + image: nginx + x-caddy: {} +`, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project, err := loadProjectFromContent(t, tt.composeYAML) + require.NoError(t, err) + + spec, err := ServiceSpecFromCompose(project, "web") + require.NoError(t, err) + + assert.Equal(t, tt.want, spec.Caddy) + }) + } +} + func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) { tests := []struct { name string diff --git a/pkg/client/compose/testdata/compose-full-spec.yaml b/pkg/client/compose/testdata/compose-full-spec.yaml index 25b0ea0f..3f36f393 100644 --- a/pkg/client/compose/testdata/compose-full-spec.yaml +++ b/pkg/client/compose/testdata/compose-full-spec.yaml @@ -31,6 +31,18 @@ services: target: /tmpfs tmpfs: size: 10485760 + x-ports: + - test.example.com:80/https + - 8000/http + - 5000:3000@host + + test-caddy-config: + image: myapp:1.2.3 + # x-ports and x-caddy are mutually exclusive. + x-caddy: | + test-caddy-config.example.com { + reverse_proxy {{ upstreams 80 }} + } volumes: data1: