Files
wild-central/internal/api/v1/handlers_reconciliation_test.go

468 lines
14 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/haproxy"
"github.com/wild-cloud/wild-central/internal/services"
)
// TestReconciliation_HAProxyConfigFromServices verifies that the reconciliation
// logic correctly maps registered services 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_HAProxyConfigFromServices(t *testing.T) {
api, _ := setupTestAPI(t)
// Register a mix of services like a real deployment
testServices := []services.Service{
{
Domain: "cloud.payne.io",
Source: "wild-cloud",
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
Subdomains: true,
Public: true,
},
{
Domain: "payne.io",
Source: "wild-cloud",
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
Subdomains: false,
Public: true,
},
{
Domain: "wild-cloud.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
// Public defaults to false
},
{
Domain: "my-api.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP, Health: "/health"},
// Public defaults to false
},
}
for _, svc := range testServices {
if err := api.services.Register(svc); err != nil {
t.Fatalf("Register %s failed: %v", svc.Domain, err)
}
}
// Replicate reconcileNetworking route-building logic
svcs, err := api.services.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
var instanceRoutes []haproxy.InstanceRoute
var httpRoutes []haproxy.HTTPRoute
for _, svc := range svcs {
switch svc.Backend.Type {
case services.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
Name: svc.Domain,
Domain: svc.Domain,
BackendIP: extractHost(svc.Backend.Address),
Subdomains: svc.Subdomains,
})
case services.BackendHTTP:
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain,
Domain: svc.Domain,
Backend: svc.Backend.Address,
HealthPath: svc.Backend.Health,
})
}
}
// Central registers itself as a service; add it to the test data
if err := api.services.Register(services.Service{
Domain: "central.payne.io",
Source: "central",
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
// Public defaults to false
TLS: services.TLSTerminate,
}); err != nil {
t.Fatalf("Register central failed: %v", err)
}
// Re-list to pick up Central
svcs, err = api.services.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
httpRoutes = nil
for _, svc := range svcs {
if svc.Backend.Type == services.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain,
Domain: svc.Domain,
Backend: svc.Backend.Address,
HealthPath: svc.Backend.Health,
})
}
}
cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/etc/haproxy/certs/",
})
// --- Assertions on HAProxy config ---
// L7 services 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 services 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 services 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_DnsmasqConfigFromServices verifies that reconciliation
// correctly generates dnsmasq config from registered services, including
// the critical reach-based local=/ directives.
func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register services with different reach and backend types
testServices := []services.Service{
{
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/
Domain: "cloud.payne.io",
Source: "wild-cloud",
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
Subdomains: true,
Public: true,
},
{
// http, internal → DNS points to Central IP, has local=/
Domain: "my-api.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP},
// Public defaults to false
},
{
// http, public → DNS points to Central IP, NO local=/
Domain: "public-app.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:8080", Type: services.BackendHTTP},
Public: true,
},
}
for _, svc := range testServices {
if err := api.services.Register(svc); err != nil {
t.Fatalf("Register %s failed: %v", svc.Domain, err)
}
}
// Build dnsmasq instance configs the same way reconcileNetworking does
svcs, err := api.services.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
globalCfg := &config.GlobalConfig{}
var instanceConfigs []config.InstanceConfig
for _, svc := range svcs {
if svc.Domain == "" {
continue
}
dnsIP := centralIP
if svc.Backend.Type == services.BackendTCPPassthrough {
dnsIP = extractHost(svc.Backend.Address)
}
ic := config.InstanceConfig{}
ic.Cluster.LoadBalancerIp = dnsIP
if !svc.Public {
ic.Cloud.InternalDomain = svc.Domain
} else {
ic.Cloud.Domain = svc.Domain
}
instanceConfigs = append(instanceConfigs, ic)
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs)
// --- TCP passthrough (public) → backend IP ---
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("tcp-passthrough service must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg)
}
// --- HTTP services → Central IP ---
if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") {
t.Errorf("http/internal service 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 service must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
}
// --- reach:internal → local=/ present ---
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
t.Errorf("reach:internal service must have local=/ entry:\n%s", dnsmasqCfg)
}
// --- reach:public → NO local=/ ---
if strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg)
}
if strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg)
}
}
// TestReconciliation_CentralDomainInHAProxy verifies that Central, registered
// as a service, produces correct HAProxy L7 routes.
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
api, _ := setupTestAPI(t)
centralPort := 15055
// Register Central as a service (as EnsureCentralService does)
if err := api.services.Register(services.Service{
Domain: "central.payne.io",
Source: "central",
Backend: services.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: services.BackendHTTP},
// Public defaults to false
TLS: services.TLSTerminate,
}); err != nil {
t.Fatalf("Register central failed: %v", err)
}
// Register another HTTP service
if err := api.services.Register(services.Service{
Domain: "my-app.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP},
// Public defaults to false
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
svcs, _ := api.services.List()
var httpRoutes []haproxy.HTTPRoute
for _, svc := range svcs {
if svc.Backend.Type == services.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain,
Domain: svc.Domain,
Backend: svc.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
// services 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.services.Register(services.Service{
Domain: "cloud.payne.io",
Source: "wild-cloud",
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
Subdomains: true,
Public: true,
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
svcs, _ := api.services.List()
globalCfg := &config.GlobalConfig{}
var instanceConfigs []config.InstanceConfig
for _, svc := range svcs {
dnsIP := centralIP
if svc.Backend.Type == services.BackendTCPPassthrough {
dnsIP = extractHost(svc.Backend.Address)
}
ic := config.InstanceConfig{}
ic.Cluster.LoadBalancerIp = dnsIP
if !svc.Public {
ic.Cloud.InternalDomain = svc.Domain
} else {
ic.Cloud.Domain = svc.Domain
}
instanceConfigs = append(instanceConfigs, ic)
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs)
// 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)
}
}
// 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, "config.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 TestEnsureCentralService_RegistersWhenDomainConfigured(t *testing.T) {
api, dataDir := setupTestAPI(t)
api.SetPort(15055)
writeTestConfig(t, dataDir, "central.example.com")
api.EnsureCentralService()
svc, err := api.services.Get("central.example.com")
if err != nil {
t.Fatalf("expected Central service to be registered: %v", err)
}
if svc.Source != "central" {
t.Errorf("source = %q, want %q", svc.Source, "central")
}
if svc.Backend.Address != "127.0.0.1:15055" {
t.Errorf("backend = %q, want %q", svc.Backend.Address, "127.0.0.1:15055")
}
if svc.TLS != services.TLSTerminate {
t.Errorf("tls = %q, want %q", svc.TLS, services.TLSTerminate)
}
}
func TestEnsureCentralService_Idempotent(t *testing.T) {
api, dataDir := setupTestAPI(t)
api.SetPort(15055)
writeTestConfig(t, dataDir, "central.example.com")
api.EnsureCentralService()
api.EnsureCentralService() // second call should be a no-op
svcs, _ := api.services.List()
count := 0
for _, s := range svcs {
if s.Source == "central" {
count++
}
}
if count != 1 {
t.Errorf("expected 1 central service, got %d", count)
}
}
func TestEnsureCentralService_CleansUpOnDomainChange(t *testing.T) {
api, dataDir := setupTestAPI(t)
api.SetPort(15055)
// Register with old domain
writeTestConfig(t, dataDir, "old.example.com")
api.EnsureCentralService()
// Change domain
writeTestConfig(t, dataDir, "new.example.com")
api.EnsureCentralService()
// Old domain should be gone
if _, err := api.services.Get("old.example.com"); err == nil {
t.Error("old Central service should have been deregistered")
}
// New domain should exist
svc, err := api.services.Get("new.example.com")
if err != nil {
t.Fatalf("new Central service should be registered: %v", err)
}
if svc.Source != "central" {
t.Errorf("source = %q, want %q", svc.Source, "central")
}
}
func TestEnsureCentralService_NoDomainIsNoop(t *testing.T) {
api, _ := setupTestAPI(t)
api.SetPort(15055)
// Default config has no Central domain
api.EnsureCentralService()
svcs, _ := api.services.List()
for _, s := range svcs {
if s.Source == "central" {
t.Error("no Central service should be registered when domain is empty")
}
}
}