Skip to content

Commit 12c0781

Browse files
committed
chore: add Caddy config to ServiceSpec, load x-caddy to it
1 parent ec73f9e commit 12c0781

File tree

8 files changed

+341
-9
lines changed

8 files changed

+341
-9
lines changed

pkg/api/caddy.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package api
2+
3+
// CaddySpec is the Caddy reverse proxy configuration for a service.
4+
type CaddySpec struct {
5+
// Config contains the Caddy config (Caddyfile) content. It must not conflict with the Caddy configs
6+
// of other services.
7+
Config string
8+
}

pkg/api/service.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"maps"
77
"regexp"
88
"slices"
9+
"strings"
910

1011
"github.com/distribution/reference"
1112
"github.com/google/go-cmp/cmp"
@@ -42,13 +43,18 @@ func ValidateServiceID(id string) bool {
4243
// ServiceSpec defines the desired state of a service.
4344
// ATTENTION: after changing this struct, verify if deploy.EvalContainerSpecChange needs to be updated.
4445
type ServiceSpec struct {
46+
// Caddy is the optional Caddy reverse proxy configuration for the service.
47+
// Caddy and Ports cannot be specified simultaneously.
48+
Caddy *CaddySpec `json:",omitempty"`
49+
// Container defines the desired state of each container in the service.
4550
Container ContainerSpec
4651
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
4752
Mode string
4853
Name string
4954
// Placement defines the placement constraints for the service.
5055
Placement Placement
5156
// Ports defines what service ports to publish to make the service accessible outside the cluster.
57+
// Caddy and Ports cannot be specified simultaneously.
5258
Ports []PortSpec
5359
// Replicas is the number of containers to run for the service. Only valid for a replicated service.
5460
Replicas uint `json:",omitempty"`
@@ -112,10 +118,17 @@ func (s *ServiceSpec) Validate() error {
112118
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
113119
}
114120
if !dnsLabelRegexp.MatchString(s.Name) {
115-
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)
121+
return fmt.Errorf("invalid service name: %q. must be 1-63 characters, lowercase letters, numbers, "+
122+
"and dashes only; must start and end with a letter or number", s.Name)
116123
}
117124
}
118125

