Skip to content

Commit 455174c

Browse files
committed
chore: generate sites in Caddyfile from x-ports alongside caddy.json
1 parent 1ce3e62 commit 455174c

File tree

4 files changed

+308
-48
lines changed

4 files changed

+308
-48
lines changed
Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,122 @@
11
package caddyconfig
22

33
import (
4+
"bytes"
45
"fmt"
6+
"log/slog"
7+
"net"
8+
"strconv"
9+
"strings"
10+
"text/template"
511

612
"github.com/psviderski/uncloud/pkg/api"
713
)
814

9-
func GenerateCaddyfile(containers []api.ServiceContainer, verifyResponse string) (string, error) {
10-
return fmt.Sprintf(`http:// {
11-
handle %s {
12-
respond "%s" 200
15+
const caddyfileTemplate = `http:// {
16+
handle {{.VerifyPath}} {
17+
respond "{{.VerifyResponse}}" 200
18+
}
19+
log
20+
}
21+
22+
(common_proxy) {
23+
# Retry failed requests up to lb_retries times against other available upstreams.
24+
lb_retries 3
25+
# Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking).
26+
fail_duration 30s
27+
}
28+
{{- range $hostname, $upstreams := .HTTPHostUpstreams}}
29+
30+
http://{{$hostname}} {
31+
reverse_proxy {
32+
to {{join $upstreams " "}}
33+
import common_proxy
1334
}
1435
log
36+
}{{end}}
37+
{{- range $hostname, $upstreams := .HTTPSHostUpstreams}}
38+
39+
https://{{$hostname}} {
40+
reverse_proxy {
41+
to {{join $upstreams " "}}
42+
import common_proxy
43+
}
44+
log
45+
}{{end}}
46+
`
47+
48+
func GenerateCaddyfile(containers []api.ServiceContainer, verifyResponse string) (string, error) {
49+
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers)
50+
51+
funcs := template.FuncMap{"join": strings.Join}
52+
tmpl, err := template.New("Caddyfile").Funcs(funcs).Parse(caddyfileTemplate)
53+
if err != nil {
54+
return "", fmt.Errorf("failed to parse Caddyfile template: %w", err)
55+
}
56+
57+
data := struct {
58+
VerifyPath string
59+
VerifyResponse string
60+
HTTPHostUpstreams map[string][]string
61+
HTTPSHostUpstreams map[string][]string
62+
}{
63+
VerifyPath: VerifyPath,
64+
VerifyResponse: verifyResponse,
65+
HTTPHostUpstreams: httpHostUpstreams,
66+
HTTPSHostUpstreams: httpsHostUpstreams,
67+
}
68+
69+
var buf bytes.Buffer
70+
if err = tmpl.Execute(&buf, data); err != nil {
71+
return "", fmt.Errorf("failed to execute Caddyfile template: %w", err)
72+
}
73+
74+
return buf.String(), nil
1575
}
16-
`, VerifyPath, verifyResponse), nil
76+
77+
// httpUpstreamsFromContainers extracts upstreams for HTTP and HTTPS protocols from the published ports of the provided
78+
// service containers.
79+
func httpUpstreamsFromContainers(containers []api.ServiceContainer) (map[string][]string, map[string][]string) {
80+
// Maps hostnames to lists of upstreams (container IP:port pairs).
81+
httpHostUpstreams := make(map[string][]string)
82+
httpsHostUpstreams := make(map[string][]string)
83+
for _, ctr := range containers {
84+
if !ctr.Healthy() {
85+
continue
86+
}
87+
88+
ip := ctr.UncloudNetworkIP()
89+
if !ip.IsValid() {
90+
// Container is not connected to the uncloud Docker network (could be host network).
91+
continue
92+
}
93+
log := slog.With("container", ctr.ID)
94+
95+
ports, err := ctr.ServicePorts()
96+
if err != nil {
97+
log.Error("Failed to parse service ports for container.", "err", err)
98+
continue
99+
}
100+
101+
for _, port := range ports {
102+
if port.Mode != api.PortModeIngress {
103+
continue
104+
}
105+
106+
switch port.Protocol {
107+
case api.ProtocolHTTP:
108+
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
109+
httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream)
110+
case api.ProtocolHTTPS:
111+
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
112+
httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream)
113+
default:
114+
// TODO: implement L4 ingress routing for TCP and UDP.
115+
log.Error("Unsupported protocol for ingress port.", "port", port)
116+
continue
117+
}
118+
}
119+
}
120+
121+
return httpHostUpstreams, httpsHostUpstreams
17122
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package caddyconfig
2+
3+
import (
4+
"testing"
5+
6+
"github.com/psviderski/uncloud/pkg/api"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestGenerateCaddyfile(t *testing.T) {
12+
caddyfileHeader := `http:// {
13+
handle /.uncloud-verify {
14+
respond "verification-response-body" 200
15+
}
16+
log
17+
}
18+
19+
(common_proxy) {
20+
# Retry failed requests up to lb_retries times against other available upstreams.
21+
lb_retries 3
22+
# Upstreams are marked unhealthy for fail_duration after a failed request (passive health checking).
23+
fail_duration 30s
24+
}
25+
`
26+
27+
tests := []struct {
28+
name string
29+
containers []api.ServiceContainer
30+
want string
31+
wantErr bool
32+
}{
33+
{
34+
name: "empty containers",
35+
containers: []api.ServiceContainer{},
36+
want: caddyfileHeader,
37+
},
38+
{
39+
name: "HTTP container",
40+
containers: []api.ServiceContainer{
41+
newContainer("10.210.0.2", "app.example.com:8080/http"),
42+
},
43+
want: caddyfileHeader + `
44+
http://app.example.com {
45+
reverse_proxy {
46+
to 10.210.0.2:8080
47+
import common_proxy
48+
}
49+
log
50+
}
51+
`,
52+
},
53+
{
54+
name: "load balancing multiple containers",
55+
containers: []api.ServiceContainer{
56+
newContainer("10.210.0.2", "app.example.com:8080/http"),
57+
newContainer("10.210.0.3", "app.example.com:8080/http"),
58+
},
59+
want: caddyfileHeader + `
60+
http://app.example.com {
61+
reverse_proxy {
62+
to 10.210.0.2:8080 10.210.0.3:8080
63+
import common_proxy
64+
}
65+
log
66+
}
67+
`,
68+
},
69+
{
70+
name: "HTTPS container",
71+
containers: []api.ServiceContainer{
72+
newContainer("10.210.0.2", "secure.example.com:8000/https"),
73+
},
74+
want: caddyfileHeader + `
75+
https://secure.example.com {
76+
reverse_proxy {
77+
to 10.210.0.2:8000
78+
import common_proxy
79+
}
80+
log
81+
}
82+
`,
83+
},
84+
{
85+
name: "mixed HTTP and HTTPS",
86+
containers: []api.ServiceContainer{
87+
newContainer("10.210.0.2",
88+
"app.example.com:8080/http",
89+
"web.example.com:8000/http"),
90+
newContainer("10.210.0.3",
91+
"app.example.com:8080/http",
92+
"secure.example.com:8888/https"),
93+
newContainer("10.210.0.4",
94+
"web.example.com:8000/http",
95+
"secure.example.com:8888/https"),
96+
newContainer("10.210.0.5",
97+
"app.example.com:8080/http",
98+
"web.example.com:8000/http",
99+
"secure.example.com:8888/https"),
100+
},
101+
want: caddyfileHeader + `
102+
http://app.example.com {
103+
reverse_proxy {
104+
to 10.210.0.2:8080 10.210.0.3:8080 10.210.0.5:8080
105+
import common_proxy
106+
}
107+
log
108+
}
109+
110+
http://web.example.com {
111+
reverse_proxy {
112+
to 10.210.0.2:8000 10.210.0.4:8000 10.210.0.5:8000
113+
import common_proxy
114+
}
115+
log
116+
}
117+
118+
https://secure.example.com {
119+
reverse_proxy {
120+
to 10.210.0.3:8888 10.210.0.4:8888 10.210.0.5:8888
121+
import common_proxy
122+
}
123+
log
124+
}
125+
`,
126+
},
127+
{
128+
name: "container without uncloud network ignored",
129+
containers: []api.ServiceContainer{
130+
newContainerWithoutNetwork("ignored.example.com:8080/http"),
131+
},
132+
want: caddyfileHeader,
133+
},
134+
{
135+
name: "container with invalid port ignored",
136+
containers: []api.ServiceContainer{
137+
newContainer("10.210.0.2", "invalid-port"),
138+
},
139+
want: caddyfileHeader,
140+
},
141+
{
142+
name: "containers with unsupported protocols and host mode ignored",
143+
containers: []api.ServiceContainer{
144+
newContainer("10.210.0.2", "5000/tcp"),
145+
newContainer("10.210.0.3", "5000/udp"),
146+
newContainer("10.210.0.4", "80:8080/tcp@host"),
147+
},
148+
want: caddyfileHeader,
149+
},
150+
{
151+
name: "restarting container ignored",
152+
containers: []api.ServiceContainer{
153+
newRestartingContainer("10.210.0.2", "app.example.com:8080/http"),
154+
},
155+
want: caddyfileHeader,
156+
},
157+
{
158+
name: "stopped container ignored",
159+
containers: []api.ServiceContainer{
160+
newStoppedContainer("10.210.0.2", "app.example.com:8080/http"),
161+
},
162+
want: caddyfileHeader,
163+
},
164+
{
165+
name: "mix of running, restarting, and stopped containers",
166+
containers: []api.ServiceContainer{
167+
newContainer("10.210.0.2", "app.example.com:8080/http"),
168+
newRestartingContainer("10.210.0.3", "app.example.com:8080/http"),
169+
newStoppedContainer("10.210.0.4", "app.example.com:8080/http"),
170+
},
171+
want: caddyfileHeader + `
172+
http://app.example.com {
173+
reverse_proxy {
174+
to 10.210.0.2:8080
175+
import common_proxy
176+
}
177+
log
178+
}
179+
`,
180+
},
181+
}
182+
183+
for _, tt := range tests {
184+
t.Run(tt.name, func(t *testing.T) {
185+
config, err := GenerateCaddyfile(tt.containers, "verification-response-body")
186+
187+
if tt.wantErr {
188+
assert.Error(t, err)
189+
return
190+
}
191+
require.NoError(t, err)
192+
193+
assert.Equal(t, tt.want, config, "Generated Caddyfile doesn't match")
194+
})
195+
}
196+
}

internal/machine/caddyconfig/jsonconfig.go

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7-
"log/slog"
87
"maps"
9-
"net"
108
"net/http"
119
"slices"
1210
"strconv"
@@ -20,46 +18,7 @@ import (
2018
)
2119

2220
func GenerateJSONConfig(containers []api.ServiceContainer, verifyResponse string) (*caddy.Config, error) {
23-
// Maps hostnames to lists of upstreams (container IP:port pairs).
24-
httpHostUpstreams := make(map[string][]string)
25-
httpsHostUpstreams := make(map[string][]string)
26-
for _, ctr := range containers {
27-
if !ctr.Healthy() {
28-
continue
29-
}
30-
31-
ip := ctr.UncloudNetworkIP()
32-
if !ip.IsValid() {
33-
// Container is not connected to the uncloud Docker network (could be host network).
34-
continue
35-
}
36-
log := slog.With("container", ctr.ID)
37-
38-
ports, err := ctr.ServicePorts()
39-
if err != nil {
40-
log.Error("Failed to parse service ports for container.", "err", err)
41-
continue
42-
}
43-
44-
for _, port := range ports {
45-
if port.Mode != api.PortModeIngress {
46-
continue
47-
}
48-
49-
switch port.Protocol {
50-
case api.ProtocolHTTP:
51-
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
52-
httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream)
53-
case api.ProtocolHTTPS:
54-
upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
55-
httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream)
56-
default:
57-
// TODO: implement L4 ingress routing for TCP and UDP.
58-
log.Error("Unsupported protocol for ingress port.", "port", port)
59-
continue
60-
}
61-
}
62-
}
21+
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers)
6322

6423
var warnings []caddyconfig.Warning
6524
servers := make(map[string]*caddyhttp.Server)

internal/machine/caddyconfig/jsonconfig_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
"github.com/stretchr/testify/require"
1414
)
1515

16-
func TestGenerateConfig(t *testing.T) {
16+
func TestGenerateJSONConfig(t *testing.T) {
1717
configWithoutServices := `{
1818
"servers": {
1919
"http": {

0 commit comments

Comments
 (0)