Files
wild-central/internal/api/v1/helpers.go
Paul Payne 5defc27f63 feat: Wire up service registration to networking reconciliation
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) <noreply@anthropic.com>
2026-07-09 03:45:51 +00:00

185 lines
5.0 KiB
Go

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 {
return
}
// Get current dnsmasq status
status, err := api.dnsmasq.GetStatus()
if err != nil {
status = nil
}
event := &sse.Event{
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global", // dnsmasq is global/central-level, not instance-specific
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
"status": status,
},
}
api.sseManager.Broadcast(event)
}
// broadcastHaproxyEvent broadcasts SSE events for HAProxy status changes
func (api *API) broadcastHaproxyEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
event := &sse.Event{
ID: fmt.Sprintf("haproxy-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
},
}
api.sseManager.Broadcast(event)
}
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
if api.sseManager == nil {
return
}
uptime := time.Since(startTime)
event := &sse.Event{
ID: fmt.Sprintf("central-%d", time.Now().UnixNano()),
Type: "central:status",
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"status": "running",
"version": api.version,
"uptime": uptime.String(),
"uptimeSeconds": int(uptime.Seconds()),
},
}
api.sseManager.Broadcast(event)
}