126+
// Validate that Caddy and Ports are not used together.
127+
if s.Caddy != nil && strings.TrimSpace(s.Caddy.Config) != "" && len(s.Ports) > 0 {
128+
return fmt.Errorf("ports and Caddy configuration cannot be specified simultaneously: " +
129+
"Caddy config is auto-generated from ports, use only one of them")
130+
}
131+
119132
for _, p := range s.Ports {
120133
if (p.Mode == "" || p.Mode == PortModeIngress) &&
121134
p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS {
@@ -151,11 +164,16 @@ func (s *ServiceSpec) Validate() error {
151164
func (s *ServiceSpec) Clone() ServiceSpec {
152165
spec := *s
153166

167+
if s.Caddy != nil {
168+
caddyCopy := *s.Caddy
169+
spec.Caddy = &caddyCopy
170+
}
171+
spec.Container = s.Container.Clone()
172+
154173
if s.Ports != nil {
155174
spec.Ports = make([]PortSpec, len(s.Ports))
156175
copy(spec.Ports, s.Ports)
157176
}
158-
spec.Container = s.Container.Clone()
159177

160178
if s.Volumes != nil {
161179
spec.Volumes = make([]VolumeSpec, len(s.Volumes))

pkg/api/service_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package api
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestServiceSpec_Validate_CaddyAndPorts(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
spec ServiceSpec
13+
wantErr string
14+
}{
15+
{
16+
name: "valid with neither Caddy nor Ports",
17+
spec: ServiceSpec{
18+
Name: "test",
19+
Container: ContainerSpec{
20+
Image: "nginx:latest",
21+
},
22+
},
23+
wantErr: "",
24+
},
25+
{
26+
name: "valid with Caddy only",
27+
spec: ServiceSpec{
28+
Name: "test",
29+
Container: ContainerSpec{
30+
Image: "nginx:latest",
31+
},
32+
Caddy: &CaddySpec{
33+
Config: "example.com {\n reverse_proxy :8080\n}",
34+
},
35+
},
36+
wantErr: "",
37+
},
38+
{
39+
name: "valid with Ports only",
40+
spec: ServiceSpec{
41+
Name: "test",
42+
Container: ContainerSpec{
43+
Image: "nginx:latest",
44+
},
45+
Ports: []PortSpec{
46+
{
47+
ContainerPort: 80,
48+
Protocol: ProtocolHTTP,
49+
},
50+
},
51+
},
52+
wantErr: "",
53+
},
54+
{
55+
name: "valid with empty Caddy config and Ports",
56+
spec: ServiceSpec{
57+
Name: "test",
58+
Container: ContainerSpec{
59+
Image: "nginx:latest",
60+
},
61+
Caddy: &CaddySpec{
62+
Config: "",
63+
},
64+
Ports: []PortSpec{
65+
{
66+
ContainerPort: 80,
67+
Protocol: ProtocolHTTP,
68+
},
69+
},
70+
},
71+
wantErr: "",
72+
},
73+
{
74+
name: "invalid with both Caddy and Ports",
75+
spec: ServiceSpec{
76+
Name: "test",
77+
Container: ContainerSpec{
78+
Image: "nginx:latest",
79+
},
80+
Caddy: &CaddySpec{
81+
Config: "example.com {\n reverse_proxy :8080\n}",
82+
},
83+
Ports: []PortSpec{
84+
{
85+
ContainerPort: 80,
86+
Protocol: ProtocolHTTP,
87+
},
88+
},
89+
},
90+
wantErr: "ports and Caddy configuration cannot be specified simultaneously",
91+
},
92+
}
93+
94+
for _, tt := range tests {
95+
t.Run(tt.name, func(t *testing.T) {
96+
err := tt.spec.Validate()
97+
if tt.wantErr == "" {
98+
require.NoError(t, err)
99+
} else {
100+
require.ErrorContains(t, err, tt.wantErr)
101+
}
102+
})
103+
}
104+
}

pkg/client/compose/caddy_test.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ func TestCaddyExtension(t *testing.T) {
1212
name string
1313
composeYAML string
1414
wantConfig string
15-
wantErr bool
15+
wantErr string
1616
}{
1717
{
1818
name: "x-caddy as string",
@@ -93,7 +93,7 @@ services:
9393
}
9494
unknown_field: "should cause error"
9595
`,
96-
wantErr: true,
96+
wantErr: "invalid keys: unknown_field",
9797
},
9898
{
9999
name: "x-caddy with non-string config field should fail",
@@ -104,16 +104,31 @@ services:
104104
x-caddy:
105105
config: 123
106106
`,
107-
wantErr: true,
107+
wantErr: "expected type 'string'",
108+
},
109+
{
110+
name: "x-caddy with x-ports conflict",
111+
composeYAML: `
112+
services:
113+
web:
114+
image: nginx
115+
x-caddy: |
116+
example.com {
117+
reverse_proxy web:80
118+
}
119+
x-ports:
120+
- example.com:80/http
121+
`,
122+
wantErr: "cannot specify both 'x-caddy' and 'x-ports'",
108123
},
109124
}
110125

111126
for _, tt := range tests {
112127
t.Run(tt.name, func(t *testing.T) {
113128
project, err := loadProjectFromContent(t, tt.composeYAML)
114129

115-
if tt.wantErr {
116-
require.Error(t, err, "expected error for test case with invalid extension")
130+
if tt.wantErr != "" {
131+
require.ErrorContains(t, err, tt.wantErr)
117132
return
118133
}
119134

pkg/client/compose/project.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,10 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
4949
return nil, err
5050
}
5151

52+
// Validate extension combinations after all transformations.
53+
if err = validateServicesExtensions(project); err != nil {
54+
return nil, err
55+
}
56+
5257
return project, nil
5358
}

pkg/client/compose/service.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
5757
Mode: api.ServiceModeReplicated,
5858
}
5959

60+
// Map x-caddy extension to spec.Caddy if specified.
61+
if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" {
62+
spec.Caddy = &api.CaddySpec{
63+
Config: caddy.Config,
64+
}
65+
}
6066
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok {
6167
spec.Ports = ports
6268
}
@@ -242,3 +248,26 @@ func tmpfsVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig) api.Vol
242248

243249
return spec
244250
}
251+
252+
// validateServicesExtensions validates extension combinations across all services in the project.
253+
func validateServicesExtensions(project *types.Project) error {
254+
for _, service := range project.Services {
255+
// Check for x-caddy and x-ports conflict.
256+
hasCaddy := false
257+
if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" {
258+
hasCaddy = true
259+
}
260+
261+
hasPorts := false
262+
if ports, ok := service.Extensions[PortsExtensionKey].([]api.PortSpec); ok && len(ports) > 0 {
263+
hasPorts = true
264+
}
265+
266+
if hasCaddy && hasPorts {
267+
return fmt.Errorf("service '%s' cannot specify both 'x-caddy' and 'x-ports': "+
268+
"Caddy config is auto-generated from ports, use only one of them", service.Name)
269+
}
270+
}
271+
272+
return nil
273+
}

0 commit comments

Comments
 (0)