From 5defc27f6314698c819b2e299f514181de46a1c7 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 9 Jul 2026 03:45:51 +0000 Subject: [PATCH] feat: Wire up service registration to networking reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a service is registered, updated, or deregistered, Central now automatically regenerates HAProxy routes and dnsmasq DNS entries to match the current set of registered services. - L4 tcp-passthrough services (Wild Cloud) → HAProxy SNI routes - L7 http services (Wild Works) → HAProxy Host-based reverse proxy - All services with reach != off → dnsmasq DNS entries - SSE events broadcast on config changes - Reconciliation runs asynchronously (non-blocking) This closes the loop: service registration is no longer inert — it actually updates the networking stack. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/v1/handlers.go | 4 ++ internal/api/v1/helpers.go | 107 ++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index f9c1bf4..995ba34 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -82,6 +82,10 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) { sseManager: sseManager, } + // Wire up service registration reconciliation: when services change, + // regenerate dnsmasq DNS entries and HAProxy routes. + api.services.SetReconcileFn(api.reconcileNetworking) + return api, nil } diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 0ebc896..103d44c 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -2,11 +2,118 @@ package v1 import ( "fmt" + "log/slog" + "path/filepath" "time" + "github.com/wild-cloud/wild-central/internal/config" + "github.com/wild-cloud/wild-central/internal/haproxy" + "github.com/wild-cloud/wild-central/internal/services" "github.com/wild-cloud/wild-central/internal/sse" ) +// reconcileNetworking reads all registered services and regenerates +// dnsmasq DNS entries and HAProxy routes to match. Called automatically +// whenever a service is registered, updated, or deregistered. +func (api *API) reconcileNetworking() { + svcs, err := api.services.List() + if err != nil { + slog.Error("reconcile: failed to list services", "error", err) + return + } + + globalConfigPath := filepath.Join(api.dataDir, "config.yaml") + globalCfg, err := config.LoadGlobalConfig(globalConfigPath) + if err != nil { + slog.Warn("reconcile: failed to load global config, using empty", "error", err) + globalCfg = &config.GlobalConfig{} + } + + // Build HAProxy routes from registered services + var instanceRoutes []haproxy.InstanceRoute + var httpRoutes []haproxy.HTTPRoute + + for _, svc := range svcs { + if svc.Reach == services.ReachOff { + continue + } + + switch svc.Backend.Type { + case services.BackendTCPPassthrough: + instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ + Name: svc.Name, + Domain: svc.Domain, + BackendIP: extractHost(svc.Backend.Address), + ExtraDomains: svc.ExtraDomains, + }) + case services.BackendHTTP, services.BackendStatic: + httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ + Name: svc.Name, + Domain: svc.Domain, + Backend: svc.Backend.Address, + HealthPath: svc.Backend.Health, + }) + } + } + + // Determine central domain for HAProxy + centralDomain := "" + if globalCfg.Cloud.Central.Domain != "" { + centralDomain = globalCfg.Cloud.Central.Domain + } + + // Generate and write HAProxy config + haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{ + CentralDomain: centralDomain, + HTTPRoutes: httpRoutes, + }) + + if err := api.haproxy.WriteConfig(haproxyCfg); err != nil { + slog.Error("reconcile: failed to write HAProxy config", "error", err) + } else { + if err := api.haproxy.ReloadService(); err != nil { + slog.Warn("reconcile: failed to reload HAProxy", "error", err) + } else { + api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered services") + } + } + + // Generate dnsmasq DNS entries from registered services + // Build instance configs for backward compatibility with dnsmasq generator + var instanceConfigs []config.InstanceConfig + for _, svc := range svcs { + if svc.Reach == services.ReachOff || svc.Domain == "" { + continue + } + ic := config.InstanceConfig{} + ic.Cloud.Domain = svc.Domain + ic.Cluster.LoadBalancerIp = extractHost(svc.Backend.Address) + instanceConfigs = append(instanceConfigs, ic) + } + + if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil { + slog.Error("reconcile: failed to update dnsmasq", "error", err) + } else { + api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered services") + } + + slog.Info("reconcile: networking updated", + "services", len(svcs), + "l4Routes", len(instanceRoutes), + "l7Routes", len(httpRoutes), + ) +} + +// extractHost gets the host part from a host:port string +func extractHost(addr string) string { + for i := len(addr) - 1; i >= 0; i-- { + if addr[i] == ':' { + return addr[:i] + } + } + return addr +} + // broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes func (api *API) broadcastDnsmasqEvent(eventType string, message string) { if api.sseManager == nil {