Refactor architecture: extract reconciler, add interfaces, reduce complexity, improve test coverage
Architecture: - Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces - Add locked modifyState helper to fix state.yaml read-modify-write race condition - Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface - Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease - Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate Complexity reduction: - haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers) - reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig) - AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia) Test coverage improvements: - reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration) - dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries) - config: 67.3% → 89.8% (DHCP static lease mutation tests)
This commit is contained in:
399
internal/reconcile/reconciler.go
Normal file
399
internal/reconcile/reconciler.go
Normal file
@@ -0,0 +1,399 @@
|
||||
// Package reconcile orchestrates the domain→config→daemon pipeline.
|
||||
// When domains change, Reconcile regenerates HAProxy routes, dnsmasq DNS
|
||||
// entries, and related subsystem configs, then reloads the affected daemons.
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/authelia"
|
||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||
"github.com/wild-cloud/wild-central/internal/domains"
|
||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||
"github.com/wild-cloud/wild-central/internal/network"
|
||||
"github.com/wild-cloud/wild-central/internal/sse"
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// HAProxyManager is the subset of haproxy.Manager used by reconciliation.
|
||||
type HAProxyManager interface {
|
||||
GenerateWithOpts(instances []haproxy.L4Route, custom []haproxy.CustomRoute, opts haproxy.GenerateOpts) string
|
||||
WriteConfig(content string) error
|
||||
ReloadService() error
|
||||
}
|
||||
|
||||
// DNSManager is the subset of dnsmasq.ConfigGenerator used by reconciliation.
|
||||
type DNSManager interface {
|
||||
UpdateConfig(cfg *config.State, entries []dnsmasq.DNSEntry, restart bool) error
|
||||
SetFilterConfPath(path string)
|
||||
GetStatus() (*dnsmasq.ServiceStatus, error)
|
||||
}
|
||||
|
||||
// DomainManager is the subset of domains.Manager used by reconciliation.
|
||||
type DomainManager interface {
|
||||
List() ([]domains.Domain, error)
|
||||
}
|
||||
|
||||
// AuthManager is the subset of authelia.Manager used by reconciliation.
|
||||
type AuthManager interface {
|
||||
UserCount() int
|
||||
RestartService() error
|
||||
}
|
||||
|
||||
// DDNSRunner is the subset of ddns.Runner used by reconciliation.
|
||||
type DDNSRunner interface {
|
||||
Trigger()
|
||||
}
|
||||
|
||||
// DNSFilterManager is the subset of dnsfilter.Manager used by reconciliation.
|
||||
type DNSFilterManager interface {
|
||||
HostsFilePath() string
|
||||
}
|
||||
|
||||
// EventBroadcaster is the subset of sse.Manager used by reconciliation.
|
||||
type EventBroadcaster interface {
|
||||
Broadcast(event *sse.Event)
|
||||
}
|
||||
|
||||
// GenerateAutheliaConfigFn generates the Authelia config from current state.
|
||||
// This is a callback because the logic depends on secrets and OIDC clients
|
||||
// that the reconciler doesn't own.
|
||||
type GenerateAutheliaConfigFn func(state *config.State) error
|
||||
|
||||
// Reconciler orchestrates config regeneration when domains change.
|
||||
type Reconciler struct {
|
||||
Domains DomainManager
|
||||
HAProxy HAProxyManager
|
||||
DNS DNSManager
|
||||
Auth AuthManager
|
||||
DDNS DDNSRunner
|
||||
DNSFilter DNSFilterManager
|
||||
SSE EventBroadcaster
|
||||
StatePath string
|
||||
GenerateAutheliaConfig GenerateAutheliaConfigFn
|
||||
}
|
||||
|
||||
// Reconcile reads all registered domains and regenerates dnsmasq DNS entries
|
||||
// and HAProxy routes to match.
|
||||
func (r *Reconciler) Reconcile() {
|
||||
doms, err := r.Domains.List()
|
||||
if err != nil {
|
||||
slog.Error("reconcile: failed to list domains", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
globalCfg, err := config.LoadState(r.StatePath)
|
||||
if err != nil {
|
||||
slog.Warn("reconcile: failed to load state, using empty", "error", err)
|
||||
globalCfg = &config.State{}
|
||||
}
|
||||
|
||||
l4Routes, httpRoutes := buildRoutes(doms)
|
||||
|
||||
cleanZeroByteCerts("/etc/haproxy/certs/")
|
||||
|
||||
// Only include L7 HTTP routes for domains that have a valid cert.
|
||||
var activeHTTPRoutes []haproxy.HTTPRoute
|
||||
for _, route := range httpRoutes {
|
||||
if hasCertForDomain(route.Domain) {
|
||||
activeHTTPRoutes = append(activeHTTPRoutes, route)
|
||||
} else {
|
||||
slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
r.writeHAProxyConfig(l4Routes, activeHTTPRoutes, globalCfg)
|
||||
|
||||
// Build and apply DNS config
|
||||
centralIP := globalCfg.Cloud.Dnsmasq.IP
|
||||
if centralIP == "" {
|
||||
centralIP, _ = network.GetWildCentralIP()
|
||||
}
|
||||
if centralIP == "" {
|
||||
centralIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
dnsEntries := buildDNSEntries(doms, centralIP)
|
||||
|
||||
if globalCfg.Cloud.DNSFilter.Enabled {
|
||||
hostsPath := r.DNSFilter.HostsFilePath()
|
||||
if storage.FileExists(hostsPath) {
|
||||
r.DNS.SetFilterConfPath(hostsPath)
|
||||
}
|
||||
} else {
|
||||
r.DNS.SetFilterConfPath("")
|
||||
}
|
||||
|
||||
if err := r.DNS.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
|
||||
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
||||
} else {
|
||||
r.broadcastDNSEvent("dnsmasq:config", "DNS config regenerated from registered domains")
|
||||
}
|
||||
|
||||
if len(httpRoutes) > 0 {
|
||||
ensureTLSCerts(globalCfg, doms)
|
||||
}
|
||||
|
||||
r.DDNS.Trigger()
|
||||
|
||||
slog.Info("reconcile: networking updated",
|
||||
"domains", len(doms),
|
||||
"l4Routes", len(l4Routes),
|
||||
"l7Routes", len(httpRoutes),
|
||||
)
|
||||
}
|
||||
|
||||
// buildRoutes converts registered domains into HAProxy L4 and L7 route lists.
|
||||
func buildRoutes(doms []domains.Domain) ([]haproxy.L4Route, []haproxy.HTTPRoute) {
|
||||
var l4Routes []haproxy.L4Route
|
||||
var httpRoutes []haproxy.HTTPRoute
|
||||
|
||||
for _, dom := range doms {
|
||||
switch dom.EffectiveBackendType() {
|
||||
case domains.BackendTCPPassthrough:
|
||||
l4Routes = append(l4Routes, haproxy.L4Route{
|
||||
Name: dom.DomainName,
|
||||
Domain: dom.DomainName,
|
||||
BackendIP: extractHost(dom.EffectiveBackendAddress()),
|
||||
Subdomains: dom.Subdomains,
|
||||
})
|
||||
case domains.BackendDNSOnly:
|
||||
// dns-only: no gateway routing, just DNS resolution
|
||||
case domains.BackendHTTP:
|
||||
var routeBackends []haproxy.HTTPRouteBackend
|
||||
for _, route := range dom.EffectiveRoutes() {
|
||||
rb := haproxy.HTTPRouteBackend{
|
||||
Paths: route.Paths,
|
||||
Backend: route.Backend.Address,
|
||||
HealthPath: route.Backend.Health,
|
||||
Headers: route.Headers,
|
||||
IPAllow: route.IPAllow,
|
||||
}
|
||||
if dom.Auth != nil && dom.Auth.Enabled {
|
||||
rb.AuthEnabled = true
|
||||
rb.AuthPolicy = dom.Auth.Policy
|
||||
}
|
||||
routeBackends = append(routeBackends, rb)
|
||||
}
|
||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||
Name: dom.DomainName,
|
||||
Domain: dom.DomainName,
|
||||
Subdomains: dom.Subdomains,
|
||||
Routes: routeBackends,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return l4Routes, httpRoutes
|
||||
}
|
||||
|
||||
// buildDNSEntries converts domains to dnsmasq entries with appropriate IPs.
|
||||
// HTTP domains point to centralIP (HAProxy terminates TLS).
|
||||
// TCP passthrough and DNS-only domains point to their backend IP directly.
|
||||
func buildDNSEntries(doms []domains.Domain, centralIP string) []dnsmasq.DNSEntry {
|
||||
var entries []dnsmasq.DNSEntry
|
||||
for _, dom := range doms {
|
||||
if dom.DomainName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
dnsIP := centralIP
|
||||
bt := dom.EffectiveBackendType()
|
||||
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
||||
dnsIP = extractHost(dom.EffectiveBackendAddress())
|
||||
}
|
||||
|
||||
entries = append(entries, dnsmasq.DNSEntry{
|
||||
Domain: dom.DomainName,
|
||||
IP: dnsIP,
|
||||
Wildcard: dom.Subdomains,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// writeHAProxyConfig generates, validates, and writes the HAProxy config.
|
||||
// On validation failure, it identifies broken domains and retries without them.
|
||||
func (r *Reconciler) writeHAProxyConfig(l4Routes []haproxy.L4Route, httpRoutes []haproxy.HTTPRoute, globalCfg *config.State) {
|
||||
genOpts := haproxy.GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
CertsDir: "/etc/haproxy/certs/",
|
||||
}
|
||||
|
||||
if globalCfg.Cloud.Authelia.Enabled && globalCfg.Cloud.Authelia.Domain != "" {
|
||||
genOpts.AuthEnabled = true
|
||||
genOpts.AuthBackend = "127.0.0.1:9091"
|
||||
genOpts.AuthDomain = globalCfg.Cloud.Authelia.Domain
|
||||
genOpts.AuthSessionDomain = authelia.SessionDomainFromAuthDomain(globalCfg.Cloud.Authelia.Domain)
|
||||
|
||||
if r.GenerateAutheliaConfig != nil {
|
||||
if err := r.GenerateAutheliaConfig(globalCfg); err != nil {
|
||||
slog.Warn("reconcile: failed to regenerate authelia config", "error", err)
|
||||
} else if r.Auth.UserCount() > 0 {
|
||||
_ = r.Auth.RestartService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg := r.HAProxy.GenerateWithOpts(l4Routes, nil, genOpts)
|
||||
|
||||
if err := r.HAProxy.WriteConfig(cfg); err != nil {
|
||||
brokenDomains := haproxy.FindBrokenServices(cfg, haproxy.ParseValidationErrors(err.Error()))
|
||||
if len(brokenDomains) > 0 {
|
||||
slog.Error("reconcile: excluding broken domains and retrying",
|
||||
"broken", brokenDomains, "error", err)
|
||||
|
||||
exclude := map[string]bool{}
|
||||
for _, d := range brokenDomains {
|
||||
exclude[d] = true
|
||||
}
|
||||
|
||||
var filteredL4 []haproxy.L4Route
|
||||
for _, route := range l4Routes {
|
||||
if !exclude[route.Domain] {
|
||||
filteredL4 = append(filteredL4, route)
|
||||
}
|
||||
}
|
||||
var filteredHTTP []haproxy.HTTPRoute
|
||||
for _, route := range httpRoutes {
|
||||
if !exclude[route.Domain] {
|
||||
filteredHTTP = append(filteredHTTP, route)
|
||||
}
|
||||
}
|
||||
|
||||
genOpts.HTTPRoutes = filteredHTTP
|
||||
cfg = r.HAProxy.GenerateWithOpts(filteredL4, nil, genOpts)
|
||||
if err := r.HAProxy.WriteConfig(cfg); err != nil {
|
||||
slog.Error("reconcile: retry also failed", "error", err)
|
||||
} else if err := r.HAProxy.ReloadService(); err != nil {
|
||||
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
||||
} else {
|
||||
r.broadcastEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
|
||||
}
|
||||
} else {
|
||||
slog.Error("reconcile: failed to write HAProxy config (no broken domains identified)", "error", err)
|
||||
}
|
||||
} else if err := r.HAProxy.ReloadService(); err != nil {
|
||||
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
||||
} else {
|
||||
r.broadcastEvent("haproxy:config", "HAProxy config regenerated from registered domains")
|
||||
}
|
||||
}
|
||||
|
||||
// cleanZeroByteCerts removes 0-byte cert files that would poison HAProxy validation.
|
||||
func cleanZeroByteCerts(certsDir string) {
|
||||
entries, err := os.ReadDir(certsDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".pem") {
|
||||
if info, err := e.Info(); err == nil && info.Size() == 0 {
|
||||
slog.Warn("reconcile: removing 0-byte cert file", "file", e.Name())
|
||||
_ = os.Remove(filepath.Join(certsDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastEvent sends a simple SSE event.
|
||||
func (r *Reconciler) broadcastEvent(eventType, message string) {
|
||||
if r.SSE == nil {
|
||||
return
|
||||
}
|
||||
r.SSE.Broadcast(&sse.Event{
|
||||
ID: fmt.Sprintf("%s-%d", strings.SplitN(eventType, ":", 2)[0], time.Now().UnixNano()),
|
||||
Type: eventType,
|
||||
InstanceName: "global",
|
||||
Timestamp: time.Now(),
|
||||
Data: map[string]any{"message": message},
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastDNSEvent sends a dnsmasq SSE event including current status.
|
||||
func (r *Reconciler) broadcastDNSEvent(eventType, message string) {
|
||||
if r.SSE == nil {
|
||||
return
|
||||
}
|
||||
status, err := r.DNS.GetStatus()
|
||||
if err != nil {
|
||||
status = nil
|
||||
}
|
||||
r.SSE.Broadcast(&sse.Event{
|
||||
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
|
||||
Type: eventType,
|
||||
InstanceName: "global",
|
||||
Timestamp: time.Now(),
|
||||
Data: map[string]any{
|
||||
"message": message,
|
||||
"status": status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ensureTLSCerts logs which certificates are missing for registered domains.
|
||||
// Does NOT auto-provision — cert provisioning is user-initiated.
|
||||
func ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) {
|
||||
gatewayDomain := ""
|
||||
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
|
||||
gatewayDomain = parts[1]
|
||||
}
|
||||
|
||||
for _, dom := range doms {
|
||||
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if covered by wildcard
|
||||
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
|
||||
wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
|
||||
if _, err := os.Stat(wildcardCert); err != nil {
|
||||
slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page",
|
||||
"domain", dom.DomainName, "needed", "*."+gatewayDomain)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check individual cert
|
||||
if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
|
||||
slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page",
|
||||
"domain", dom.DomainName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
|
||||
// either an individual cert (<domain>.pem) or a wildcard cert that covers it.
|
||||
func hasCertForDomain(domain string) bool {
|
||||
if isValidCertFile(certbot.HAProxyCertPath(domain)) {
|
||||
return true
|
||||
}
|
||||
parts := strings.SplitN(domain, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
if isValidCertFile(certbot.HAProxyCertPath(parts[1])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isValidCertFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.Size() > 0
|
||||
}
|
||||
|
||||
func extractHost(addr string) string {
|
||||
for i := len(addr) - 1; i >= 0; i-- {
|
||||
if addr[i] == ':' {
|
||||
return addr[:i]
|
||||
}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
286
internal/reconcile/reconciler_test.go
Normal file
286
internal/reconcile/reconciler_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||
"github.com/wild-cloud/wild-central/internal/domains"
|
||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||
"github.com/wild-cloud/wild-central/internal/sse"
|
||||
)
|
||||
|
||||
// --- Stubs ---
|
||||
|
||||
type stubDomainManager struct {
|
||||
domains []domains.Domain
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubDomainManager) List() ([]domains.Domain, error) {
|
||||
return s.domains, s.err
|
||||
}
|
||||
|
||||
type stubHAProxy struct {
|
||||
generateCalls int
|
||||
lastL4Routes []haproxy.L4Route
|
||||
writtenConfig string
|
||||
writeErr error
|
||||
reloadCalled bool
|
||||
}
|
||||
|
||||
func (s *stubHAProxy) GenerateWithOpts(l4 []haproxy.L4Route, _ []haproxy.CustomRoute, _ haproxy.GenerateOpts) string {
|
||||
s.generateCalls++
|
||||
s.lastL4Routes = l4
|
||||
return "generated-config"
|
||||
}
|
||||
func (s *stubHAProxy) WriteConfig(c string) error { s.writtenConfig = c; return s.writeErr }
|
||||
func (s *stubHAProxy) ReloadService() error { s.reloadCalled = true; return nil }
|
||||
|
||||
type stubDNS struct {
|
||||
entries []dnsmasq.DNSEntry
|
||||
filterPath string
|
||||
updateCalled bool
|
||||
}
|
||||
|
||||
func (s *stubDNS) UpdateConfig(_ *config.State, entries []dnsmasq.DNSEntry, _ bool) error {
|
||||
s.entries = entries
|
||||
s.updateCalled = true
|
||||
return nil
|
||||
}
|
||||
func (s *stubDNS) SetFilterConfPath(p string) { s.filterPath = p }
|
||||
func (s *stubDNS) GetStatus() (*dnsmasq.ServiceStatus, error) { return nil, nil }
|
||||
|
||||
type stubAuth struct {
|
||||
userCount int
|
||||
restartCalled bool
|
||||
}
|
||||
|
||||
func (s *stubAuth) UserCount() int { return s.userCount }
|
||||
func (s *stubAuth) RestartService() error { s.restartCalled = true; return nil }
|
||||
|
||||
type stubDDNS struct{ triggerCalled bool }
|
||||
|
||||
func (s *stubDDNS) Trigger() { s.triggerCalled = true }
|
||||
|
||||
type stubDNSFilter struct{ hostsPath string }
|
||||
|
||||
func (s *stubDNSFilter) HostsFilePath() string { return s.hostsPath }
|
||||
|
||||
type stubSSE struct{ events []*sse.Event }
|
||||
|
||||
func (s *stubSSE) Broadcast(e *sse.Event) { s.events = append(s.events, e) }
|
||||
|
||||
func newTestReconciler(t *testing.T, doms []domains.Domain) (*Reconciler, *stubHAProxy, *stubDNS, *stubDDNS) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
statePath := filepath.Join(tmpDir, "state.yaml")
|
||||
// Write minimal state
|
||||
_ = config.SaveState(&config.State{}, statePath)
|
||||
|
||||
hp := &stubHAProxy{}
|
||||
dns := &stubDNS{}
|
||||
ddns := &stubDDNS{}
|
||||
|
||||
r := &Reconciler{
|
||||
Domains: &stubDomainManager{domains: doms},
|
||||
HAProxy: hp,
|
||||
DNS: dns,
|
||||
Auth: &stubAuth{},
|
||||
DDNS: ddns,
|
||||
DNSFilter: &stubDNSFilter{},
|
||||
SSE: &stubSSE{},
|
||||
StatePath: statePath,
|
||||
}
|
||||
return r, hp, dns, ddns
|
||||
}
|
||||
|
||||
// --- Pure function tests ---
|
||||
|
||||
func TestBuildRoutes_MixedBackendTypes(t *testing.T) {
|
||||
doms := []domains.Domain{
|
||||
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true},
|
||||
{DomainName: "api.example.com", Backend: domains.Backend{Address: "192.168.1.20:8080", Type: domains.BackendHTTP}},
|
||||
{DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}},
|
||||
}
|
||||
|
||||
l4, http := buildRoutes(doms)
|
||||
|
||||
if len(l4) != 1 {
|
||||
t.Fatalf("expected 1 L4 route, got %d", len(l4))
|
||||
}
|
||||
if l4[0].Domain != "cloud.example.com" || l4[0].BackendIP != "192.168.1.10" || !l4[0].Subdomains {
|
||||
t.Errorf("L4 route mismatch: %+v", l4[0])
|
||||
}
|
||||
|
||||
if len(http) != 1 {
|
||||
t.Fatalf("expected 1 HTTP route, got %d", len(http))
|
||||
}
|
||||
if http[0].Domain != "api.example.com" {
|
||||
t.Errorf("HTTP route domain = %q, want api.example.com", http[0].Domain)
|
||||
}
|
||||
if len(http[0].Routes) != 1 || http[0].Routes[0].Backend != "192.168.1.20:8080" {
|
||||
t.Errorf("HTTP route backend mismatch: %+v", http[0].Routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoutes_EmptyDomains(t *testing.T) {
|
||||
l4, http := buildRoutes(nil)
|
||||
if l4 != nil || http != nil {
|
||||
t.Error("expected nil routes for nil domains")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSEntries_IPAssignment(t *testing.T) {
|
||||
centralIP := "10.0.0.1"
|
||||
doms := []domains.Domain{
|
||||
{DomainName: "central.example.com", Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP}},
|
||||
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}},
|
||||
{DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}},
|
||||
}
|
||||
|
||||
entries := buildDNSEntries(doms, centralIP)
|
||||
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// HTTP → centralIP
|
||||
if entries[0].IP != centralIP {
|
||||
t.Errorf("HTTP domain IP = %q, want %q", entries[0].IP, centralIP)
|
||||
}
|
||||
// TCP passthrough → backend IP
|
||||
if entries[1].IP != "192.168.1.10" {
|
||||
t.Errorf("TCP domain IP = %q, want 192.168.1.10", entries[1].IP)
|
||||
}
|
||||
// DNS-only → backend IP
|
||||
if entries[2].IP != "192.168.1.30" {
|
||||
t.Errorf("DNS-only domain IP = %q, want 192.168.1.30", entries[2].IP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSEntries_SkipsEmptyDomainName(t *testing.T) {
|
||||
entries := buildDNSEntries([]domains.Domain{{DomainName: ""}}, "10.0.0.1")
|
||||
if len(entries) != 0 {
|
||||
t.Error("expected empty domain to be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSEntries_WildcardFlag(t *testing.T) {
|
||||
doms := []domains.Domain{
|
||||
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true},
|
||||
{DomainName: "exact.example.com", Backend: domains.Backend{Address: "192.168.1.20:443", Type: domains.BackendTCPPassthrough}, Subdomains: false},
|
||||
}
|
||||
entries := buildDNSEntries(doms, "10.0.0.1")
|
||||
if !entries[0].Wildcard {
|
||||
t.Error("expected first entry to be wildcard")
|
||||
}
|
||||
if entries[1].Wildcard {
|
||||
t.Error("expected second entry to not be wildcard")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integration tests with stubs ---
|
||||
|
||||
func TestReconcile_EmptyDomains(t *testing.T) {
|
||||
r, hp, dns, ddns := newTestReconciler(t, nil)
|
||||
r.Reconcile()
|
||||
|
||||
if !dns.updateCalled {
|
||||
t.Error("expected dnsmasq update")
|
||||
}
|
||||
if len(dns.entries) != 0 {
|
||||
t.Errorf("expected 0 DNS entries, got %d", len(dns.entries))
|
||||
}
|
||||
if !hp.reloadCalled {
|
||||
// No routes but config is still generated and written
|
||||
}
|
||||
if !ddns.triggerCalled {
|
||||
t.Error("expected DDNS trigger")
|
||||
}
|
||||
// With no routes, HAProxy should still generate (empty config is valid)
|
||||
if hp.generateCalls != 1 {
|
||||
t.Errorf("expected 1 HAProxy generate call, got %d", hp.generateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_DDNSTriggered(t *testing.T) {
|
||||
doms := []domains.Domain{
|
||||
{DomainName: "test.example.com", Backend: domains.Backend{Address: "127.0.0.1:80", Type: domains.BackendHTTP}},
|
||||
}
|
||||
r, _, _, ddns := newTestReconciler(t, doms)
|
||||
r.Reconcile()
|
||||
|
||||
if !ddns.triggerCalled {
|
||||
t.Error("expected DDNS.Trigger() to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_DNSEntriesBuiltFromDomains(t *testing.T) {
|
||||
doms := []domains.Domain{
|
||||
{DomainName: "app.example.com", Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP}},
|
||||
{DomainName: "k8s.example.com", Backend: domains.Backend{Address: "192.168.1.50:443", Type: domains.BackendTCPPassthrough}},
|
||||
}
|
||||
r, _, dns, _ := newTestReconciler(t, doms)
|
||||
r.Reconcile()
|
||||
|
||||
if len(dns.entries) != 2 {
|
||||
t.Fatalf("expected 2 DNS entries, got %d", len(dns.entries))
|
||||
}
|
||||
// HTTP domain should use centralIP (127.0.0.1 fallback since state has no IP)
|
||||
if dns.entries[0].Domain != "app.example.com" {
|
||||
t.Errorf("first DNS entry domain = %q", dns.entries[0].Domain)
|
||||
}
|
||||
// TCP passthrough should use backend IP directly
|
||||
if dns.entries[1].IP != "192.168.1.50" {
|
||||
t.Errorf("TCP DNS entry IP = %q, want 192.168.1.50", dns.entries[1].IP)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper tests ---
|
||||
|
||||
func TestIsValidCertFile_Valid(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "test.pem")
|
||||
if err := os.WriteFile(path, []byte("--- cert content ---"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isValidCertFile(path) {
|
||||
t.Error("expected valid cert file to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidCertFile_Empty(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := filepath.Join(tmp, "empty.pem")
|
||||
if err := os.WriteFile(path, nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isValidCertFile(path) {
|
||||
t.Error("expected 0-byte cert file to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidCertFile_Missing(t *testing.T) {
|
||||
if isValidCertFile("/nonexistent/path.pem") {
|
||||
t.Error("expected missing file to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"192.168.1.1:8080", "192.168.1.1"},
|
||||
{"example.com:443", "example.com"},
|
||||
{"localhost", "localhost"},
|
||||
{"127.0.0.1", "127.0.0.1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := extractHost(tt.input); got != tt.want {
|
||||
t.Errorf("extractHost(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user