Skip to content

Commit 5cc005a

Browse files
committed
feat: validate and append custom per-service Caddy configs to generated Caddyfile
1 parent 066d411 commit 5cc005a

File tree

5 files changed

+794
-39
lines changed

5 files changed

+794
-39
lines changed

internal/machine/caddyconfig/caddyfile.go

Lines changed: 86 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/psviderski/uncloud/pkg/api"
1818
)
1919

20+
// TODO: change upstreams from 'to' to the directive arguments.
2021
const caddyfileTemplate = `http:// {
2122
handle {{.VerifyPath}} {
2223
respond "{{.VerifyResponse}}" 200
@@ -75,6 +76,7 @@ func NewCaddyfileGenerator(machineID string, validator CaddyfileValidator, log *
7576
}
7677

7778
// Generate creates a Caddyfile configuration based on the provided service containers.
79+
// The Caddyfile is generated from the service ports of the healthy containers.
7880
// If a 'caddy' service container is running on this machine and defines a custom Caddy config (x-caddy) in its service
7981
// spec, it will be validated and prepended to the generated Caddyfile. Custom Caddy configs (x-caddy) defined in other
8082
// 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
9294
for i, cr := range records {
9395
containers[i] = cr.Container
9496
}
95-
// Sort containers by service name and container ID to generate a stable Caddyfile.
96-
slices.SortFunc(containers, func(a, b api.ServiceContainer) int {
97+
// Sort containers by service name and creation time to generate a stable Caddyfile.
98+
slices.SortStableFunc(containers, func(a, b api.ServiceContainer) int {
9799
return cmp.Or(
98100
strings.Compare(a.ServiceName(), b.ServiceName()),
99-
strings.Compare(a.ID, b.ID),
101+
a.CreatedTime().Compare(b.CreatedTime()),
100102
)
101103
})
102104

@@ -105,26 +107,38 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
105107
return "", fmt.Errorf("generate base Caddyfile from service ports: %w", err)
106108
}
107109

110+
upstreams := serviceUpstreams(containers)
111+
108112
// Find the 'caddy' service container on this machine. Use the most recent one if multiple exist.
109113
var caddyCtr *api.ServiceContainer
110114
for _, cr := range records {
111115
if cr.MachineID == g.machineID && cr.Container.ServiceName() == CaddyServiceName &&
112-
(caddyCtr == nil || cr.Container.Created > caddyCtr.Created) {
116+
(caddyCtr == nil || cr.Container.CreatedTime().Compare(caddyCtr.CreatedTime()) > 0) {
113117
caddyCtr = &cr.Container
114118
}
115119
}
116120

117-
// If the caddy container is running on this machine and has a custom Caddy config, prepend it to the generated
118-
// Caddyfile and validate it.
121+
// If the caddy container is running on this machine and has a custom Caddy config (global),
122+
// prepend it to the generated Caddyfile and validate it.
119123
if caddyCtr != nil && caddyCtr.ServiceSpec.CaddyConfig() != "" {
120-
// TODO: render the template actions in the Caddy config.
121-
caddyfileCandidate := caddyCtr.ServiceSpec.CaddyConfig() + "\n\n" + caddyfile
122-
123-
if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil {
124-
g.log.Error("Custom Caddy config on the caddy container on this machine is invalid, skipping it.",
124+
// Render the custom global Caddy config as a Go template with the upstreams.
125+
tmplCtx := templateContext{
126+
Name: caddyCtr.ServiceName(),
127+
Upstreams: upstreams,
128+
}
129+
renderedConfig, err := renderCaddyfile(tmplCtx, caddyCtr.ServiceSpec.CaddyConfig())
130+
if err != nil {
131+
g.log.Error("Failed to render template directives in custom global Caddy config, skipping it.",
125132
"service", caddyCtr.ServiceName(), "container", caddyCtr.ID, "err", err)
126133
} else {
127-
caddyfile = caddyfileCandidate
134+
caddyfileCandidate := renderedConfig + "\n\n" + caddyfile
135+
136+
if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil {
137+
g.log.Error("Custom global Caddy config is invalid, skipping it.",
138+
"service", caddyCtr.ServiceName(), "container", caddyCtr.ID, "err", err)
139+
} else {
140+
caddyfile = caddyfileCandidate
141+
}
128142
}
129143
}
130144

@@ -134,7 +148,7 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
134148
latestServiceContainers := make(map[string]api.ServiceContainer, len(containers))
135149
for _, ctr := range containers {
136150
if latest, ok := latestServiceContainers[ctr.ServiceName()]; ok {
137-
if ctr.Created > latest.Created {
151+
if ctr.CreatedTime().Compare(latest.CreatedTime()) > 0 {
138152
latestServiceContainers[ctr.ServiceName()] = ctr
139153
}
140154
} else {
@@ -143,6 +157,8 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
143157
}
144158
sortedServiceNames := slices.Sorted(maps.Keys(latestServiceContainers))
145159

160+
// Append a custom Caddy config for each service to the Caddyfile and validate it. If the config for a service
161+
// is invalid, skip it but continue processing other services to ensure the Caddyfile remains valid.
146162
for _, serviceName := range sortedServiceNames {
147163
// Skip the caddy container as we already processed it.
148164
if serviceName == CaddyServiceName {
@@ -154,29 +170,37 @@ func (g *CaddyfileGenerator) Generate(ctx context.Context, records []store.Conta
154170
continue
155171
}
156172

157-
// TODO: render the template actions in the Caddy config.
158-
caddyfileCandidate := fmt.Sprintf("%s\n# Service: %s\n%s\n",
159-
caddyfile, ctr.ServiceName(), ctr.ServiceSpec.CaddyConfig())
173+
// Render the template actions in the service's Caddy config.
174+
tmplCtx := templateContext{
175+
Name: serviceName,
176+
Upstreams: upstreams,
177+
}
178+
renderedConfig, err := renderCaddyfile(tmplCtx, ctr.ServiceSpec.CaddyConfig())
179+
if err != nil {
180+
g.log.Error("Failed to render template directives in custom Caddy config for service, skipping it.",
181+
"service", serviceName, "err", err)
182+
continue
183+
}
160184

185+
caddyfileCandidate := fmt.Sprintf("%s\n# Service: %s\n%s\n", caddyfile, serviceName, renderedConfig)
161186
if err = g.validator.Validate(ctx, caddyfileCandidate); err != nil {
162187
g.log.Error("Custom Caddy config for service is invalid, skipping it.",
163-
"service", ctr.ServiceName(), "err", err)
164-
continue
188+
"service", serviceName, "err", err)
189+
} else {
190+
caddyfile = caddyfileCandidate
165191
}
166-
167-
caddyfile = caddyfileCandidate
168192
}
169193

170194
return caddyfile, nil
171195
}
172196

173197
func (g *CaddyfileGenerator) generateBaseFromPorts(containers []api.ServiceContainer) (string, error) {
174-
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers)
198+
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromPorts(containers)
175199

176200
funcs := template.FuncMap{"join": strings.Join}
177201
tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfileTemplate)
178202
if err != nil {
179-
return "", fmt.Errorf("failed to parse Caddyfile template: %w", err)
203+
return "", fmt.Errorf("parse Caddyfile template: %w", err)
180204
}
181205

182206
data := struct {
@@ -193,15 +217,15 @@ func (g *CaddyfileGenerator) generateBaseFromPorts(containers []api.ServiceConta
193217

194218
var buf bytes.Buffer
195219
if err = tmpl.Execute(&buf, data); err != nil {
196-
return "", fmt.Errorf("failed to execute Caddyfile template: %w", err)
220+
return "", fmt.Errorf("execute Caddyfile template: %w", err)
197221
}
198222

199223
return buf.String(), nil
200224
}
201225

202-
// httpUpstreamsFromContainers extracts upstreams for HTTP and HTTPS protocols from the published ports of the provided
226+
// httpUpstreamsFromPorts extracts upstreams for HTTP and HTTPS protocols from the published ports of the provided
203227
// service containers. It's expected that all containers are healthy.
204-
func httpUpstreamsFromContainers(containers []api.ServiceContainer) (map[string][]string, map[string][]string) {
228+
func httpUpstreamsFromPorts(containers []api.ServiceContainer) (map[string][]string, map[string][]string) {
205229
// Maps hostnames to lists of upstreams (container IP:port pairs).
206230
httpHostUpstreams := make(map[string][]string)
207231
httpsHostUpstreams := make(map[string][]string)
@@ -241,3 +265,40 @@ func httpUpstreamsFromContainers(containers []api.ServiceContainer) (map[string]
241265

242266
return httpHostUpstreams, httpsHostUpstreams
243267
}
268+
269+
// serviceUpstreams creates a map of service names to their container IPs.
270+
// Only includes containers connected to the uncloud Docker network.
271+
func serviceUpstreams(containers []api.ServiceContainer) map[string][]string {
272+
upstreams := make(map[string][]string)
273+
for _, ctr := range containers {
274+
ip := ctr.UncloudNetworkIP()
275+
if !ip.IsValid() {
276+
// Container is not connected to the uncloud Docker network (could be host network).
277+
continue
278+
}
279+
280+
serviceName := ctr.ServiceName()
281+
upstreams[serviceName] = append(upstreams[serviceName], ip.String())
282+
}
283+
284+
return upstreams
285+
}
286+
287+
// renderCaddyfile renders a Caddyfile template with the upstreams function and data.
288+
func renderCaddyfile(tmplCtx templateContext, caddyfile string) (string, error) {
289+
funcs := template.FuncMap{
290+
"upstreams": upstreamsTemplateFn(tmplCtx),
291+
}
292+
293+
tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfile)
294+
if err != nil {
295+
return "", fmt.Errorf("parse config as Go template: %w", err)
296+
}
297+
298+
var buf bytes.Buffer
299+
if err = tmpl.Execute(&buf, tmplCtx); err != nil {
300+
return "", fmt.Errorf("execute template: %w", err)
301+
}
302+
303+
return buf.String(), nil
304+
}

0 commit comments

Comments
 (0)