chore: refactor Caddyfile generator to accept a validator

This commit is contained in:
Pasha Sviderski
2025-08-18 18:48:32 +10:00
parent 455174ccb0
commit 93fef88fac
4 changed files with 76 additions and 21 deletions
+38 -2
View File
@@ -45,7 +45,43 @@ https://{{$hostname}} {
}{{end}} }{{end}}
` `
func GenerateCaddyfile(containers []api.ServiceContainer, verifyResponse string) (string, error) { // CaddyfileGenerator generates a Caddyfile configuration for the Caddy reverse proxy.
type CaddyfileGenerator struct {
// MachineID is the unique identifier of the machine where the controller is running.
MachineID string
Validator CaddyfileValidator
}
// CaddyfileValidator is an interface for validating Caddyfile configurations.
type CaddyfileValidator interface {
Validate(caddyfile string) error
}
// Generate creates a Caddyfile configuration based on the provided service containers.
// 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
// service specs are validated and appended to the generated Caddyfile. Invalid configs are logged and skipped to ensure
// the generated Caddyfile remains valid.
//
// The final Caddyfile structure includes:
//
// [caddy x-caddy]
// [generated Caddyfile from all service ports]
// [service-a x-caddy]
// ...
// [service-z x-caddy]
func (g *CaddyfileGenerator) Generate(containers []api.ServiceContainer) (string, error) {
baseCaddyfile, err := g.generateBaseFromPorts(containers)
if err != nil {
return "", fmt.Errorf("generate base Caddyfile from service ports: %w", err)
}
// TODO: Implement support for custom Caddy configs (x-caddy) in service specs.
return baseCaddyfile, nil
}
func (g *CaddyfileGenerator) generateBaseFromPorts(containers []api.ServiceContainer) (string, error) {
httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers) httpHostUpstreams, httpsHostUpstreams := httpUpstreamsFromContainers(containers)
funcs := template.FuncMap{"join": strings.Join} funcs := template.FuncMap{"join": strings.Join}
@@ -61,7 +97,7 @@ func GenerateCaddyfile(containers []api.ServiceContainer, verifyResponse string)
HTTPSHostUpstreams map[string][]string HTTPSHostUpstreams map[string][]string
}{ }{
VerifyPath: VerifyPath, VerifyPath: VerifyPath,
VerifyResponse: verifyResponse, VerifyResponse: g.MachineID,
HTTPHostUpstreams: httpHostUpstreams, HTTPHostUpstreams: httpHostUpstreams,
HTTPSHostUpstreams: httpsHostUpstreams, HTTPSHostUpstreams: httpsHostUpstreams,
} }
@@ -8,10 +8,10 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestGenerateCaddyfile(t *testing.T) { func TestCaddyfileGenerator(t *testing.T) {
caddyfileHeader := `http:// { caddyfileHeader := `http:// {
handle /.uncloud-verify { handle /.uncloud-verify {
respond "verification-response-body" 200 respond "test-machine-id" 200
} }
log log
} }
@@ -24,6 +24,10 @@ func TestGenerateCaddyfile(t *testing.T) {
} }
` `
generator := &CaddyfileGenerator{
MachineID: "test-machine-id",
}
tests := []struct { tests := []struct {
name string name string
containers []api.ServiceContainer containers []api.ServiceContainer
@@ -182,7 +186,7 @@ http://app.example.com {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
config, err := GenerateCaddyfile(tt.containers, "verification-response-body") config, err := generator.Generate(tt.containers)
if tt.wantErr { if tt.wantErr {
assert.Error(t, err) assert.Error(t, err)
+18 -11
View File
@@ -22,13 +22,14 @@ const (
// proxy. The generated configuration allows Caddy to route external traffic to service containers across the internal // proxy. The generated configuration allows Caddy to route external traffic to service containers across the internal
// network. // network.
type Controller struct { type Controller struct {
store *store.Store machineID string
configDir string configDir string
verifyResponse string generator *CaddyfileGenerator
store *store.Store
log *slog.Logger log *slog.Logger
} }
func NewController(store *store.Store, configDir string, verifyResponse string) (*Controller, error) { func NewController(machineID, configDir, adminSock string, store *store.Store) (*Controller, error) {
if err := os.MkdirAll(configDir, 0o750); err != nil { if err := os.MkdirAll(configDir, 0o750); err != nil {
return nil, fmt.Errorf("create directory for Caddy configuration '%s': %w", configDir, err) return nil, fmt.Errorf("create directory for Caddy configuration '%s': %w", configDir, err)
} }
@@ -36,10 +37,15 @@ func NewController(store *store.Store, configDir string, verifyResponse string)
return nil, fmt.Errorf("change owner of directory for Caddy configuration '%s': %w", configDir, err) return nil, fmt.Errorf("change owner of directory for Caddy configuration '%s': %w", configDir, err)
} }
generator := &CaddyfileGenerator{
MachineID: machineID,
}
return &Controller{ return &Controller{
store: store, machineID: machineID,
configDir: configDir, configDir: configDir,
verifyResponse: verifyResponse, generator: generator,
store: store,
log: slog.With("component", "caddy-controller"), log: slog.With("component", "caddy-controller"),
}, nil }, nil
} }
@@ -73,20 +79,20 @@ func (c *Controller) Run(ctx context.Context) error {
containerRecords, err = c.store.ListContainers(ctx, store.ListOptions{}) containerRecords, err = c.store.ListContainers(ctx, store.ListOptions{})
if err != nil { if err != nil {
c.log.Info("Failed to list containers.", "err", err) c.log.Error("Failed to list containers.", "err", err)
continue continue
} }
containers, err = c.filterAvailableContainers(containerRecords) containers, err = c.filterAvailableContainers(containerRecords)
if err != nil { if err != nil {
c.log.Info("Failed to filter available containers.", "err", err) c.log.Error("Failed to filter available containers.", "err", err)
continue continue
} }
if err = c.generateCaddyfile(containers); err != nil { if err = c.generateCaddyfile(containers); err != nil {
c.log.Info("Failed to generate Caddyfile configuration.", "err", err) c.log.Error("Failed to generate Caddyfile configuration.", "err", err)
} }
if err = c.generateJSONConfig(containers); err != nil { if err = c.generateJSONConfig(containers); err != nil {
c.log.Info("Failed to generate Caddy JSON configuration.", "err", err) c.log.Error("Failed to generate Caddy JSON configuration.", "err", err)
} }
c.log.Info("Updated Caddy configuration.", "dir", c.configDir) c.log.Info("Updated Caddy configuration.", "dir", c.configDir)
@@ -110,12 +116,13 @@ func (c *Controller) filterAvailableContainers(
} }
func (c *Controller) generateCaddyfile(containers []api.ServiceContainer) error { func (c *Controller) generateCaddyfile(containers []api.ServiceContainer) error {
caddyfile, err := GenerateCaddyfile(containers, c.verifyResponse) caddyfile, err := c.generator.Generate(containers)
if err != nil { if err != nil {
return fmt.Errorf("generate Caddyfile: %w", err) return fmt.Errorf("generate Caddyfile: %w", err)
} }
caddyfilePath := filepath.Join(c.configDir, "Caddyfile") caddyfilePath := filepath.Join(c.configDir, "Caddyfile")
// TODO: use atomic file write to avoid partial loads on Caddy watch reload.
if err = os.WriteFile(caddyfilePath, []byte(caddyfile), 0o640); err != nil { if err = os.WriteFile(caddyfilePath, []byte(caddyfile), 0o640); err != nil {
return fmt.Errorf("write Caddyfile to file '%s': %w", caddyfilePath, err) return fmt.Errorf("write Caddyfile to file '%s': %w", caddyfilePath, err)
} }
@@ -127,7 +134,7 @@ func (c *Controller) generateCaddyfile(containers []api.ServiceContainer) error
} }
func (c *Controller) generateJSONConfig(containers []api.ServiceContainer) error { func (c *Controller) generateJSONConfig(containers []api.ServiceContainer) error {
config, err := GenerateJSONConfig(containers, c.verifyResponse) config, err := GenerateJSONConfig(containers, c.machineID)
if err != nil { if err != nil {
return err return err
} }
+9 -1
View File
@@ -42,6 +42,9 @@ const (
DefaultMachineSockPath = "/run/uncloud/machine.sock" DefaultMachineSockPath = "/run/uncloud/machine.sock"
DefaultUncloudSockPath = "/run/uncloud/uncloud.sock" DefaultUncloudSockPath = "/run/uncloud/uncloud.sock"
DefaultSockGroup = "uncloud" DefaultSockGroup = "uncloud"
// DefaultCaddyAdminSockPath is the default path to the Caddy admin socket for validating the generated Caddy
// reverse proxy configuration.
DefaultCaddyAdminSockPath = "/run/uncloud/caddy/admin.sock"
) )
type Config struct { type Config struct {
@@ -387,7 +390,12 @@ func (m *Machine) Run(ctx context.Context) error {
// Create a new caddyconfig controller for managing the Caddy reverse proxy configuration. // Create a new caddyconfig controller for managing the Caddy reverse proxy configuration.
// It will also serve the current machine ID at /.uncloud-verify to verify Caddy reachability. // It will also serve the current machine ID at /.uncloud-verify to verify Caddy reachability.
caddyconfigCtrl, err := caddyconfig.NewController(m.store, m.config.CaddyConfigDir, m.state.ID) caddyconfigCtrl, err := caddyconfig.NewController(
m.state.ID,
m.config.CaddyConfigDir,
DefaultCaddyAdminSockPath,
m.store,
)
if err != nil { if err != nil {
return fmt.Errorf("create caddyconfig controller: %w", err) return fmt.Errorf("create caddyconfig controller: %w", err)
} }