mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
chore: add Caddy config to ServiceSpec, load x-caddy to it
This commit is contained in:
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user