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:
Allow ingress hostname to take precedence over IPs
Resolves #15 

Patch:
diff --git a/internal/switchboard/targets.go b/internal/switchboard/targets.go
index 2e756d6..72803c5 100644
--- a/internal/switchboard/targets.go
+++ b/internal/switchboard/targets.go
@@ -41,21 +41,28 @@ func (t serviceTarget) Targets(ctx context.Context, client client.Client) ([]str
 	if err := client.Get(ctx, t.name, &service); err != nil {
 		return nil, fmt.Errorf("failed to query service: %w", err)
 	}
+	return t.targetsFromService(service), nil
+}
 
-	// Get IPs: try to get load balancer IPs/hostnames, fall back to cluster IPs
+func (t serviceTarget) targetsFromService(service v1.Service) []string {
+	// Try to get load balancer IPs/hostnames...
 	targets := make([]string, 0)
 	for _, ingress := range service.Status.LoadBalancer.Ingress {
+		if ingress.Hostname != "" {
+			// We cannot have more than one CNAME record, the hostname overwrites everything
+			targets = []string{ingress.Hostname}
+			break
+		}
 		if ingress.IP != "" {
 			targets = append(targets, ingress.IP)
 		}
-		if ingress.Hostname != "" {
-			targets = append(targets, ingress.Hostname)
-		}
 	}
+
+	// ...fall back to cluster IPs
 	if len(targets) == 0 {
 		targets = append(targets, service.Spec.ClusterIPs...)
 	}
-	return targets, nil
+	return targets
 }
 
 func (t serviceTarget) NamespacedName() *types.NamespacedName {


Test patch:
diff --git a/internal/switchboard/targets_test.go b/internal/switchboard/targets_test.go
index e6fe0ab..630141c 100644
--- a/internal/switchboard/targets_test.go
+++ b/internal/switchboard/targets_test.go
@@ -8,10 +8,11 @@ import (
 	"github.com/borchero/switchboard/internal/k8tests"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	v1 "k8s.io/api/core/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
-func TestServiceTargetIP(t *testing.T) {
+func TestServiceTargetTargets(t *testing.T) {
 	// Setup
 	ctx := context.Background()
 	scheme := k8tests.NewScheme()
@@ -45,6 +46,53 @@ func TestServiceTargetIP(t *testing.T) {
 	assert.ElementsMatch(t, []string{service.Status.LoadBalancer.Ingress[0].IP}, targets)
 }
 
+func TestServiceTargetTargetsFromService(t *testing.T) {
+	target := serviceTarget{}
+	service := v1.Service{
+		Spec: v1.ServiceSpec{ClusterIPs: []string{"10.0.0.5"}},
+	}
+
+	// Source cluster IP
+	targets := target.targetsFromService(service)
+	assert.ElementsMatch(t, service.Spec.ClusterIPs, targets)
+
+	// Source multiple cluster IPs
+	service.Spec.ClusterIPs = []string{"10.0.0.5", "2001:db8::1"}
+	targets = target.targetsFromService(service)
+	assert.ElementsMatch(t, service.Spec.ClusterIPs, targets)
+
+	// Source IP from status
+	service.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{
+		IP: "192.168.5.5",
+	}}
+	targets = target.targetsFromService(service)
+	assert.ElementsMatch(t, []string{"192.168.5.5"}, targets)
+
+	// Source hostname from status
+	service.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{
+		Hostname: "example.lb.identifier.amazonaws.com",
+	}}
+	targets = target.targetsFromService(service)
+	assert.ElementsMatch(t, []string{"example.lb.identifier.amazonaws.com"}, targets)
+
+	// Ensure hostname takes precedence
+	service.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{
+		IP:       "192.168.5.5",
+		Hostname: "example.lb.identifier.amazonaws.com",
+	}}
+	targets = target.targetsFromService(service)
+	assert.ElementsMatch(t, []string{"example.lb.identifier.amazonaws.com"}, targets)
+
+	// Ensure only one hostname
+	service.Status.LoadBalancer.Ingress = []v1.LoadBalancerIngress{{
+		Hostname: "example.lb.identifier.amazonaws.com",
+	}, {
+		Hostname: "example2.lb.identifier.amazonaws.com",
+	}}
+	targets = target.targetsFromService(service)
+	assert.ElementsMatch(t, []string{"example.lb.identifier.amazonaws.com"}, targets)
+}
+
 func TestServiceTargetNamespacedName(t *testing.T) {
 	target := NewServiceTarget("my-service", "my-namespace")
 	name := target.NamespacedName()