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
+20 -5
View File
@@ -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
}
+5
View File
@@ -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
}
+29
View File
@@ -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
}
+143 -2
View File
@@ -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
+12
View File
@@ -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: