diff --git a/internal/machine/caddyconfig/caddyfile.go b/internal/machine/caddyconfig/caddyfile.go index e72fe2a4..053be3c7 100644 --- a/internal/machine/caddyconfig/caddyfile.go +++ b/internal/machine/caddyconfig/caddyfile.go @@ -45,7 +45,43 @@ https://{{$hostname}} { }{{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) funcs := template.FuncMap{"join": strings.Join} @@ -61,7 +97,7 @@ func GenerateCaddyfile(containers []api.ServiceContainer, verifyResponse string) HTTPSHostUpstreams map[string][]string }{ VerifyPath: VerifyPath, - VerifyResponse: verifyResponse, + VerifyResponse: g.MachineID, HTTPHostUpstreams: httpHostUpstreams, HTTPSHostUpstreams: httpsHostUpstreams, } diff --git a/internal/machine/caddyconfig/caddyfile_test.go b/internal/machine/caddyconfig/caddyfile_test.go index 46516223..ce60c9f7 100644 --- a/internal/machine/caddyconfig/caddyfile_test.go +++ b/internal/machine/caddyconfig/caddyfile_test.go @@ -8,10 +8,10 @@ import ( "github.com/stretchr/testify/require" ) -func TestGenerateCaddyfile(t *testing.T) { +func TestCaddyfileGenerator(t *testing.T) { caddyfileHeader := `http:// { handle /.uncloud-verify { - respond "verification-response-body" 200 + respond "test-machine-id" 200 } log } @@ -24,6 +24,10 @@ func TestGenerateCaddyfile(t *testing.T) { } ` + generator := &CaddyfileGenerator{ + MachineID: "test-machine-id", + } + tests := []struct { name string containers []api.ServiceContainer @@ -182,7 +186,7 @@ http://app.example.com { for _, tt := range tests { 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 { assert.Error(t, err) diff --git a/internal/machine/caddyconfig/controller.go b/internal/machine/caddyconfig/controller.go index 49efbc57..c77d0b36 100644 --- a/internal/machine/caddyconfig/controller.go +++ b/internal/machine/caddyconfig/controller.go @@ -22,13 +22,14 @@ const ( // proxy. The generated configuration allows Caddy to route external traffic to service containers across the internal // network. type Controller struct { - store *store.Store - configDir string - verifyResponse string - log *slog.Logger + machineID string + configDir string + generator *CaddyfileGenerator + store *store.Store + 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 { return nil, fmt.Errorf("create directory for Caddy configuration '%s': %w", configDir, err) } @@ -36,11 +37,16 @@ func NewController(store *store.Store, configDir string, verifyResponse string) return nil, fmt.Errorf("change owner of directory for Caddy configuration '%s': %w", configDir, err) } + generator := &CaddyfileGenerator{ + MachineID: machineID, + } + return &Controller{ - store: store, - configDir: configDir, - verifyResponse: verifyResponse, - log: slog.With("component", "caddy-controller"), + machineID: machineID, + configDir: configDir, + generator: generator, + store: store, + log: slog.With("component", "caddy-controller"), }, nil } @@ -73,20 +79,20 @@ func (c *Controller) Run(ctx context.Context) error { containerRecords, err = c.store.ListContainers(ctx, store.ListOptions{}) if err != nil { - c.log.Info("Failed to list containers.", "err", err) + c.log.Error("Failed to list containers.", "err", err) continue } containers, err = c.filterAvailableContainers(containerRecords) if err != nil { - c.log.Info("Failed to filter available containers.", "err", err) + c.log.Error("Failed to filter available containers.", "err", err) continue } 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 { - 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) @@ -110,12 +116,13 @@ func (c *Controller) filterAvailableContainers( } func (c *Controller) generateCaddyfile(containers []api.ServiceContainer) error { - caddyfile, err := GenerateCaddyfile(containers, c.verifyResponse) + caddyfile, err := c.generator.Generate(containers) if err != nil { return fmt.Errorf("generate Caddyfile: %w", err) } 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 { 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 { - config, err := GenerateJSONConfig(containers, c.verifyResponse) + config, err := GenerateJSONConfig(containers, c.machineID) if err != nil { return err } diff --git a/internal/machine/machine.go b/internal/machine/machine.go index 9896a943..22416c0b 100644 --- a/internal/machine/machine.go +++ b/internal/machine/machine.go @@ -42,6 +42,9 @@ const ( DefaultMachineSockPath = "/run/uncloud/machine.sock" DefaultUncloudSockPath = "/run/uncloud/uncloud.sock" 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 { @@ -387,7 +390,12 @@ func (m *Machine) Run(ctx context.Context) error { // 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. - 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 { return fmt.Errorf("create caddyconfig controller: %w", err) }