Tests added (13 new): - HAProxy ACL ordering: L7 before L4 wildcard, L4 exact before L4 wildcard - HAProxy subdomains: false=exact only, true=wildcard+exact - Reconciliation integration: HAProxy config from services, DNS config from services, Central config domain injection, correct DNS target IPs - dnsmasq: reach:internal has local=/, reach:public does not - Services: idempotent registration, no duplicates in list Bug fix: - Run initial reconciliation on startup so Central's own domain (from config) gets HAProxy + DNS routes even with zero registered services. Previously, Central only got routes after the first service was registered, causing 503 on fresh restart. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
353 lines
12 KiB
Go
353 lines
12 KiB
Go
package v1
|
|
|
|
import (
|
|
"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,
|
|
Reach: services.ReachPublic,
|
|
},
|
|
{
|
|
Domain: "payne.io",
|
|
Source: "wild-cloud",
|
|
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough},
|
|
Subdomains: false,
|
|
Reach: services.ReachPublic,
|
|
},
|
|
{
|
|
Domain: "wild-cloud.payne.io",
|
|
Source: "wild-works",
|
|
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
|
|
Reach: services.ReachInternal,
|
|
},
|
|
{
|
|
Domain: "my-api.payne.io",
|
|
Source: "wild-works",
|
|
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP, Health: "/health"},
|
|
Reach: services.ReachInternal,
|
|
},
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Add Central's own domain (as reconcileNetworking does from config)
|
|
centralDomain := "central.payne.io"
|
|
httpRoutes = append([]haproxy.HTTPRoute{{
|
|
Name: "wild-central",
|
|
Domain: centralDomain,
|
|
Backend: "127.0.0.1:5055",
|
|
}}, httpRoutes...)
|
|
|
|
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_wild_central 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_wild_central")
|
|
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,
|
|
Reach: services.ReachPublic,
|
|
},
|
|
{
|
|
// 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},
|
|
Reach: services.ReachInternal,
|
|
},
|
|
{
|
|
// 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},
|
|
Reach: services.ReachPublic,
|
|
},
|
|
}
|
|
|
|
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.Reach == services.ReachInternal {
|
|
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 the Central domain
|
|
// from global config is injected as the first L7 HTTP route.
|
|
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
|
api, _ := setupTestAPI(t)
|
|
|
|
// Register one 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},
|
|
Reach: services.ReachInternal,
|
|
}); 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Central domain goes first (as reconcileNetworking does)
|
|
centralDomain := "central.payne.io"
|
|
httpRoutes = append([]haproxy.HTTPRoute{{
|
|
Name: "wild-central",
|
|
Domain: centralDomain,
|
|
Backend: "127.0.0.1:5055",
|
|
}}, httpRoutes...)
|
|
|
|
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 route should be the first L7 route
|
|
centralPos := strings.Index(cfg, "is_l7_wild_central")
|
|
appPos := strings.Index(cfg, "is_l7_my_app_payne_io")
|
|
if centralPos < 0 || appPos < 0 {
|
|
t.Fatalf("expected both L7 ACLs in config:\n%s", cfg)
|
|
}
|
|
if centralPos > appPos {
|
|
t.Errorf("Central domain (pos %d) should appear before app domain (pos %d)", centralPos, appPos)
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
Reach: services.ReachPublic,
|
|
}); 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.Reach == services.ReachInternal {
|
|
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)
|
|
}
|
|
}
|