Files
nginx-ui/internal/upstream/dynamic_resolver_test.go
2025-07-20 22:17:23 +08:00

684 lines
20 KiB
Go

package upstream
import (
"context"
"fmt"
"net"
"sort"
"strings"
"testing"
)
// MockDNSServer simulates DNS responses for testing
type MockDNSServer struct {
srvRecords map[string][]*net.SRV
aRecords map[string][]net.IPAddr
}
// NewMockDNSServer creates a mock DNS server for testing
func NewMockDNSServer() *MockDNSServer {
return &MockDNSServer{
srvRecords: make(map[string][]*net.SRV),
aRecords: make(map[string][]net.IPAddr),
}
}
// AddSRVRecord adds a SRV record to the mock DNS server
func (m *MockDNSServer) AddSRVRecord(domain string, priority, weight uint16, port uint16, target string) {
m.srvRecords[domain] = append(m.srvRecords[domain], &net.SRV{
Priority: priority,
Weight: weight,
Port: port,
Target: target,
})
}
// AddARecord adds an A record to the mock DNS server
func (m *MockDNSServer) AddARecord(domain string, ip string) {
m.aRecords[domain] = append(m.aRecords[domain], net.IPAddr{
IP: net.ParseIP(ip),
})
}
// MockResolver is a custom resolver that uses our mock DNS server
type MockResolver struct {
mockServer *MockDNSServer
}
// LookupSRV simulates SRV record lookup with proper priority sorting
func (mr *MockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) {
domain := name
if service != "" || proto != "" {
domain = fmt.Sprintf("_%s._%s.%s", service, proto, name)
}
if records, exists := mr.mockServer.srvRecords[domain]; exists {
// Sort SRV records by priority (lowest first), then by weight (highest first)
// This follows RFC 2782 and nginx behavior
sortedRecords := make([]*net.SRV, len(records))
copy(sortedRecords, records)
sort.Slice(sortedRecords, func(i, j int) bool {
if sortedRecords[i].Priority != sortedRecords[j].Priority {
return sortedRecords[i].Priority < sortedRecords[j].Priority
}
// For same priority, higher weight comes first (but this is simplified for testing)
return sortedRecords[i].Weight > sortedRecords[j].Weight
})
return "", sortedRecords, nil
}
return "", nil, fmt.Errorf("no SRV records for %s", domain)
}
// LookupIPAddr simulates A record lookup
func (mr *MockResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
if records, exists := mr.mockServer.aRecords[host]; exists {
return records, nil
}
return nil, fmt.Errorf("no A records for %s", host)
}
// TestParseServiceURL tests the parseServiceURL function with nginx compliance
func TestParseServiceURL(t *testing.T) {
tests := []struct {
name string
input string
expectedErr bool
expected *ServiceInfo
}{
{
name: "Valid nginx service URL - simple service name",
input: "backend.example.com service=http resolve",
expected: &ServiceInfo{
Hostname: "backend.example.com",
ServiceName: "http",
},
},
{
name: "Valid nginx service URL - service name with underscores",
input: "backend.example.com service=_http._tcp resolve",
expected: &ServiceInfo{
Hostname: "backend.example.com",
ServiceName: "_http._tcp",
},
},
{
name: "Valid nginx service URL - service name with dots",
input: "example.com service=server1.backend resolve",
expected: &ServiceInfo{
Hostname: "example.com",
ServiceName: "server1.backend",
},
},
{
name: "Consul service example",
input: "service.consul service=web-service resolve",
expected: &ServiceInfo{
Hostname: "service.consul",
ServiceName: "web-service",
},
},
{
name: "Empty input",
input: "",
expectedErr: true,
},
{
name: "Missing resolve parameter",
input: "backend.example.com service=http",
expectedErr: true,
},
{
name: "Missing service parameter",
input: "backend.example.com resolve",
expectedErr: true,
},
{
name: "Empty service name",
input: "backend.example.com service= resolve",
expectedErr: true,
},
{
name: "Only hostname",
input: "backend.example.com",
expectedErr: true,
},
}
resolver := NewDynamicResolver("127.0.0.1:8600")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := resolver.parseServiceURL(tt.input)
if tt.expectedErr {
if err == nil {
t.Errorf("Expected error but got none")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if result.Hostname != tt.expected.Hostname {
t.Errorf("Expected hostname %s, got %s", tt.expected.Hostname, result.Hostname)
}
if result.ServiceName != tt.expected.ServiceName {
t.Errorf("Expected service name %s, got %s", tt.expected.ServiceName, result.ServiceName)
}
})
}
}
// TestConstructSRVDomain tests SRV domain construction according to nginx.org rules
func TestConstructSRVDomain(t *testing.T) {
tests := []struct {
name string
input *ServiceInfo
expected string
rule string
}{
{
name: "Rule 1: Service name without dots - http",
input: &ServiceInfo{
Hostname: "backend.example.com",
ServiceName: "http",
},
expected: "_http._tcp.backend.example.com",
rule: "nginx rule 1: no dots, add TCP protocol",
},
{
name: "Rule 1: Service name without dots - https",
input: &ServiceInfo{
Hostname: "api.example.com",
ServiceName: "https",
},
expected: "_https._tcp.api.example.com",
rule: "nginx rule 1: no dots, add TCP protocol",
},
{
name: "Rule 1: Service name without dots - mysql",
input: &ServiceInfo{
Hostname: "db.example.com",
ServiceName: "mysql",
},
expected: "_mysql._tcp.db.example.com",
rule: "nginx rule 1: no dots, add TCP protocol",
},
{
name: "Rule 2: Service name with dots - _http._tcp",
input: &ServiceInfo{
Hostname: "backend.example.com",
ServiceName: "_http._tcp",
},
expected: "_http._tcp.backend.example.com",
rule: "nginx rule 2: contains dots, join directly",
},
{
name: "Rule 2: Service name with dots - server1.backend",
input: &ServiceInfo{
Hostname: "example.com",
ServiceName: "server1.backend",
},
expected: "server1.backend.example.com",
rule: "nginx rule 2: contains dots, join directly",
},
{
name: "Rule 2: Complex service name with underscores and dots",
input: &ServiceInfo{
Hostname: "dc1.consul",
ServiceName: "_api._tcp.production",
},
expected: "_api._tcp.production.dc1.consul",
rule: "nginx rule 2: contains dots, join directly",
},
{
name: "Consul example - simple service",
input: &ServiceInfo{
Hostname: "service.consul",
ServiceName: "web",
},
expected: "_web._tcp.service.consul",
rule: "nginx rule 1: no dots, add TCP protocol",
},
}
resolver := NewDynamicResolver("127.0.0.1:8600")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := resolver.constructSRVDomain(tt.input)
if result != tt.expected {
t.Errorf("Expected SRV domain %s, got %s (rule: %s)", tt.expected, result, tt.rule)
}
})
}
}
// TestNginxOfficialExamples tests the exact examples from nginx.org documentation
func TestNginxOfficialExamples(t *testing.T) {
tests := []struct {
name string
nginxConfig string
expectedQuery string
description string
}{
{
name: "Official Example 1",
nginxConfig: "backend.example.com service=http resolve",
expectedQuery: "_http._tcp.backend.example.com",
description: "To look up _http._tcp.backend.example.com SRV record",
},
{
name: "Official Example 2",
nginxConfig: "backend.example.com service=_http._tcp resolve",
expectedQuery: "_http._tcp.backend.example.com",
description: "Service name already contains dots, join directly",
},
{
name: "Official Example 3",
nginxConfig: "example.com service=server1.backend resolve",
expectedQuery: "server1.backend.example.com",
description: "Service name contains dots, join directly",
},
}
resolver := NewDynamicResolver("127.0.0.1:8600")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
serviceInfo, err := resolver.parseServiceURL(tt.nginxConfig)
if err != nil {
t.Fatalf("Failed to parse nginx config: %v", err)
}
result := resolver.constructSRVDomain(serviceInfo)
if result != tt.expectedQuery {
t.Errorf("nginx.org example failed: expected %s, got %s (%s)",
tt.expectedQuery, result, tt.description)
}
})
}
}
// TestSRVRecordResolutionWithMockDNS tests actual SRV record resolution using mock DNS
func TestSRVRecordResolutionWithMockDNS(t *testing.T) {
// Create mock DNS server
mockDNS := NewMockDNSServer()
// Add SRV records for _http._tcp.backend.example.com
mockDNS.AddSRVRecord("_http._tcp.backend.example.com", 10, 60, 8080, "web1.backend.example.com")
mockDNS.AddSRVRecord("_http._tcp.backend.example.com", 10, 40, 8080, "web2.backend.example.com")
mockDNS.AddSRVRecord("_http._tcp.backend.example.com", 20, 100, 8080, "web3.backend.example.com")
// Add A records for the targets
mockDNS.AddARecord("web1.backend.example.com", "192.168.1.10")
mockDNS.AddARecord("web2.backend.example.com", "192.168.1.11")
mockDNS.AddARecord("web3.backend.example.com", "192.168.1.12")
t.Run("SRV record resolution", func(t *testing.T) {
mockResolver := &MockResolver{mockServer: mockDNS}
// Test SRV lookup
_, srvRecords, err := mockResolver.LookupSRV(context.Background(), "", "", "_http._tcp.backend.example.com")
if err != nil {
t.Fatalf("SRV lookup failed: %v", err)
}
if len(srvRecords) != 3 {
t.Errorf("Expected 3 SRV records, got %d", len(srvRecords))
}
// Verify priority ordering (lowest priority first) and weight ordering (highest weight first within same priority)
expectedPriorities := []uint16{10, 10, 20}
expectedWeights := []uint16{60, 40, 100} // For priorities [10, 10, 20], weights should be [60, 40, 100]
expectedTargets := []string{"web1.backend.example.com", "web2.backend.example.com", "web3.backend.example.com"}
for i, srv := range srvRecords {
if srv.Priority != expectedPriorities[i] {
t.Errorf("Expected priority %d at index %d, got %d", expectedPriorities[i], i, srv.Priority)
}
if srv.Weight != expectedWeights[i] {
t.Errorf("Expected weight %d at index %d, got %d", expectedWeights[i], i, srv.Weight)
}
if srv.Target != expectedTargets[i] {
t.Errorf("Expected target %s at index %d, got %s", expectedTargets[i], i, srv.Target)
}
}
// Test A record resolution for each target
for _, srv := range srvRecords {
ips, err := mockResolver.LookupIPAddr(context.Background(), srv.Target)
if err != nil {
t.Errorf("A record lookup failed for %s: %v", srv.Target, err)
continue
}
if len(ips) != 1 {
t.Errorf("Expected 1 IP for %s, got %d", srv.Target, len(ips))
}
}
})
}
// TestSRVPriorityHandling tests nginx SRV priority handling as per nginx.org documentation
func TestSRVPriorityHandling(t *testing.T) {
// Create mock DNS server
mockDNS := NewMockDNSServer()
// Add SRV records with different priorities to test primary/backup server logic
// Priority 5 (highest priority / primary servers)
mockDNS.AddSRVRecord("_http._tcp.app.example.com", 5, 100, 8080, "primary1.app.example.com")
mockDNS.AddSRVRecord("_http._tcp.app.example.com", 5, 50, 8080, "primary2.app.example.com")
// Priority 10 (backup servers)
mockDNS.AddSRVRecord("_http._tcp.app.example.com", 10, 80, 8080, "backup1.app.example.com")
// Priority 15 (lower priority backup servers)
mockDNS.AddSRVRecord("_http._tcp.app.example.com", 15, 200, 8080, "backup2.app.example.com")
// Add A records
mockDNS.AddARecord("primary1.app.example.com", "10.0.1.1")
mockDNS.AddARecord("primary2.app.example.com", "10.0.1.2")
mockDNS.AddARecord("backup1.app.example.com", "10.0.2.1")
mockDNS.AddARecord("backup2.app.example.com", "10.0.3.1")
t.Run("SRV priority handling", func(t *testing.T) {
mockResolver := &MockResolver{mockServer: mockDNS}
// Test SRV lookup
_, srvRecords, err := mockResolver.LookupSRV(context.Background(), "", "", "_http._tcp.app.example.com")
if err != nil {
t.Fatalf("SRV lookup failed: %v", err)
}
if len(srvRecords) != 4 {
t.Errorf("Expected 4 SRV records, got %d", len(srvRecords))
}
// According to nginx.org: "Highest-priority SRV records (records with the same lowest-number priority value)
// are resolved as primary servers, the rest of SRV records are resolved as backup servers"
expectedOrder := []struct {
priority uint16
weight uint16
target string
serverType string
}{
{5, 100, "primary1.app.example.com", "primary"}, // Highest priority (lowest number)
{5, 50, "primary2.app.example.com", "primary"}, // Same priority, lower weight
{10, 80, "backup1.app.example.com", "backup"}, // Lower priority (backup)
{15, 200, "backup2.app.example.com", "backup"}, // Lowest priority (backup)
}
for i, srv := range srvRecords {
expected := expectedOrder[i]
if srv.Priority != expected.priority {
t.Errorf("Record %d: expected priority %d, got %d", i, expected.priority, srv.Priority)
}
if srv.Weight != expected.weight {
t.Errorf("Record %d: expected weight %d, got %d", i, expected.weight, srv.Weight)
}
if srv.Target != expected.target {
t.Errorf("Record %d: expected target %s, got %s", i, expected.target, srv.Target)
}
// Log the server type for documentation
t.Logf("Record %d: Priority %d, Weight %d, Target %s (%s server)",
i, srv.Priority, srv.Weight, srv.Target, expected.serverType)
}
// Verify primary servers come first (lowest priority numbers)
primaryCount := 0
for _, srv := range srvRecords {
if srv.Priority == 5 { // Primary servers have priority 5
primaryCount++
} else {
break // Once we hit a non-primary, all following should be backups
}
}
if primaryCount != 2 {
t.Errorf("Expected 2 primary servers at the beginning, got %d", primaryCount)
}
})
}
// TestARecordFallback tests A record fallback when SRV lookup fails
func TestARecordFallback(t *testing.T) {
mockDNS := NewMockDNSServer()
// Only add A record, no SRV record
mockDNS.AddARecord("_http._tcp.backend.example.com", "192.168.1.100")
t.Run("A record fallback", func(t *testing.T) {
mockResolver := &MockResolver{mockServer: mockDNS}
// SRV lookup should fail
_, srvRecords, err := mockResolver.LookupSRV(context.Background(), "", "", "_http._tcp.backend.example.com")
if err == nil {
t.Error("Expected SRV lookup to fail")
}
if len(srvRecords) != 0 {
t.Errorf("Expected 0 SRV records, got %d", len(srvRecords))
}
// A record lookup should succeed
ips, err := mockResolver.LookupIPAddr(context.Background(), "_http._tcp.backend.example.com")
if err != nil {
t.Fatalf("A record lookup failed: %v", err)
}
if len(ips) != 1 {
t.Errorf("Expected 1 IP, got %d", len(ips))
}
expectedIP := "192.168.1.100"
if ips[0].IP.String() != expectedIP {
t.Errorf("Expected IP %s, got %s", expectedIP, ips[0].IP.String())
}
})
}
// TestComplexNginxScenarios tests more complex real-world nginx scenarios
func TestComplexNginxScenarios(t *testing.T) {
tests := []struct {
name string
nginxLine string
expectedSRV string
scenario string
}{
{
name: "Load balancer with HTTP service",
nginxLine: "api.microservices.local service=http resolve",
expectedSRV: "_http._tcp.api.microservices.local",
scenario: "Microservices API load balancing",
},
{
name: "Database connection",
nginxLine: "db.cluster.local service=mysql resolve",
expectedSRV: "_mysql._tcp.db.cluster.local",
scenario: "Database cluster connection",
},
{
name: "WebSocket service",
nginxLine: "chat.app.local service=ws resolve",
expectedSRV: "_ws._tcp.chat.app.local",
scenario: "WebSocket service discovery",
},
{
name: "Custom protocol with dots",
nginxLine: "service.consul service=_grpc._tcp resolve",
expectedSRV: "_grpc._tcp.service.consul",
scenario: "gRPC service via Consul",
},
{
name: "Multi-level service hierarchy",
nginxLine: "consul.local service=api.v1.production resolve",
expectedSRV: "api.v1.production.consul.local",
scenario: "Multi-level service naming",
},
{
name: "Kubernetes style service",
nginxLine: "cluster.local service=_http._tcp.nginx.default resolve",
expectedSRV: "_http._tcp.nginx.default.cluster.local",
scenario: "Kubernetes service discovery",
},
}
resolver := NewDynamicResolver("127.0.0.1:8600")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
serviceInfo, err := resolver.parseServiceURL(tt.nginxLine)
if err != nil {
t.Fatalf("Failed to parse nginx line: %v", err)
}
result := resolver.constructSRVDomain(serviceInfo)
if result != tt.expectedSRV {
t.Errorf("Scenario '%s' failed: expected %s, got %s",
tt.scenario, tt.expectedSRV, result)
}
})
}
}
// TestBackwardCompatibility tests backward compatibility with old format
func TestBackwardCompatibility(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "New nginx format should work",
input: "backend.example.com service=http resolve",
expected: "http",
},
{
name: "New nginx format with dots",
input: "example.com service=_http._tcp resolve",
expected: "_http._tcp",
},
{
name: "Old consul format should still work as fallback",
input: "test-service.service.consul",
expected: "test-service",
},
{
name: "Invalid format should return empty",
input: "invalid format without proper structure",
expected: "",
},
}
resolver := NewDynamicResolver("127.0.0.1:8600")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := resolver.extractServiceName(tt.input)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
// TestDynamicTargetsFunction tests the TestDynamicTargets function
func TestDynamicTargetsFunction(t *testing.T) {
t.Run("Valid dynamic targets", func(t *testing.T) {
targets := []ProxyTarget{
{
Host: "service.consul",
Port: "dynamic",
Type: "upstream",
Resolver: "127.0.0.1:8600",
IsConsul: true,
ServiceURL: "backend.example.com service=http resolve",
},
}
results := TestDynamicTargets(targets)
if len(results) != 1 {
t.Errorf("Expected 1 result, got %d", len(results))
}
key := "service.consul:dynamic"
if _, found := results[key]; !found {
t.Errorf("Expected result for key %s not found", key)
}
})
t.Run("Target without resolver should be offline", func(t *testing.T) {
targets := []ProxyTarget{
{
Host: "service.consul",
Port: "dynamic",
Type: "upstream",
IsConsul: true,
ServiceURL: "backend.example.com service=http resolve",
// No resolver specified
},
}
results := TestDynamicTargets(targets)
key := "service.consul:dynamic"
if status, found := results[key]; found {
if status.Online {
t.Error("Expected target without resolver to be offline")
}
if status.Latency != 0 {
t.Errorf("Expected latency 0 for offline target, got %.2f", status.Latency)
}
} else {
t.Errorf("Expected result for key %s", key)
}
})
}
// TestIntegrationWithProxyParser tests integration with the proxy parser
func TestIntegrationWithProxyParser(t *testing.T) {
config := `upstream web-backend {
zone upstream_web 128k;
resolver 127.0.0.1:8600 valid=5s;
resolver_timeout 2s;
server backend.example.com service=http resolve;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://web-backend;
}
}`
targets := ParseProxyTargetsFromRawContent(config)
// Should find the dynamic DNS target
found := false
for _, target := range targets {
if target.IsConsul && strings.Contains(target.ServiceURL, "service=http") {
found = true
// Verify the target is correctly parsed
if target.Resolver != "127.0.0.1:8600" {
t.Errorf("Expected resolver 127.0.0.1:8600, got %s", target.Resolver)
}
if target.ServiceURL != "backend.example.com service=http resolve" {
t.Errorf("Expected service URL 'backend.example.com service=http resolve', got %s", target.ServiceURL)
}
break
}
}
if !found {
t.Error("Dynamic DNS target not found in parsed config")
}
}