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
+83 -22
View File
@@ -17,6 +17,7 @@ import (
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
) )
// TODO: change upstreams from 'to' to the directive arguments.
const caddyfileTemplate = `http:// { const caddyfileTemplate = `http:// {
handle {{.VerifyPath}} { handle {{.VerifyPath}} {
respond "{{.VerifyResponse}}" 200 respond "{{.VerifyResponse}}" 200
@@ -75,6 +76,7 @@ func NewCaddyfileGenerator(machineID string, validator CaddyfileValidator, log *
} }
// Generate creates a Caddyfile configuration based on the provided service containers. // Generate creates a Caddyfile configuration based on the provided service containers.
// The Caddyfile is generated from the service ports of the healthy containers.
// If a 'caddy' service container is running on this machine and defines a custom Caddy config (x-caddy) in its service // If a 'caddy' service container is running on this machine and defines a custom Caddy config (x-caddy) in its service
// spec, it will be validated and prepended to the generated Caddyfile. Custom Caddy configs (x-caddy) defined in other // spec, it will be validated and prepended to the generated Caddyfile. Custom Caddy configs (x-caddy) defined in other
// service specs are validated and appended to the generated Caddyfile. Invalid configs are logged and skipped to ensure // service specs are validated and appended to the generated Caddyfile. Invalid configs are logged and skipped to ensure
@@ -92,11 +94,11 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
for i, cr := range records { for i, cr := range records {
containers[i] = cr.Container containers[i] = cr.Container
} }
// Sort containers by service name and container ID to generate a stable Caddyfile. // Sort containers by service name and creation time to generate a stable Caddyfile.
slices.SortFunc(containers, func(a, b api.ServiceContainer) int { slices.SortStableFunc(containers, func(a, b api.ServiceContainer) int {
return cmp.Or( return cmp.Or(
strings.Compare(a.ServiceName(), b.ServiceName()), strings.Compare(a.ServiceName(), b.ServiceName()),
strings.Compare(a.ID, b.ID), a.CreatedTime().Compare(b.CreatedTime()),
) )
}) })
@@ -105,28 +107,40 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
return "", fmt.Errorf("generate base Caddyfile from service ports: %w", err) return "", fmt.Errorf("generate base Caddyfile from service ports: %w", err)
} }
upstreams := serviceUpstreams(containers)
// Find the 'caddy' service container on this machine. Use the most recent one if multiple exist. // Find the 'caddy' service container on this machine. Use the most recent one if multiple exist.
var caddyCtr *api.ServiceContainer var caddyCtr *api.ServiceContainer
for _, cr := range records { for _, cr := range records {
if cr.MachineID == g.machineID && cr.Container.ServiceName() == CaddyServiceName && if cr.MachineID == g.machineID && cr.Container.ServiceName() == CaddyServiceName &&
(caddyCtr == nil || cr.Container.Created > caddyCtr.Created) { (caddyCtr == nil || cr.Container.CreatedTime().Compare(caddyCtr.CreatedTime()) > 0) {
caddyCtr = &cr.Container caddyCtr = &cr.Container
} }
} }
// If the caddy container is running on this machine and has a custom Caddy config, prepend it to the generated // If the caddy container is running on this machine and has a custom Caddy config (global),
// Caddyfile and validate it. // prepend it to the generated Caddyfile and validate it.
if caddyCtr != nil && caddyCtr.ServiceSpec.CaddyConfig() != "" { if caddyCtr != nil && caddyCtr.ServiceSpec.CaddyConfig() != "" {
// TODO: render the template actions in the Caddy config. // Render the custom global Caddy config as a Go template with the upstreams.
caddyfileCandidate := caddyCtr.ServiceSpec.CaddyConfig() + "\n\n" + caddyfile tmplCtx := templateContext{
Name: caddyCtr.ServiceName(),
Upstreams: upstreams,
}
renderedConfig, err := renderCaddyfile(tmplCtx, caddyCtr.ServiceSpec.CaddyConfig())
if err != nil {
g.log.Error("Failed to render template directives in custom global Caddy config, skipping it.",
"service", caddyCtr.ServiceName(), "container", caddyCtr.ID, "err", err)
} else {
caddyfileCandidate := renderedConfig + "\n\n" + caddyfile
if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil { if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil {
g.log.Error("Custom Caddy config on the caddy container on this machine is invalid, skipping it.", g.log.Error("Custom global Caddy config is invalid, skipping it.",
"service", caddyCtr.ServiceName(), "container", caddyCtr.ID, "err", err) "service", caddyCtr.ServiceName(), "container", caddyCtr.ID, "err", err)
} else { } else {
caddyfile = caddyfileCandidate caddyfile = caddyfileCandidate
} }
} }
}
// There could be multiple service containers for the same service with different custom Caddy configs, for example, // There could be multiple service containers for the same service with different custom Caddy configs, for example,
// if the service has been partially updated. The most recent container for each service defines the current custom // if the service has been partially updated. The most recent container for each service defines the current custom
@@ -134,7 +148,7 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
latestServiceContainers := make(map[string]api.ServiceContainer, len(containers)) latestServiceContainers := make(map[string]api.ServiceContainer, len(containers))
for _, ctr := range containers { for _, ctr := range containers {
if latest, ok := latestServiceContainers[ctr.ServiceName()]; ok { if latest, ok := latestServiceContainers[ctr.ServiceName()]; ok {
if ctr.Created > latest.Created { if ctr.CreatedTime().Compare(latest.CreatedTime()) > 0 {
latestServiceContainers[ctr.ServiceName()] = ctr latestServiceContainers[ctr.ServiceName()] = ctr
} }
} else { } else {
@@ -143,6 +157,8 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
} }
sortedServiceNames := slices.Sorted(maps.Keys(latestServiceContainers)) sortedServiceNames := slices.Sorted(maps.Keys(latestServiceContainers))
// Append a custom Caddy config for each service to the Caddyfile and validate it. If the config for a service
// is invalid, skip it but continue processing other services to ensure the Caddyfile remains valid.
for _, serviceName := range sortedServiceNames { for _, serviceName := range sortedServiceNames {
// Skip the caddy container as we already processed it. // Skip the caddy container as we already processed it.
if serviceName == CaddyServiceName { if serviceName == CaddyServiceName {
@@ -154,29 +170,37 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
continue continue
} }
// TODO: render the template actions in the Caddy config. // Render the template actions in the service's Caddy config.
caddyfileCandidate := fmt.Sprintf("%s\n# Service: %s\n%s\n", tmplCtx := templateContext{
caddyfile, ctr.ServiceName(), ctr.ServiceSpec.CaddyConfig()) Name: serviceName,
Upstreams: upstreams,
if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil { }
g.log.Error("Custom Caddy config for service is invalid, skipping it.", renderedConfig, err := renderCaddyfile(tmplCtx, ctr.ServiceSpec.CaddyConfig())
"service", ctr.ServiceName(), "err", err) if err != nil {
g.log.Error("Failed to render template directives in custom Caddy config for service, skipping it.",
"service", serviceName, "err", err)
continue continue
} }
caddyfileCandidate := fmt.Sprintf("%s\n# Service: %s\n%s\n", caddyfile, serviceName, renderedConfig)
if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil {
g.log.Error("Custom Caddy config for service is invalid, skipping it.",
"service", serviceName, "err", err)
} else {
caddyfile = caddyfileCandidate caddyfile = caddyfileCandidate
} }
}
return caddyfile, nil return caddyfile, nil
} }
func (g *CaddyfileGenerator) generateBaseFromPorts(containers []api.ServiceContainer) (string, error) { func (g *CaddyfileGenerator) generateBaseFromPorts(containers []api.ServiceContainer) (string, error) {
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers) httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromPorts(containers)
funcs := template.FuncMap{"join": strings.Join} funcs := template.FuncMap{"join": strings.Join}
tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfileTemplate) tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfileTemplate)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to parse Caddyfile template: %w", err) return "", fmt.Errorf("parse Caddyfile template: %w", err)
} }
data := struct { data := struct {
@@ -193,15 +217,15 @@ func (g *CaddyfileGenerator) generateBaseFromPorts(containers []api.ServiceConta
var buf bytes.Buffer var buf bytes.Buffer
if err = tmpl.Execute(&buf, data); err != nil { if err = tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute Caddyfile template: %w", err) return "", fmt.Errorf("execute Caddyfile template: %w", err)
} }
return buf.String(), nil return buf.String(), nil
} }
// httpUpstreamsFromContainers extracts upstreams for HTTP and HTTPS protocols from the published ports of the provided // httpUpstreamsFromPorts extracts upstreams for HTTP and HTTPS protocols from the published ports of the provided
// service containers. It's expected that all containers are healthy. // service containers. It's expected that all containers are healthy.
func httpUpstreamsFromContainers(containers []api.ServiceContainer) (map[string][]string, map[string][]string) { func httpUpstreamsFromPorts(containers []api.ServiceContainer) (map[string][]string, map[string][]string) {
// Maps hostnames to lists of upstreams (container IP:port pairs). // Maps hostnames to lists of upstreams (container IP:port pairs).
httpHostUpstreams := make(map[string][]string) httpHostUpstreams := make(map[string][]string)
httpsHostUpstreams := make(map[string][]string) httpsHostUpstreams := make(map[string][]string)
@@ -241,3 +265,40 @@ func httpUpstreamsFromContainers(containers []api.ServiceContainer) (map[string]
return httpHostUpstreams, httpsHostUpstreams return httpHostUpstreams, httpsHostUpstreams
} }
// serviceUpstreams creates a map of service names to their container IPs.
// Only includes containers connected to the uncloud Docker network.
func serviceUpstreams(containers []api.ServiceContainer) map[string][]string {
upstreams := make(map[string][]string)
for _, ctr := range containers {
ip := ctr.UncloudNetworkIP()
if !ip.IsValid() {
// Container is not connected to the uncloud Docker network (could be host network).
continue
}
serviceName := ctr.ServiceName()
upstreams[serviceName] = append(upstreams[serviceName], ip.String())
}
return upstreams
}
// renderCaddyfile renders a Caddyfile template with the upstreams function and data.
func renderCaddyfile(tmplCtx templateContext, caddyfile string) (string, error) {
funcs := template.FuncMap{
"upstreams": upstreamsTemplateFn(tmplCtx),
}
tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfile)
if err != nil {
return "", fmt.Errorf("parse config as Go template: %w", err)
}
var buf bytes.Buffer
if err = tmpl.Execute(&buf, tmplCtx); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
return buf.String(), nil
}
+612 -3
View File
@@ -2,11 +2,19 @@ package caddyconfig
import ( import (
"context" "context"
"errors"
"strings"
"testing" "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/internal/machine/store"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "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 { tests := []struct {
name string name string
containers []store.ContainerRecord containers []store.ContainerRecord
@@ -169,6 +174,541 @@ https://secure.example.com {
ctx := context.Background() ctx := context.Background()
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) config, err := generator.Generate(ctx, tt.containers)
if tt.wantErr { if tt.wantErr {
@@ -188,3 +728,72 @@ func newContainerRecord(ctr api.ServiceContainer, machineID string) store.Contai
MachineID: machineID, 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,
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
) )
func GenerateJSONConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) { func GenerateJSONConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) {
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers) httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromPorts(containers)
var warnings []caddyconfig.Warning var warnings []caddyconfig.Warning
servers := make(map[string]*caddyhttp.Server) servers := make(map[string]*caddyhttp.Server)
+18 -10
View File
@@ -5,6 +5,8 @@
package caddyconfig package caddyconfig
import ( import (
"context"
mock "github.com/stretchr/testify/mock" mock "github.com/stretchr/testify/mock"
) )
@@ -36,16 +38,16 @@ func (_m *MockCaddyfileValidator) EXPECT() *MockCaddyfileValidator_Expecter {
} }
// Validate provides a mock function for the type MockCaddyfileValidator // Validate provides a mock function for the type MockCaddyfileValidator
func (_mock *MockCaddyfileValidator) Validate(caddyfile string) error { func (_mock *MockCaddyfileValidator) Validate(ctx context.Context, caddyfile string) error {
ret := _mock.Called(caddyfile) ret := _mock.Called(ctx, caddyfile)
if len(ret) == 0 { if len(ret) == 0 {
panic("no return value specified for Validate") panic("no return value specified for Validate")
} }
var r0 error var r0 error
if returnFunc, ok := ret.Get(0).(func(string) error); ok { if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = returnFunc(caddyfile) r0 = returnFunc(ctx, caddyfile)
} else { } else {
r0 = ret.Error(0) r0 = ret.Error(0)
} }
@@ -58,19 +60,25 @@ type MockCaddyfileValidator_Validate_Call struct {
} }
// Validate is a helper method to define mock.On call // Validate is a helper method to define mock.On call
// - ctx context.Context
// - caddyfile string // - caddyfile string
func (_e *MockCaddyfileValidator_Expecter) Validate(caddyfile interface{}) *MockCaddyfileValidator_Validate_Call { func (_e *MockCaddyfileValidator_Expecter) Validate(ctx interface{}, caddyfile interface{}) *MockCaddyfileValidator_Validate_Call {
return &MockCaddyfileValidator_Validate_Call{Call: _e.mock.On("Validate", caddyfile)} return &MockCaddyfileValidator_Validate_Call{Call: _e.mock.On("Validate", ctx, caddyfile)}
} }
func (_c *MockCaddyfileValidator_Validate_Call) Run(run func(caddyfile string)) *MockCaddyfileValidator_Validate_Call { func (_c *MockCaddyfileValidator_Validate_Call) Run(run func(ctx context.Context, caddyfile string)) *MockCaddyfileValidator_Validate_Call {
_c.Call.Run(func(args mock.Arguments) { _c.Call.Run(func(args mock.Arguments) {
var arg0 string var arg0 context.Context
if args[0] != nil { if args[0] != nil {
arg0 = args[0].(string) arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
} }
run( run(
arg0, arg0,
arg1,
) )
}) })
return _c return _c
@@ -81,7 +89,7 @@ func (_c *MockCaddyfileValidator_Validate_Call) Return(err error) *MockCaddyfile
return _c return _c
} }
func (_c *MockCaddyfileValidator_Validate_Call) RunAndReturn(run func(caddyfile string) error) *MockCaddyfileValidator_Validate_Call { func (_c *MockCaddyfileValidator_Validate_Call) RunAndReturn(run func(ctx context.Context, caddyfile string) error) *MockCaddyfileValidator_Validate_Call {
_c.Call.Return(run) _c.Call.Return(run)
return _c return _c
} }
+77
View File
@@ -0,0 +1,77 @@
package caddyconfig
import (
"fmt"
"net"
"strconv"
"strings"
)
// templateContext holds the data available to Caddyfile templates.
type templateContext struct {
// Name is the current service name.
Name string
// Upstreams maps service names to their container IPs.
Upstreams map[string][]string
}
// upstreamsTemplateFn returns a template function that generates a space separated string of upstreams for the service.
// It optionally accepts a service name and a port number: {{upstreams [service-name] [port]}}.
func upstreamsTemplateFn(tmplCtx templateContext) func(args ...any) (string, error) {
return func(args ...any) (string, error) {
var serviceName string
var port int
// Parse arguments.
switch len(args) {
case 0:
// Current service, default port.
serviceName = tmplCtx.Name
case 1:
// Either port (int) for current service or service name (string).
switch arg := args[0].(type) {
case int:
serviceName = tmplCtx.Name
port = arg
case string:
serviceName = arg
port = 0
default:
return "", fmt.Errorf("upstreams function: invalid argument type: %T", arg)
}
case 2:
// Service name and port.
name, ok := args[0].(string)
if !ok {
return "", fmt.Errorf("upstreams function: first argument must be service name (string)")
}
serviceName = name
p, ok := args[1].(int)
if !ok {
return "", fmt.Errorf("upstreams function: second argument must be port (int)")
}
port = p
default:
return "", fmt.Errorf("upstreams function: too many arguments; expected 0-2, got %d", len(args))
}
ips, ok := tmplCtx.Upstreams[serviceName]
if !ok || len(ips) == 0 {
// No upstreams available.
return "", nil
}
// Build the space separated upstreams string.
var upstreams []string
for _, ip := range ips {
if port > 0 {
upstreams = append(upstreams, net.JoinHostPort(ip, strconv.Itoa(port)))
} else {
upstreams = append(upstreams, ip)
}
}
return strings.Join(upstreams, " "), nil
}
}