Files
wild-central/internal/api/v1/handlers_reconciliation_test.go
2026-07-11 23:05:17 +00:00

549 lines
17 KiB
Go

package v1
import (
"fmt"
"os"
"path/filepath"
"strings"
"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"
)
// TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation
// logic correctly maps registered domains to HAProxy configuration. This
// replicates the route-building logic from reconcileNetworking() and asserts
// on the generated config without requiring haproxy or dnsmasq binaries.
func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) {
api, _ := setupTestAPI(t)
// Register a mix of domains like a real deployment
testDomains := []domains.Domain{
{
DomainName: "cloud.payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: true,
Public: true,
},
{
DomainName: "payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: false,
Public: true,
},
{
DomainName: "wild-cloud.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
},
{
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP, Health: "/health"},
},
}
for _, dom := range testDomains {
if err := api.domains.Register(dom); err != nil {
t.Fatalf("Register %s failed: %v", dom.DomainName, err)
}
}
// Replicate reconcileNetworking route-building logic
doms, err := api.domains.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
var instanceRoutes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.Backend.Type {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.Backend.Address),
Subdomains: dom.Subdomains,
})
case domains.BackendHTTP:
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{
Backend: dom.Backend.Address,
HealthPath: dom.Backend.Health,
}},
})
}
}
// Central registers itself as a domain; add it to the test data
if err := api.domains.Register(domains.Domain{
DomainName: "central.payne.io",
Source: "central",
Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
TLS: domains.TLSTerminate,
}); err != nil {
t.Fatalf("Register central failed: %v", err)
}
// Re-list to pick up Central
doms, err = api.domains.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
httpRoutes = nil
for _, dom := range doms {
if dom.Backend.Type == domains.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{
Backend: dom.Backend.Address,
HealthPath: dom.Backend.Health,
}},
})
}
}
cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/etc/haproxy/certs/",
})
// --- Assertions on HAProxy config ---
// L7 domains have exact-match ACLs in the SNI frontend
if !strings.Contains(cfg, "acl is_l7_central_payne_io req_ssl_sni -m str central.payne.io") {
t.Errorf("expected L7 exact-match ACL for central.payne.io:\n%s", cfg)
}
if !strings.Contains(cfg, "acl is_l7_wild_cloud_payne_io req_ssl_sni -m str wild-cloud.payne.io") {
t.Errorf("expected L7 exact-match ACL for wild-cloud.payne.io:\n%s", cfg)
}
// L4 domains with subdomains:true have wildcard ACLs
if !strings.Contains(cfg, "req_ssl_sni -m end .cloud.payne.io") {
t.Errorf("expected L4 wildcard ACL for cloud.payne.io (subdomains:true):\n%s", cfg)
}
// L4 domains with subdomains:false have exact-match only
if !strings.Contains(cfg, "req_ssl_sni -m str payne.io") {
t.Errorf("expected L4 exact-match ACL for payne.io:\n%s", cfg)
}
if strings.Contains(cfg, "req_ssl_sni -m end .payne.io") {
t.Errorf("payne.io (subdomains:false) must NOT have wildcard ACL:\n%s", cfg)
}
// Central's config domain appears as an L7 route
if !strings.Contains(cfg, "hdr(host) -i central.payne.io") {
t.Errorf("expected Host header ACL for central.payne.io in L7 frontend:\n%s", cfg)
}
// L7 ACLs appear BEFORE L4 wildcard ACLs
l7Pos := strings.Index(cfg, "is_l7_central_payne_io")
l4WildcardPos := strings.Index(cfg, "req_ssl_sni -m end .cloud.payne.io")
if l7Pos > l4WildcardPos {
t.Errorf("L7 exact matches (pos %d) must appear before L4 wildcards (pos %d)", l7Pos, l4WildcardPos)
}
// L4 exact (payne.io) appears before L4 wildcard (*.cloud.payne.io)
l4ExactPos := strings.Index(cfg, "acl is_payne_io req_ssl_sni -m str payne.io")
if l4ExactPos < 0 {
t.Fatalf("expected L4 exact ACL for payne.io:\n%s", cfg)
}
if l4ExactPos > l4WildcardPos {
t.Errorf("L4 exact match (pos %d) must come before L4 wildcard (pos %d)", l4ExactPos, l4WildcardPos)
}
// Health check path is present for my-api
if !strings.Contains(cfg, "option httpchk GET /health") {
t.Errorf("expected health check for my-api:\n%s", cfg)
}
}
// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
// correctly generates dnsmasq config from registered domains, including
// the critical reach-based local=/ directives.
func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register domains with different reach and backend types
testDomains := []domains.Domain{
{
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/
DomainName: "cloud.payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: true,
Public: true,
},
{
// http, internal → DNS points to Central IP, has local=/
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
},
{
// http, public → DNS points to Central IP, NO local=/
DomainName: "public-app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
Public: true,
},
}
for _, dom := range testDomains {
if err := api.domains.Register(dom); err != nil {
t.Fatalf("Register %s failed: %v", dom.DomainName, err)
}
}
// Build dnsmasq entries the same way reconcileNetworking does
doms, err := api.domains.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
globalCfg := &config.State{}
var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms {
if dom.DomainName == "" {
continue
}
dnsIP := centralIP
if dom.Backend.Type == domains.BackendTCPPassthrough {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// --- TCP passthrough (public) → backend IP ---
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg)
}
// --- HTTP domains → Central IP ---
if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") {
t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") {
t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
}
// --- All domains get local=/ to prevent AAAA leaking upstream (Happy Eyeballs) ---
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
t.Errorf("domain must have local=/ entry:\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
}
}
// TestReconciliation_CentralDomainInHAProxy verifies that Central, registered
// as a domain, produces correct HAProxy L7 routes.
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
api, _ := setupTestAPI(t)
centralPort := 15055
// Register Central as a domain (as EnsureCentralDomain does)
if err := api.domains.Register(domains.Domain{
DomainName: "central.payne.io",
Source: "central",
Backend: domains.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: domains.BackendHTTP},
TLS: domains.TLSTerminate,
}); err != nil {
t.Fatalf("Register central failed: %v", err)
}
// Register another HTTP domain
if err := api.domains.Register(domains.Domain{
DomainName: "my-app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
doms, _ := api.domains.List()
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
if dom.Backend.Type == domains.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{
Backend: dom.Backend.Address,
}},
})
}
}
cfg := api.haproxy.GenerateWithOpts(nil, nil, haproxy.GenerateOpts{
HTTPRoutes: httpRoutes,
})
// Central domain should be present as both SNI ACL and Host header ACL
if !strings.Contains(cfg, "req_ssl_sni -m str central.payne.io") {
t.Errorf("expected Central domain SNI ACL:\n%s", cfg)
}
if !strings.Contains(cfg, "hdr(host) -i central.payne.io") {
t.Errorf("expected Central domain Host ACL:\n%s", cfg)
}
// Central backend must use the configured port (15055)
if !strings.Contains(cfg, fmt.Sprintf("server s0 127.0.0.1:%d", centralPort)) {
t.Errorf("Central backend must use port %d:\n%s", centralPort, cfg)
}
}
// TestReconciliation_TCPPassthroughDNSTarget verifies that tcp-passthrough
// domains get DNS pointing to the backend IP, not Central's IP.
func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register a k8s instance with tcp-passthrough
if err := api.domains.Register(domains.Domain{
DomainName: "cloud.payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: true,
Public: true,
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
doms, _ := api.domains.List()
globalCfg := &config.State{}
var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms {
dnsIP := centralIP
if dom.Backend.Type == domains.BackendTCPPassthrough {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// TCP passthrough → DNS points to backend (k8s LB), NOT central
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("tcp-passthrough DNS must point to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
}
if strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.151") {
t.Errorf("tcp-passthrough DNS must NOT point to Central IP:\n%s", dnsmasqCfg)
}
}
// TestReconciliation_DNSOnlyHasNoDNSButNoProxy verifies that dns-only domains
// get DNS entries but no HAProxy routes.
func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register a dns-only domain (e.g. for SSH access)
if err := api.domains.Register(domains.Domain{
DomainName: "dev.payne.io",
Source: "manual",
Backend: domains.Backend{Address: "192.168.8.222", Type: domains.BackendDNSOnly},
Public: true,
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
// Also register a normal HTTP domain to verify it still works
if err := api.domains.Register(domains.Domain{
DomainName: "app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP},
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
doms, _ := api.domains.List()
// Build HAProxy routes — dns-only should be absent
var instanceRoutes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.Backend.Type {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.Backend.Address),
})
case domains.BackendDNSOnly:
// dns-only: no gateway routing
case domains.BackendHTTP:
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{Backend: dom.Backend.Address}},
})
}
}
cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/etc/haproxy/certs/",
})
// dns-only domain must NOT appear in HAProxy config
if strings.Contains(cfg, "dev.payne.io") {
t.Errorf("dns-only domain must NOT appear in HAProxy config:\n%s", cfg)
}
// HTTP domain should appear
if !strings.Contains(cfg, "app.payne.io") {
t.Errorf("http domain should appear in HAProxy config:\n%s", cfg)
}
// Build DNS config — dns-only should point to its backend IP
globalCfg := &config.State{}
var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms {
dnsIP := centralIP
bt := dom.Backend.Type
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// dns-only domain should resolve to its backend IP (192.168.8.222)
if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {
t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg)
}
// HTTP domain should resolve to Central IP
if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") {
t.Errorf("http domain must point DNS to Central IP:\n%s", dnsmasqCfg)
}
}
// writeTestConfig writes a minimal global config with the given Central domain.
func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
t.Helper()
configPath := filepath.Join(dataDir, "state.yaml")
content := fmt.Sprintf("cloud:\n central:\n domain: %s\n", centralDomain)
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
}
func TestEnsureCentralDomain_RegistersWhenDomainConfigured(t *testing.T) {
api, dataDir := setupTestAPI(t)
api.SetPort(15055)
writeTestConfig(t, dataDir, "central.example.com")
api.EnsureCentralDomain()
dom, err := api.domains.Get("central.example.com")
if err != nil {
t.Fatalf("expected Central domain to be registered: %v", err)
}
if dom.Source != "central" {
t.Errorf("source = %q, want %q", dom.Source, "central")
}
if dom.Backend.Address != "127.0.0.1:15055" {
t.Errorf("backend = %q, want %q", dom.Backend.Address, "127.0.0.1:15055")
}
if dom.TLS != domains.TLSTerminate {
t.Errorf("tls = %q, want %q", dom.TLS, domains.TLSTerminate)
}
}
func TestEnsureCentralDomain_Idempotent(t *testing.T) {
api, dataDir := setupTestAPI(t)
api.SetPort(15055)
writeTestConfig(t, dataDir, "central.example.com")
api.EnsureCentralDomain()
api.EnsureCentralDomain() // second call should be a no-op
doms, _ := api.domains.List()
count := 0
for _, d := range doms {
if d.Source == "central" {
count++
}
}
if count != 1 {
t.Errorf("expected 1 central domain, got %d", count)
}
}
func TestEnsureCentralDomain_CleansUpOnDomainChange(t *testing.T) {
api, dataDir := setupTestAPI(t)
api.SetPort(15055)
// Register with old domain
writeTestConfig(t, dataDir, "old.example.com")
api.EnsureCentralDomain()
// Change domain
writeTestConfig(t, dataDir, "new.example.com")
api.EnsureCentralDomain()
// Old domain should be gone
if _, err := api.domains.Get("old.example.com"); err == nil {
t.Error("old Central domain should have been deregistered")
}
// New domain should exist
dom, err := api.domains.Get("new.example.com")
if err != nil {
t.Fatalf("new Central domain should be registered: %v", err)
}
if dom.Source != "central" {
t.Errorf("source = %q, want %q", dom.Source, "central")
}
}
func TestEnsureCentralDomain_NoDomainIsNoop(t *testing.T) {
api, _ := setupTestAPI(t)
api.SetPort(15055)
// Default config has no Central domain
api.EnsureCentralDomain()
doms, _ := api.domains.List()
for _, d := range doms {
if d.Source == "central" {
t.Error("no Central domain should be registered when domain is empty")
}
}
}