Repository: borchero/switchboard (go)

Issue:
Allow CNAME records for external-dns integration
Hi again,

Many cloud providers provide their `LoadBalancer` resources with hostname only, and no IP addresses. The hostname is usually meant to be added as a CNAME record to DNS.

Example kubernetes service status:
```
status:
  loadBalancer:
    ingress:
    - hostname: example.lb.identifier.amazonaws.com
```

It would be useful if Switchboard would also be able to pick up a `hostname` variable in the absence of an IP address, from the defined traefik service object, and create the `DNSEndpoint` resource with `recordType: CNAME` in this case. I think external-dns does something like that from `ingress` sources.

Pull request description:
Correctly source hostnames from service status
Resolves #15

Patch:
diff --git a/internal/integrations/externaldns.go b/internal/integrations/externaldns.go
index ccebf37..dd434e6 100644
--- a/internal/integrations/externaldns.go
+++ b/internal/integrations/externaldns.go
@@ -61,7 +61,7 @@ func (e *externalDNS) UpdateResource(
 	}
 
 	// Get the IPs of the target service
-	ips, err := e.target.IPs(ctx, e.client)
+	targets, err := e.target.Targets(ctx, e.client)
 	if err != nil {
 		return fmt.Errorf("failed to query IP for DNS A record: %w", err)
 	}
@@ -74,7 +74,7 @@ func (e *externalDNS) UpdateResource(
 			return nil
 		}
 		// Spec
-		resource.Spec.Endpoints = e.endpoints(info.Hosts, ips)
+		resource.Spec.Endpoints = e.endpoints(info.Hosts, targets)
 		return nil
 	}); err != nil {
 		return fmt.Errorf("failed to upsert DNS endpoint: %w", err)
@@ -98,11 +98,7 @@ func (e *externalDNS) endpoints(hosts []string, targets []string) []*endpoint.En
 	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}
-		}
+		targetRecords[rtype] = append(targetRecords[rtype], target)
 	}
 
 	// Create the endpoints
@@ -124,5 +120,8 @@ func (e *externalDNS) recordType(target string) string {
 	if govalidator.IsIPv4(target) {
 		return "A"
 	}
-	return "AAAA"
+	if govalidator.IsIPv6(target) {
+		return "AAAA"
+	}
+	return "CNAME"
 }
