Repository: borchero/switchboard (go)

Issue:
Support dual-stack/multiple target IPs
[Only the first IP of a target is used for ExternalDNS records](https://github.com/borchero/switchboard/blob/f9ee58652cb4a22dc44ca3d0f370f92deda57770/internal/switchboard/targets.go#L33-L37). To support dual-stack services, records for all IPs of the target should be created. 

(Depends on #5)

Pull request description:
Process multiple target ip addresses
Resolves #6

Patch:
diff --git a/internal/integrations/externaldns.go b/internal/integrations/externaldns.go
index 8b5b6f5..7d36825 100644
--- a/internal/integrations/externaldns.go
+++ b/internal/integrations/externaldns.go
@@ -56,8 +56,8 @@ func (e *externalDNS) UpdateResource(
 		return nil
 	}
 
-	// Get the IP of the target service
-	ip, err := e.target.IP(ctx, e.client)
+	// Get the IPs of the target service
+	ips, err := e.target.IPs(ctx, e.client)
 	if err != nil {
 		return fmt.Errorf("failed to query IP for DNS A record: %w", err)
 	}
@@ -70,7 +70,7 @@ func (e *externalDNS) UpdateResource(
 			return nil
 		}
 		// Spec
-		resource.Spec.Endpoints = e.endpoints(info.Hosts, ip)
+		resource.Spec.Endpoints = e.endpoints(info.Hosts, ips)
 		return nil
 	}); err != nil {
 		return fmt.Errorf("failed to upsert DNS endpoint: %w", err)
@@ -89,15 +89,29 @@ func (e *externalDNS) objectMeta(owner metav1.Object) metav1.ObjectMeta {
 	}
 }
 
-func (e *externalDNS) endpoints(hosts []string, target string) []*endpoint.Endpoint {
+func (e *externalDNS) endpoints(hosts []string, targets []string) []*endpoint.Endpoint {
+	// Get the records for the target service
+	targetRecords := make(map[string][]string)
+	for _, target := range targets {
+		rtype := e.recordType(target)
+		if _, ok := targetRecords[rtype]; ok {
+			targetRecords[rtype] = append(targetRecords[rtype], target)
+		} else {
+			targetRecords[rtype] = []string{target}
+		}
+	}
+
+	// Create the endpoints
 	endpoints := make([]*endpoint.Endpoint, 0, len(hosts))
 	for _, host := range hosts {
-		endpoints = append(endpoints, &endpoint.Endpoint{
-			DNSName:    host,
-			Targets:    []string{target},
-			RecordType: e.recordType(target),
-			RecordTTL:  e.ttl,
-		})
+		for rtype, values := range targetRecords {
+			endpoints = append(endpoints, &endpoint.Endpoint{
+				DNSName:    host,
+				Targets:    values,
+				RecordType: rtype,
+				RecordTTL:  e.ttl,
+			})
+		}
 	}
 	return endpoints
 }
diff --git a/internal/switchboard/targets.go b/internal/switchboard/targets.go
index 1b24355..52016ef 100644
--- a/internal/switchboard/targets.go
+++ b/internal/switchboard/targets.go
@@ -21,21 +21,26 @@ func NewTarget(name, namespace string) Target {
 	}
 }
 
