feat: validate and append custom per-service Caddy configs to generated Caddyfile

This commit is contained in:
Pasha Sviderski
2025-08-21 08:18:59 +10:00
parent 066d411367
commit 5cc005a423
5 changed files with 795 additions and 40 deletions
+612 -3
View File
@@ -2,11 +2,19 @@ package caddyconfig
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/psviderski/uncloud/internal/machine/docker"
"github.com/psviderski/uncloud/internal/machine/store"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -26,9 +34,6 @@ func TestCaddyfileGenerator(t *testing.T) {
}
`
// TODO: mock validator
generator := NewCaddyfileGenerator("test-machine-id", nil, nil)
tests := []struct {
name string
containers []store.ContainerRecord
@@ -169,6 +174,541 @@ https://secure.example.com {
ctx := context.Background()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Validator is not expected to be called in these tests.
generator := NewCaddyfileGenerator("test-machine-id", nil, nil)
config, err := generator.Generate(ctx, tt.containers)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match")
})
}
}
func TestCaddyfileGeneratorWithCustomConfigs(t *testing.T) {
caddyfileBase := `http:// {
handle /.uncloud-verify {
respond "test-machine-id" 200
}
log
}
(common_proxy) {
# Retry failed requests up to lb_retries times against other available upstreams.
lb_retries 3
# Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking).
fail_duration 30s
}
`
tests := []struct {
name string
containers []store.ContainerRecord
want string
wantErr bool
}{
{
name: "caddy service with valid global config",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"caddy",
"10.210.0.2",
`# Global Caddy configuration
{
global directive
}`,
"test-machine-id",
time.Now(),
),
},
want: `# Global Caddy configuration
{
global directive
}
` + caddyfileBase,
},
{
name: "regular service with valid custom config",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"web",
"10.210.0.2",
`# Custom config for web service
web.example.com {
reverse_proxy web:3000
}`,
"test-machine-id",
time.Now(),
),
},
want: caddyfileBase + `
# Service: web
# Custom config for web service
web.example.com {
reverse_proxy web:3000
}
`,
},
{
name: "service with invalid config is skipped",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"bad-service",
"10.210.0.2",
`# test:invalid
bad.config.com {
respond "This config is invalid"
}`,
"test-machine-id",
time.Now(),
),
},
want: caddyfileBase,
},
{
name: "service with invalid config template is skipped",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"bad-template",
"10.210.0.2",
`
bad.template.com {
reverse_proxy {{upstreams
}`,
"test-machine-id",
time.Now(),
),
},
want: caddyfileBase,
},
{
name: "caddy service with invalid global config is skipped",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"caddy",
"10.210.0.2",
`# test:invalid
localhost {
respond "Invalid global config"
}`,
"test-machine-id",
time.Now(),
),
},
want: caddyfileBase,
},
{
name: "caddy service on different machine is ignored",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"caddy",
"10.210.0.2",
`# Global config from other machine
{
global directive
}`,
"other-machine-id",
time.Now(),
),
},
want: caddyfileBase,
},
{
name: "multiple services with mixed valid and invalid configs",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"api",
"10.210.0.2",
`api.example.com {
reverse_proxy api:8080
}`,
"test-machine-id",
time.Now(),
),
newContainerRecordWithCaddyConfig(
"invalid-svc",
"10.210.0.3",
`# test:invalid
bad.example.com {
respond "Invalid"
}`,
"test-machine-id",
time.Now(),
),
newContainerRecordWithCaddyConfig(
"web",
"10.210.0.4",
`web.example.com {
reverse_proxy web:3000
}`,
"test-machine-id",
time.Now(),
),
},
want: caddyfileBase + `
# Service: api
api.example.com {
reverse_proxy api:8080
}
# Service: web
web.example.com {
reverse_proxy web:3000
}
`,
},
{
name: "combined: caddy global config + service configs + ports",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"caddy",
"10.210.0.1",
`# Global config
{
global directive
}`,
"test-machine-id",
time.Now(),
),
newContainerRecordWithPorts(
"app",
"10.210.0.2",
[]string{"app.example.com:8080/http"},
"test-machine-id",
),
newContainerRecordWithCaddyConfig(
"api",
"10.210.0.3",
`api.example.com {
reverse_proxy api:8000
}`,
"other-machine-id",
time.Now(),
),
},
want: `# Global config
{
global directive
}
` + caddyfileBase + `
http://app.example.com {
reverse_proxy {
to 10.210.0.2:8080
import common_proxy
}
log
}
# Service: api
api.example.com {
reverse_proxy api:8000
}
`,
},
{
name: "service with template directives using upstreams",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"web",
"10.210.0.2",
`web.example.com {
reverse_proxy {{upstreams}}
}`,
"test-machine-id",
time.Now(),
),
newContainerRecordWithPorts(
"api",
"10.210.0.3",
[]string{"api.example.com:8080/http"},
"test-machine-id",
),
},
want: caddyfileBase + `
http://api.example.com {
reverse_proxy {
to 10.210.0.3:8080
import common_proxy
}
log
}
# Service: web
web.example.com {
reverse_proxy 10.210.0.2
}
`,
},
{
name: "only most recent container config is used per service",
containers: []store.ContainerRecord{
newContainerRecordWithCaddyConfig(
"web",
"10.210.0.2",
`# Old config
old.example.com {
respond "Old"
}`,
"test-machine-id",
time.Now().Add(-1*time.Hour),
),
newContainerRecordWithCaddyConfig(
"web",
"10.210.0.3",
`# New config
new.example.com {
respond "New"
}`,
"test-machine-id",
time.Now(),
),
},
want: caddyfileBase + `
# Service: web
# New config
new.example.com {
respond "New"
}
`,
},
{
name: "compound test: upstreams variants, global caddy, and multi-machine services",
containers: []store.ContainerRecord{
// Global Caddy service on test-machine-id
newContainerRecordWithCaddyConfig(
"caddy",
"10.210.1.1",
`# Global config from test machine
{
admin off
}
localhost:8080 {
respond "Admin panel"
}`,
"test-machine-id",
time.Now(),
),
// Another caddy on different machine (should be ignored)
newContainerRecordWithCaddyConfig(
"caddy",
"10.210.2.1",
`# Should be ignored
{
debug
}`,
"machine-2",
time.Now(),
),
// API service containers across different machines
newContainerRecordWithPorts("api", "10.210.1.2", []string{"api.example.com:8080/http"},
"test-machine-id"),
newContainerRecordWithPorts("api", "10.210.2.2", []string{"api.example.com:8080/http"}, "machine-2"),
newContainerRecordWithPorts("api", "10.210.3.2", []string{"api.example.com:8080/http"}, "machine-3"),
// Web service with different versions on different machines
newContainerRecordWithCaddyConfig(
"web",
"10.210.1.3",
`# Web service config v1 (older)
web-v1.example.com {
reverse_proxy web:3000
}`,
"test-machine-id",
time.Now().Add(-2*time.Hour),
),
newContainerRecordWithPorts("web", "10.210.3.3", []string{"web.example.com:3000/http"}, "machine-3"),
newContainerRecordWithCaddyConfig(
"web",
"10.210.2.3",
`# Web service config v2 (most recent)
web-v2.example.com {
reverse_proxy {{upstreams 8080}}
}`,
"machine-2",
time.Now().Add(1*time.Second),
),
// DB service with custom config
newContainerRecordWithCaddyConfig(
"db",
"10.210.1.4",
`# DB admin panel
dbadmin.example.com {
basicauth {
admin $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG
}
reverse_proxy {{upstreams 5432}}
}`,
"test-machine-id",
time.Now(),
),
// Gateway service with various upstream template usages
newContainerRecordWithCaddyConfig(
"gateway",
"10.210.1.5",
`# Testing different upstream template functions
gateway.example.com {
# Current service upstreams (gateway)
handle /self {
reverse_proxy {{upstreams}}
}
# Named service upstreams without port
handle /api {
reverse_proxy {{upstreams "api"}}
}
# Named service upstreams with port
handle /api-custom {
reverse_proxy {{upstreams "api" 9000}}
}
# Current service with name and port
handle /self-port {
reverse_proxy {{upstreams .Name 8888}}
}
# Service with mixed containers (web) and advanced template
handle /web {
reverse_proxy {{- range $up := index .Upstreams "web"}} {{$up}}{{end}}
}
# Non-existent service
handle /missing {
reverse_proxy {{upstreams "nonexistent"}}
}
}`,
"test-machine-id",
time.Now(),
),
// App service with just ports (no custom config)
newContainerRecordWithPorts("app", "10.210.1.6", []string{"app.example.com:3000/http"},
"test-machine-id"),
newContainerRecordWithPorts("app", "10.210.2.6", []string{"app.example.com:3000/http"}, "machine-2"),
// Service with invalid config (should be ignored)
newContainerRecordWithCaddyConfig(
"invalid",
"10.210.1.7",
`# test:invalid
badconfig.com {
respond "This config is invalid"
}`,
"test-machine-id",
time.Now(),
),
},
want: `# Global config from test machine
{
admin off
}
localhost:8080 {
respond "Admin panel"
}
` + caddyfileBase + `
http://api.example.com {
reverse_proxy {
to 10.210.1.2:8080 10.210.2.2:8080 10.210.3.2:8080
import common_proxy
}
log
}
http://app.example.com {
reverse_proxy {
to 10.210.1.6:3000 10.210.2.6:3000
import common_proxy
}
log
}
http://web.example.com {
reverse_proxy {
to 10.210.3.3:3000
import common_proxy
}
log
}
# Service: db
# DB admin panel
dbadmin.example.com {
basicauth {
admin $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG
}
reverse_proxy 10.210.1.4:5432
}
# Service: gateway
# Testing different upstream template functions
gateway.example.com {
# Current service upstreams (gateway)
handle /self {
reverse_proxy 10.210.1.5
}
# Named service upstreams without port
handle /api {
reverse_proxy 10.210.1.2 10.210.2.2 10.210.3.2
}
# Named service upstreams with port
handle /api-custom {
reverse_proxy 10.210.1.2:9000 10.210.2.2:9000 10.210.3.2:9000
}
# Current service with name and port
handle /self-port {
reverse_proxy 10.210.1.5:8888
}
# Service with mixed containers (web) and advanced template
handle /web {
reverse_proxy 10.210.1.3 10.210.3.3 10.210.2.3
}
# Non-existent service
handle /missing {
` + "\t\treverse_proxy " + `
}
}
# Service: web
# Web service config v2 (most recent)
web-v2.example.com {
reverse_proxy 10.210.1.3:8080 10.210.3.3:8080 10.210.2.3:8080
}
`,
},
}
ctx := context.Background()
validator := NewMockCaddyfileValidator(t)
validator.EXPECT().Validate(mock.Anything, mock.Anything).RunAndReturn(
func(ctx context.Context, caddyfile string) error {
if strings.Contains(caddyfile, "# test:invalid") {
return errors.New("invalid config detected")
}
return nil
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
generator := NewCaddyfileGenerator("test-machine-id", validator, nil)
config, err := generator.Generate(ctx, tt.containers)
if tt.wantErr {
@@ -188,3 +728,72 @@ func newContainerRecord(ctr api.ServiceContainer, machineID string) store.Contai
MachineID: machineID,
}
}
func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID string, created time.Time) store.ContainerRecord {
return store.ContainerRecord{
Container: api.ServiceContainer{
Container: api.Container{
ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: serviceName + "-" + ip, // Add ID for stable sorting
State: &types.ContainerState{
Running: true,
},
Created: created.UTC().Format(time.RFC3339Nano),
},
NetworkSettings: &types.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
docker.NetworkName: {
IPAddress: ip,
},
},
},
Config: &container.Config{
Labels: map[string]string{
api.LabelServiceName: serviceName,
},
},
},
},
ServiceSpec: api.ServiceSpec{
Caddy: &api.CaddySpec{
Config: caddyConfig,
},
},
},
MachineID: machineID,
}
}
func newContainerRecordWithPorts(serviceName, ip string, ports []string, machineID string) store.ContainerRecord {
portsLabel := strings.Join(ports, ",")
return store.ContainerRecord{
Container: api.ServiceContainer{
Container: api.Container{
ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{
ID: serviceName + "-" + ip, // Add ID for stable sorting
State: &types.ContainerState{
Running: true,
},
Created: time.Now().UTC().Format(time.RFC3339Nano),
},
NetworkSettings: &types.NetworkSettings{
Networks: map[string]*network.EndpointSettings{
docker.NetworkName: {
IPAddress: ip,
},
},
},
Config: &container.Config{
Labels: map[string]string{
api.LabelServiceName: serviceName,
api.LabelServicePorts: portsLabel,
},
},
},
},
},
MachineID: machineID,
}
}