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>
364 lines
12 KiB
Go
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)
|
|
}
|
|
}
|