feat: Add comprehensive tests + fix startup reconciliation

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>
This commit is contained in:
2026-07-09 20:55:27 +00:00
parent 1fca2b8032
commit e5cd6583f2
6 changed files with 621 additions and 2 deletions

View File

@@ -0,0 +1,352 @@
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)
}
}

View File

@@ -16,9 +16,14 @@ import (
"github.com/wild-cloud/wild-central/internal/sse"
)
// Reconcile runs networking reconciliation immediately. Called on startup
// and automatically whenever a service is registered/updated/deregistered.
func (api *API) Reconcile() {
api.reconcileNetworking()
}
// reconcileNetworking reads all registered services and regenerates
// dnsmasq DNS entries and HAProxy routes to match. Called automatically
// whenever a service is registered, updated, or deregistered.
// dnsmasq DNS entries and HAProxy routes to match.
func (api *API) reconcileNetworking() {
svcs, err := api.services.List()
if err != nil {

View File

@@ -352,6 +352,66 @@ func TestGenerateInstanceConfig_ServiceWithoutInternalDomain(t *testing.T) {
}
}
// Test: reach:internal → local=/ directive present (prevents upstream DNS forwarding)
func TestGenerate_ReachInternal_HasLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
// Simulate reconciliation for an internal-reach service:
// InternalDomain is set (reach:internal), Domain is empty (reach:public)
inst := instanceWith("", "my-api.payne.io", "192.168.8.151")
out := g.Generate(globalCfg, []config.InstanceConfig{inst})
if !strings.Contains(out, "local=/my-api.payne.io/") {
t.Errorf("reach:internal must produce local=/ directive, got:\n%s", out)
}
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") {
t.Errorf("reach:internal must produce address entry, got:\n%s", out)
}
}
// Test: reach:public → no local=/ directive (allows upstream DNS forwarding)
func TestGenerate_ReachPublic_NoLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
// Simulate reconciliation for a public-reach service:
// Domain is set (reach:public), InternalDomain is empty
inst := instanceWith("cloud.payne.io", "", "192.168.8.240")
out := g.Generate(globalCfg, []config.InstanceConfig{inst})
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("reach:public must produce address entry, got:\n%s", out)
}
if strings.Contains(out, "local=/cloud.payne.io/") {
t.Errorf("reach:public must NOT produce local=/ directive, got:\n%s", out)
}
}
// Test: InternalDomain field → local=/ entry for LAN-only resolution
func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
// Instance with only an internal domain (no external domain)
inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240")
out := g.Generate(globalCfg, []config.InstanceConfig{inst})
if !strings.Contains(out, "local=/internal.cloud.payne.io/") {
t.Errorf("InternalDomain must produce local=/ entry, got:\n%s", out)
}
if !strings.Contains(out, "address=/internal.cloud.payne.io/192.168.8.240") {
t.Errorf("InternalDomain must produce address entry, got:\n%s", out)
}
// No external domain → no address for empty domain
if strings.Contains(out, "address=//") {
t.Errorf("no external domain must not produce empty address=// entry, got:\n%s", out)
}
}
// Test: Mix of instances with and without internal domains
func TestGenerate_MixedServicesAndInstances(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")

View File

@@ -300,6 +300,114 @@ func TestGenerateWithOpts_MixedL4AndL7(t *testing.T) {
}
}
func TestGenerateWithOpts_ACLOrdering_L7BeforeL4Wildcard(t *testing.T) {
m := NewManager("")
// Scenario: cloud.payne.io with subdomains:true is an L4 wildcard route.
// wild-cloud.payne.io is an L7 HTTP service. The L7 exact match MUST
// appear before the L4 wildcard, otherwise HAProxy matches
// wild-cloud.payne.io against *.payne.io and sends it to the wrong backend.
instances := []InstanceRoute{
{Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.240", Subdomains: true},
}
httpRoutes := []HTTPRoute{
{Name: "wild-central", Domain: "central.payne.io", Backend: "127.0.0.1:15055"},
{Name: "wild-cloud", Domain: "wild-cloud.payne.io", Backend: "127.0.0.1:5055"},
}
out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes})
// All L7 exact matches must appear before L4 wildcard matches
l7Central := strings.Index(out, "is_l7_wild_central")
l7WildCloud := strings.Index(out, "is_l7_wild_cloud")
l4Wildcard := strings.Index(out, "req_ssl_sni -m end .payne.io")
if l7Central < 0 {
t.Fatalf("expected L7 ACL for wild-central, got:\n%s", out)
}
if l7WildCloud < 0 {
t.Fatalf("expected L7 ACL for wild-cloud, got:\n%s", out)
}
if l4Wildcard < 0 {
t.Fatalf("expected L4 wildcard ACL for payne.io, got:\n%s", out)
}
if l7Central > l4Wildcard {
t.Errorf("L7 central.payne.io (pos %d) must appear before L4 *.payne.io wildcard (pos %d)", l7Central, l4Wildcard)
}
if l7WildCloud > l4Wildcard {
t.Errorf("L7 wild-cloud.payne.io (pos %d) must appear before L4 *.payne.io wildcard (pos %d)", l7WildCloud, l4Wildcard)
}
// Verify the config order overall: L7 exact → L4 wildcard → default
defaultBackend := strings.Index(out, "default_backend be_l7_termination")
if defaultBackend < 0 {
t.Fatalf("expected default_backend for L7 termination, got:\n%s", out)
}
if l4Wildcard > defaultBackend {
t.Errorf("L4 wildcard (pos %d) must appear before default_backend (pos %d)", l4Wildcard, defaultBackend)
}
}
func TestGenerateWithOpts_SubdomainsFalse_ExactOnly(t *testing.T) {
m := NewManager("")
instances := []InstanceRoute{
{Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false},
}
out := m.GenerateWithOpts(instances, nil, GenerateOpts{})
// subdomains:false must produce ONLY -m str (exact), NOT -m end (wildcard)
if !strings.Contains(out, "req_ssl_sni -m str payne.io") {
t.Errorf("expected exact match ACL for payne.io, got:\n%s", out)
}
if strings.Contains(out, "req_ssl_sni -m end .payne.io") {
t.Errorf("subdomains:false must NOT produce wildcard ACL, got:\n%s", out)
}
}
func TestGenerateWithOpts_SubdomainsTrue_WildcardAndExact(t *testing.T) {
m := NewManager("")
instances := []InstanceRoute{
{Name: "cloud-payne", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true},
}
out := m.GenerateWithOpts(instances, nil, GenerateOpts{})
// subdomains:true must produce BOTH -m end (wildcard) AND -m str (exact)
if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") {
t.Errorf("subdomains:true must produce wildcard ACL (-m end), got:\n%s", out)
}
if !strings.Contains(out, "req_ssl_sni -m str cloud.payne.io") {
t.Errorf("subdomains:true must also produce exact ACL (-m str), got:\n%s", out)
}
// Both ACLs should use the same backend
if !strings.Contains(out, "use_backend be_https_cloud_payne if is_cloud_payne") {
t.Errorf("expected use_backend for cloud_payne, got:\n%s", out)
}
}
func TestGenerateWithOpts_ACLOrdering_L4ExactBeforeL4Wildcard(t *testing.T) {
m := NewManager("")
// payne.io exact (subdomains:false) must appear before cloud.payne.io wildcard (subdomains:true)
instances := []InstanceRoute{
{Name: "cloud-payne", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true},
{Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false},
}
out := m.GenerateWithOpts(instances, nil, GenerateOpts{})
exactPos := strings.Index(out, "req_ssl_sni -m str payne.io")
wildcardPos := strings.Index(out, "req_ssl_sni -m end .cloud.payne.io")
if exactPos < 0 {
t.Fatalf("expected exact match ACL for payne.io, got:\n%s", out)
}
if wildcardPos < 0 {
t.Fatalf("expected wildcard ACL for cloud.payne.io, got:\n%s", out)
}
if exactPos > wildcardPos {
t.Errorf("L4 exact match (pos %d) must come before L4 wildcard (pos %d):\n%s", exactPos, wildcardPos, out)
}
}
func TestGenerateWithOpts_BackwardCompatible(t *testing.T) {
m := NewManager("")
instances := []InstanceRoute{

View File

@@ -217,6 +217,96 @@ func TestDefaultSource(t *testing.T) {
}
}
func TestRegisterIdempotent(t *testing.T) {
mgr := NewManager(t.TempDir())
svc := Service{
Domain: "idempotent.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
Reach: ReachInternal,
}
// Register twice with same domain
if err := mgr.Register(svc); err != nil {
t.Fatalf("first Register failed: %v", err)
}
// Update the backend address on second registration
svc.Backend.Address = "127.0.0.1:9090"
if err := mgr.Register(svc); err != nil {
t.Fatalf("second Register failed: %v", err)
}
// Should still have only one service
svcs, err := mgr.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(svcs) != 1 {
t.Errorf("expected 1 service after idempotent register, got %d", len(svcs))
}
// Should have the updated address
got, err := mgr.Get("idempotent.example.com")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got.Backend.Address != "127.0.0.1:9090" {
t.Errorf("expected updated backend address 127.0.0.1:9090, got %s", got.Backend.Address)
}
}
func TestListNoDuplicates(t *testing.T) {
mgr := NewManager(t.TempDir())
domains := []string{
"alpha.example.com",
"beta.example.com",
"gamma.example.com",
}
// Register each domain
for _, domain := range domains {
if err := mgr.Register(Service{
Domain: domain,
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
Reach: ReachInternal,
}); err != nil {
t.Fatalf("Register %s failed: %v", domain, err)
}
}
// Re-register one domain to verify no duplication
if err := mgr.Register(Service{
Domain: "beta.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP},
Reach: ReachInternal,
}); err != nil {
t.Fatalf("Re-register beta failed: %v", err)
}
svcs, err := mgr.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(svcs) != 3 {
t.Errorf("expected 3 unique services, got %d", len(svcs))
}
// Verify no duplicate domains
seen := make(map[string]bool)
for _, svc := range svcs {
if seen[svc.Domain] {
t.Errorf("duplicate domain in List(): %s", svc.Domain)
}
seen[svc.Domain] = true
}
}
func TestDomainToFilename(t *testing.T) {
tests := []struct {
domain string

View File

@@ -116,6 +116,10 @@ func main() {
api.StartCentralStatusBroadcaster(startTime)
slog.Info("central status broadcaster started")
// Run initial reconciliation so Central's own domain (from config) gets
// DNS + HAProxy routes even with zero registered services.
api.Reconcile()
api.StartDDNS(ctx)
router := mux.NewRouter()