-// IP returns the IP that should be used as target or an error if querying the IP fails.
-func (t Target) IP(ctx context.Context, client client.Client) (string, error) {
+// IPs returns the IP v4/v6 addresses that should be used as targets or an error if querying the
+// IP addresses fails.
+func (t Target) IPs(ctx context.Context, client client.Client) ([]string, error) {
 	// Get service
 	var service v1.Service
 	if err := client.Get(ctx, t.name, &service); err != nil {
-		return "", fmt.Errorf("failed to query service: %w", err)
+		return nil, fmt.Errorf("failed to query service: %w", err)
 	}
 
-	// Get IP: try to get load balancer IP, fall back to cluster IP
-	targetIP := service.Spec.ClusterIP
-	lbIngress := service.Status.LoadBalancer.Ingress
-	if len(lbIngress) > 0 {
-		targetIP = lbIngress[0].IP
+	// Get IPs: try to get load balancer IPs, fall back to cluster IPs
+	targets := make([]string, 0)
+	for _, ingress := range service.Status.LoadBalancer.Ingress {
+		if ingress.IP != "" {
+			targets = append(targets, ingress.IP)
+		}
 	}
-	return targetIP, nil
+	if len(targets) == 0 {
+		targets = append(targets, service.Spec.ClusterIPs...)
+	}
+	return targets, nil
 }
 
 // NamespacedName returns the namespaced name of the target service.


Test patch:
diff --git a/internal/integrations/externaldns_test.go b/internal/integrations/externaldns_test.go
index 1e3847d..f1b2c49 100644
--- a/internal/integrations/externaldns_test.go
+++ b/internal/integrations/externaldns_test.go
@@ -68,7 +68,7 @@ func TestExternalDNSEndpoints(t *testing.T) {
 	integration := externalDNS{ttl: 250}
 	hosts := []string{"example.com", "www.example.com"}
 
-	endpoints := integration.endpoints(hosts, "127.0.0.1")
+	endpoints := integration.endpoints(hosts, []string{"127.0.0.1"})
 	assert.Len(t, endpoints, 2)
 	for _, ep := range endpoints {
 		assert.ElementsMatch(t, ep.Targets, []string{"127.0.0.1"})
@@ -77,21 +77,36 @@ func TestExternalDNSEndpoints(t *testing.T) {
 		assert.Contains(t, hosts, ep.DNSName)
 	}
 
-	endpoints = integration.endpoints(hosts, "::FFFF:C0A8:1")
+	endpoints = integration.endpoints(hosts, []string{"2001:db8::1"})
 	assert.Len(t, endpoints, 2)
 	for _, ep := range endpoints {
-		assert.ElementsMatch(t, ep.Targets, []string{"::FFFF:C0A8:1"})
+		assert.ElementsMatch(t, ep.Targets, []string{"2001:db8::1"})
 		assert.Equal(t, ep.RecordTTL, endpoint.TTL(250))
 		assert.Equal(t, ep.RecordType, "AAAA")
 		assert.Contains(t, hosts, ep.DNSName)
 	}
+
+	endpoints = integration.endpoints(hosts, []string{"127.0.0.1", "2001:db8::1"})
+	assert.Len(t, endpoints, 4)
+	for _, ep := range endpoints {
+		if ep.RecordType == "A" {
+			assert.ElementsMatch(t, ep.Targets, []string{"127.0.0.1"})
+			assert.Equal(t, ep.RecordTTL, endpoint.TTL(250))
+			assert.Contains(t, hosts, ep.DNSName)
+		} else {
+			assert.ElementsMatch(t, ep.Targets, []string{"2001:db8::1"})
+			assert.Equal(t, ep.RecordTTL, endpoint.TTL(250))
+			assert.Equal(t, ep.RecordType, "AAAA")
+			assert.Contains(t, hosts, ep.DNSName)
+		}
+	}
 }
 
 func TestExternalDNSRecordType(t *testing.T) {
 	integration := externalDNS{ttl: 250}
 	assert.Equal(t, "A", integration.recordType("127.0.0.1"))
 	assert.Equal(t, "AAAA", integration.recordType("::FFFF:C0A8:1"))
-	assert.Equal(t, "AAAA", integration.recordType("0000:0000:0000:0000:0000:FFFF:C0A8:1"))
+	assert.Equal(t, "AAAA", integration.recordType("2001:db8::1"))
 }
 
 //-------------------------------------------------------------------------------------------------
diff --git a/internal/switchboard/targets_test.go b/internal/switchboard/targets_test.go
index ecdda3d..ec6615b 100644
--- a/internal/switchboard/targets_test.go
+++ b/internal/switchboard/targets_test.go
@@ -26,9 +26,9 @@ func TestIP(t *testing.T) {
 
 	// Check whether we find the cluster IP
 	target := NewTarget(service.Name, service.Namespace)
-	ip, err := target.IP(ctx, ctrlClient)
+	ips, err := target.IPs(ctx, ctrlClient)
 	require.Nil(t, err)
-	assert.Equal(t, service.Spec.ClusterIP, ip)
+	assert.ElementsMatch(t, service.Spec.ClusterIPs, ips)
 
 	// Update the service to provide a load balancer
 	service.Spec.Type = "LoadBalancer"
@@ -40,9 +40,9 @@ func TestIP(t *testing.T) {
 	name := client.ObjectKeyFromObject(&service)
 	err = ctrlClient.Get(ctx, name, &service)
 	require.Nil(t, err)
-	ip, err = target.IP(ctx, ctrlClient)
+	ips, err = target.IPs(ctx, ctrlClient)
 	require.Nil(t, err)
-	assert.Equal(t, service.Status.LoadBalancer.Ingress[0].IP, ip)
+	assert.ElementsMatch(t, []string{service.Status.LoadBalancer.Ingress[0].IP}, ips)
 }
 
 func TestNamespacedName(t *testing.T) {