chore: add Caddy config to ServiceSpec, load x-caddy to it

This commit is contained in:
Pasha Sviderski
2025-08-13 18:44:38 +10:00
parent ec73f9ecd8
commit 12c07812a2
8 changed files with 341 additions and 9 deletions
+8
View File
@@ -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
}
+20 -2
View File
@@ -6,6 +6,7 @@ import (
"maps" "maps"
"regexp" "regexp"
"slices" "slices"
"strings"
"github.com/distribution/reference" "github.com/distribution/reference"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
@@ -42,6 +43,10 @@ func ValidateServiceID(id string) bool {
// ServiceSpec defines the desired state of a service. // ServiceSpec defines the desired state of a service.
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated. // ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
type ServiceSpec struct { 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 Container ContainerSpec
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty. // Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
Mode string Mode string
@@ -49,6 +54,7 @@ type ServiceSpec struct {
// Placement defines the placement constraints for the service. // Placement defines the placement constraints for the service.
Placement Placement Placement Placement
// Ports defines what service ports to publish to make the service accessible outside the cluster. // Ports defines what service ports to publish to make the service accessible outside the cluster.
// Caddy and Ports cannot be specified simultaneously.
Ports []PortSpec Ports []PortSpec
// Replicas is the number of containers to run for the service. Only valid for a replicated service. // Replicas is the number of containers to run for the service. Only valid for a replicated service.
Replicas uint `json:",omitempty"` 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) return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
} }
if !dnsLabelRegexp.MatchString(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 { for _, p := range s.Ports {
if (p.Mode == "" || p.Mode == PortModeIngress) && if (p.Mode == "" || p.Mode == PortModeIngress) &&
p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS { p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS {
@@ -151,11 +164,16 @@ func (s *ServiceSpec) Validate() error {
func (s *ServiceSpec) Clone() ServiceSpec { func (s *ServiceSpec) Clone() ServiceSpec {
spec := *s spec := *s
if s.Caddy != nil {
caddyCopy := *s.Caddy
spec.Caddy = &caddyCopy
}
spec.Container = s.Container.Clone()
if s.Ports != nil { if s.Ports != nil {
spec.Ports = make([]PortSpec, len(s.Ports)) spec.Ports = make([]PortSpec, len(s.Ports))
copy(spec.Ports, s.Ports) copy(spec.Ports, s.Ports)
} }
spec.Container = s.Container.Clone()
if s.Volumes != nil { if s.Volumes != nil {
spec.Volumes = make([]VolumeSpec, len(s.Volumes)) spec.Volumes = make([]VolumeSpec, len(s.Volumes))
+104
View File
@@ -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)
}
})
}
}
+20 -5
View File
@@ -12,7 +12,7 @@ func TestCaddyExtension(t *testing.T) {
name string name string
composeYAML string composeYAML string
wantConfig string wantConfig string
wantErr bool wantErr string
}{ }{
{ {
name: "x-caddy as string", name: "x-caddy as string",
@@ -93,7 +93,7 @@ services:
} }
unknown_field: "should cause error" unknown_field: "should cause error"
`, `,
wantErr: true, wantErr: "invalid keys: unknown_field",
}, },
{ {
name: "x-caddy with non-string config field should fail", name: "x-caddy with non-string config field should fail",
@@ -104,7 +104,22 @@ services:
x-caddy: x-caddy:
config: 123 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) { t.Run(tt.name, func(t *testing.T) {
project, err := loadProjectFromContent(t, tt.composeYAML) project, err := loadProjectFromContent(t, tt.composeYAML)
if tt.wantErr { if tt.wantErr != "" {
require.Error(t, err, "expected error for test case with invalid extension") require.ErrorContains(t, err, tt.wantErr)
return return
} }
+5
View File
@@ -49,5 +49,10 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
return nil, err return nil, err
} }
// Validate extension combinations after all transformations.
if err = validateServicesExtensions(project); err != nil {
return nil, err
}
return project, nil return project, nil
} }
+29
View File
@@ -57,6 +57,12 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
Mode: api.ServiceModeReplicated, 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 { if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
spec.Ports = ports spec.Ports = ports
} }
@@ -242,3 +248,26 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol
return spec 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
}
+143 -2
View File
@@ -2,6 +2,7 @@ package compose
import ( import (
"context" "context"
"net/netip"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@@ -54,6 +55,11 @@ func loadProjectFromContent(t *testing.T, content string) (*types.Project, error
return nil, err return nil, err
} }
// Validate extension combinations after all transformations.
if err = validateServicesExtensions(project); err != nil {
return nil, err
}
return project, nil 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, Replicas: 3,
Volumes: []api.VolumeSpec{ 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) return strings.Compare(a.Name, b.Name)
}) })
assert.True(t, cmp.Equal(spec, expectedSpec, cmpopts.EquateEmpty()), cmpOpts := cmp.Options{cmpopts.EquateEmpty(), cmpopts.EquateComparable(netip.Addr{})}
cmp.Diff(spec, expectedSpec, cmpopts.EquateEmpty())) 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) { func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+12
View File
@@ -31,6 +31,18 @@ services:
target: /tmpfs target: /tmpfs
tmpfs: tmpfs:
size: 10485760 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: volumes:
data1: data1: