Files
wild-central/internal/api/v1/handlers_reconciliation_test.go
Paul Payne 112f7c7f55 test: Verify Central backend uses configured port, not default
The reconciliation test for Central domain now verifies the HAProxy
backend uses port 15055 (the configured port) and NOT port 5055
(the default). This catches the bug where Reconcile() was called
before SetPort().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 20:59:42 +00:00

364 lines
12 KiB
Go

package v1
import (
"fmt"
"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).
// Uses port 15055 — must match the running port, NOT default 5055.
centralDomain := "central.payne.io"
centralPort := 15055
httpRoutes = append([]haproxy.HTTPRoute{{
Name: "wild-central",
Domain: centralDomain,
Backend: fmt.Sprintf("127.0.0.1:%d", centralPort),
}}, 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 backend must use the configured port (15055), not default 5055
if !strings.Contains(cfg, "server s0 127.0.0.1:15055") {
t.Errorf("Central backend must use port 15055, not 5055:\n%s", cfg)
}
if strings.Contains(cfg, "server s0 127.0.0.1:5055") {
t.Errorf("Central backend must NOT use default port 5055:\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)
}
}