diff --git a/internal/switchboard/targets.go b/internal/switchboard/targets.go
index 628b0ae..2e756d6 100644
--- a/internal/switchboard/targets.go
+++ b/internal/switchboard/targets.go
@@ -11,9 +11,9 @@ import (
 
 // Target is a type which allows to retrieve a potentially dynamically changing IP from Kubernetes.
 type Target interface {
-	// IPs returns the IPv4/IPv6 addresses that should be used as targets or an error if the IP
-	// addresses cannot be retrieved.
-	IPs(ctx context.Context, client client.Client) ([]string, error)
+	// Targets returns the IPv4/IPv6 addresses or hostnames that should be used as targets or an
+	// error if the addresses/hostnames cannot be retrieved.
+	Targets(ctx context.Context, client client.Client) ([]string, error)
 	// NamespacedName returns the namespaced name of the dynamic target service or none if the IP
 	// is not retrieved dynamically.
 	NamespacedName() *types.NamespacedName
@@ -35,19 +35,22 @@ func NewServiceTarget(name, namespace string) Target {
 	}
 }
 
-func (t serviceTarget) IPs(ctx context.Context, client client.Client) ([]string, error) {
+func (t serviceTarget) Targets(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 nil, fmt.Errorf("failed to query service: %w", err)
 	}
 
-	// Get IPs: try to get load balancer IPs, fall back to cluster IPs
+	// Get IPs: try to get load balancer IPs/hostnames, fall back to cluster IPs
 	targets := make([]string, 0)
 	for _, ingress := range service.Status.LoadBalancer.Ingress {
 		if ingress.IP != "" {
 			targets = append(targets, ingress.IP)
 		}
+		if ingress.Hostname != "" {
+			targets = append(targets, ingress.Hostname)
+		}
 	}
 	if len(targets) == 0 {
 		targets = append(targets, service.Spec.ClusterIPs...)
@@ -73,7 +76,7 @@ func NewStaticTarget(ips ...string) Target {
 	return staticTarget{ips}
 }
 
-func (t staticTarget) IPs(ctx context.Context, client client.Client) ([]string, error) {
+func (t staticTarget) Targets(ctx context.Context, client client.Client) ([]string, error) {
 	return t.ips, nil
 }
 


Test patch:
diff --git a/internal/integrations/externaldns_test.go b/internal/integrations/externaldns_test.go
index bbb7191..59f90d1 100644
--- a/internal/integrations/externaldns_test.go
+++ b/internal/integrations/externaldns_test.go
@@ -100,6 +100,15 @@ func TestExternalDNSEndpoints(t *testing.T) {
 			assert.Contains(t, hosts, ep.DNSName)
 		}
 	}
+
+	endpoints = integration.endpoints(hosts, []string{"example.lb.identifier.amazonaws.com"})
+	assert.Len(t, endpoints, 2)
+	for _, ep := range endpoints {
+		assert.ElementsMatch(t, ep.Targets, []string{"example.lb.identifier.amazonaws.com"})
+		assert.Equal(t, ep.RecordTTL, endpoint.TTL(250))
+		assert.Equal(t, ep.RecordType, "CNAME")
+		assert.Contains(t, hosts, ep.DNSName)
+	}
 }
 
 func TestExternalDNSRecordType(t *testing.T) {
@@ -107,6 +116,7 @@ func TestExternalDNSRecordType(t *testing.T) {
 	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("2001:db8::1"))
+	assert.Equal(t, "CNAME", integration.recordType("example.lb.identifier.amazonaws.com"))
 }
 
 //-------------------------------------------------------------------------------------------------
diff --git a/internal/switchboard/targets_test.go b/internal/switchboard/targets_test.go
index 8df348e..e6fe0ab 100644
--- a/internal/switchboard/targets_test.go
+++ b/internal/switchboard/targets_test.go
@@ -26,9 +26,9 @@ func TestServiceTargetIP(t *testing.T) {
 
 	// Check whether we find the cluster IP
 	target := NewServiceTarget(service.Name, service.Namespace)
-	ips, err := target.IPs(ctx, ctrlClient)
+	targets, err := target.Targets(ctx, ctrlClient)
 	require.Nil(t, err)
-	assert.ElementsMatch(t, service.Spec.ClusterIPs, ips)
+	assert.ElementsMatch(t, service.Spec.ClusterIPs, targets)
 
 	// Update the service to provide a load balancer
 	service.Spec.Type = "LoadBalancer"
@@ -40,9 +40,9 @@ func TestServiceTargetIP(t *testing.T) {
 	name := client.ObjectKeyFromObject(&service)
 	err = ctrlClient.Get(ctx, name, &service)
 	require.Nil(t, err)
-	ips, err = target.IPs(ctx, ctrlClient)
+	targets, err = target.Targets(ctx, ctrlClient)
 	require.Nil(t, err)
-	assert.ElementsMatch(t, []string{service.Status.LoadBalancer.Ingress[0].IP}, ips)
+	assert.ElementsMatch(t, []string{service.Status.LoadBalancer.Ingress[0].IP}, targets)
 }
 
 func TestServiceTargetNamespacedName(t *testing.T) {
@@ -56,7 +56,7 @@ func TestStaticTargetIPs(t *testing.T) {
 	ctx := context.Background()
 	expectedIPs := []string{"127.0.0.1", "2001:db8::1"}
 	target := NewStaticTarget(expectedIPs...)
-	ips, err := target.IPs(ctx, nil)
+	ips, err := target.Targets(ctx, nil)
 	require.Nil(t, err)
 	assert.ElementsMatch(t, expectedIPs, ips)
 }