Repository: borchero/switchboard (go)

Issue:
Support for manually setting the target IP
We deploy our Traefik as a DaemonSet with direct `hostPort` binding. This means that there is no service that switchboard could retrieve the external IP from.

Since our external IP never changes, it may be a simple fix to allow the configuration of a fixed IP that switchboard uses for all targets. 

Pull request description:
Allow static IP targets for external-dns integration
Resolves #10

Patch:
diff --git a/cmd/main.go b/cmd/main.go
index e3ad403..ed0ff18 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -51,7 +51,10 @@ func main() {
 	}
 
 	// Create the controllers
-	controller := controllers.NewIngressRouteReconciler(manager.GetClient(), logger, config)
+	controller, err := controllers.NewIngressRouteReconciler(manager.GetClient(), logger, config)
+	if err != nil {
+		logger.Fatal("unable to initialize ingress route controller", zap.Error(err))
+	}
 	if err := controller.SetupWithManager(manager); err != nil {
 		logger.Fatal("unable to start ingress route controller", zap.Error(err))
 	}
diff --git a/internal/config/v1/config_types.go b/internal/config/v1/config_types.go
index a5a7deb..2516c6d 100644
--- a/internal/config/v1/config_types.go
+++ b/internal/config/v1/config_types.go
@@ -32,8 +32,10 @@ type IntegrationConfigs struct {
 }
 
 // ExternalDNSIntegrationConfig describes the configuration for the external-dns integration.
+// Exactly one of target and target IPs should be set.
 type ExternalDNSIntegrationConfig struct {
-	Target ServiceRef `json:"target"`
+	TargetService *ServiceRef `json:"targetService,omitempty"`
+	TargetIPs     []string    `json:"targetIPs,omitempty"`
 }
 
 // CertManagerIntegrationConfig describes the configuration for the cert-manager integration.
diff --git a/internal/config/v1/zz_generated.deepcopy.go b/internal/config/v1/zz_generated.deepcopy.go
index 549da60..2fea486 100644
--- a/internal/config/v1/zz_generated.deepcopy.go
+++ b/internal/config/v1/zz_generated.deepcopy.go
@@ -55,7 +55,12 @@ func (in *Config) DeepCopyObject() runtime.Object {
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ExternalDNSIntegrationConfig) DeepCopyInto(out *ExternalDNSIntegrationConfig) {
 	*out = *in
-	out.Target = in.Target
+	out.TargetService = in.TargetService
+	if in.TargetIPs != nil {
+		in, out := &in.TargetIPs, &out.TargetIPs
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalDNSIntegrationConfig.
@@ -94,7 +99,7 @@ func (in *IntegrationConfigs) DeepCopyInto(out *IntegrationConfigs) {
 	if in.ExternalDNS != nil {
 		in, out := &in.ExternalDNS, &out.ExternalDNS
 		*out = new(ExternalDNSIntegrationConfig)
-		**out = **in
+		(*in).DeepCopyInto(*out)
 	}
 	if in.CertManager != nil {
 		in, out := &in.CertManager, &out.CertManager
diff --git a/internal/controllers/ingressroute.go b/internal/controllers/ingressroute.go
index 1335a07..73f52ac 100644
--- a/internal/controllers/ingressroute.go
+++ b/internal/controllers/ingressroute.go
@@ -2,6 +2,7 @@ package controllers
 
 import (
 	"context"
+	"fmt"
 
 	configv1 "github.com/borchero/switchboard/internal/config/v1"
 	"github.com/borchero/switchboard/internal/ext"
@@ -25,13 +26,17 @@ type IngressRouteReconciler struct {
 // NewIngressRouteReconciler creates a new IngressRouteReconciler.
 func NewIngressRouteReconciler(
 	client client.Client, logger *zap.Logger, config configv1.Config,
-) IngressRouteReconciler {
+) (IngressRouteReconciler, error) {
+	integrations, err := integrationsFromConfig(config, client)
+	if err != nil {
+		return IngressRouteReconciler{}, fmt.Errorf("failed to initialize integrations: %s", err)
+	}
 	return IngressRouteReconciler{
 		Client:       client,
 		logger:       logger,
 		selector:     switchboard.NewSelector(config.Selector.IngressClass),
-		integrations: integrationsFromConfig(config, client),
-	}
+		integrations: integrations,
+	}, nil
 }
 
 // Reconcile is part of the main kubernetes reconciliation loop which aims to
diff --git a/internal/controllers/utils.go b/internal/controllers/utils.go
index a3fd0cf..0f1fe59 100644
--- a/internal/controllers/utils.go
+++ b/internal/controllers/utils.go
@@ -1,6 +1,8 @@
 package controllers
 
 import (
+	"fmt"
+
 	configv1 "github.com/borchero/switchboard/internal/config/v1"
 	"github.com/borchero/switchboard/internal/ext"
 	"github.com/borchero/switchboard/internal/integrations"
@@ -17,25 +19,39 @@ import (
 
 func integrationsFromConfig(
 	config configv1.Config, client client.Client,
-) []integrations.Integration {
+) ([]integrations.Integration, error) {
 	result := make([]integrations.Integration, 0)
+	externalDNS := config.Integrations.ExternalDNS
 	if config.Integrations.ExternalDNS != nil {
-		result = append(result, integrations.NewExternalDNS(
-			client, switchboard.NewTarget(
-				config.Integrations.ExternalDNS.Target.Name,
-				config.Integrations.ExternalDNS.Target.Namespace,
-			),
-		))
+		if (externalDNS.TargetService == nil) == (len(externalDNS.TargetIPs) == 0) {
+			return nil, fmt.Errorf(
+				"exactly one of `targetService` and `targetIPs` must be set for external-dns",
+			)
+		}
+		if externalDNS.TargetService != nil {
+			result = append(result, integrations.NewExternalDNS(
+				client, switchboard.NewServiceTarget(
+					externalDNS.TargetService.Name,
+					externalDNS.TargetService.Namespace,
+				),
+			))
+		} else {
+			result = append(result, integrations.NewExternalDNS(
+				client, switchboard.NewStaticTarget(externalDNS.TargetIPs...),
+			))
+		}
 	}
-	if config.Integrations.CertManager != nil {
+
+	certManager := config.Integrations.CertManager
+	if certManager != nil {
 		result = append(result, integrations.NewCertManager(
 			client, cmmeta.ObjectReference{
-				Kind: config.Integrations.CertManager.Issuer.Kind,
-				Name: config.Integrations.CertManager.Issuer.Name,
+				Kind: certManager.Issuer.Kind,
+				Name: certManager.Issuer.Name,
 			},
 		))
 	}
-	return result
+	return result, nil
 }
 
 func builderWithIntegrations(
diff --git a/internal/integrations/externaldns.go b/internal/integrations/externaldns.go
index 7d36825..ccebf37 100644
--- a/internal/integrations/externaldns.go
+++ b/internal/integrations/externaldns.go
@@ -35,10 +35,14 @@ func (*externalDNS) OwnedResource() client.Object {
 }
 
 func (e *externalDNS) WatchedObject() client.Object {
+	name := e.target.NamespacedName()
+	if name == nil {
+		return nil
+	}
 	return &v1.Service{
 		ObjectMeta: metav1.ObjectMeta{
-			Name:      e.target.NamespacedName().Name,
-			Namespace: e.target.NamespacedName().Namespace,
+			Name:      name.Name,
+			Namespace: name.Namespace,
 		},
 	}
 }
diff --git a/internal/switchboard/targets.go b/internal/switchboard/targets.go
index 52016ef..628b0ae 100644
--- a/internal/switchboard/targets.go
+++ b/internal/switchboard/targets.go
@@ -9,21 +9,33 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
-// Target represents a service whose external/internal IP should be used as target for DNS records.
-type Target struct {
+// 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)
+	// NamespacedName returns the namespaced name of the dynamic target service or none if the IP
+	// is not retrieved dynamically.
+	NamespacedName() *types.NamespacedName
+}
+
+//-------------------------------------------------------------------------------------------------
+// SERVICE TARGET
+//-------------------------------------------------------------------------------------------------
+
+type serviceTarget struct {
 	name types.NamespacedName
 }
 
-// NewTarget creates a new target from the service with the specified name in the given namespace.
-func NewTarget(name, namespace string) Target {
-	return Target{
+// NewServiceTarget creates a new target which dynamically sources the IP from the provided
+// Kubernetes service.
+func NewServiceTarget(name, namespace string) Target {
+	return serviceTarget{
 		name: types.NamespacedName{Name: name, Namespace: namespace},
 	}
 }
 
-// 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) {
+func (t serviceTarget) 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 {
@@ -43,7 +55,28 @@ func (t Target) IPs(ctx context.Context, client client.Client) ([]string, error)
 	return targets, nil
 }
 
-// NamespacedName returns the namespaced name of the target service.
-func (t Target) NamespacedName() types.NamespacedName {
-	return t.name
+func (t serviceTarget) NamespacedName() *types.NamespacedName {
+	return &t.name
+}
+
+//-------------------------------------------------------------------------------------------------
+// STATIC TARGET
+//-------------------------------------------------------------------------------------------------
+
+type staticTarget struct {
+	ips []string
+}
+
+// NewStaticTarget creates a new target which provides the given static IPs. IPs may be IPv4 or
+// IPv6 addresses (and any combination thereof).
+func NewStaticTarget(ips ...string) Target {
+	return staticTarget{ips}
+}
+
+func (t staticTarget) IPs(ctx context.Context, client client.Client) ([]string, error) {
+	return t.ips, nil
+}
+
+func (t staticTarget) NamespacedName() *types.NamespacedName {
+	return nil
 }


Test patch:
diff --git a/internal/controllers/ingressroute_test.go b/internal/controllers/ingressroute_test.go
index 41c4433..613b627 100644
--- a/internal/controllers/ingressroute_test.go
+++ b/internal/controllers/ingressroute_test.go
@@ -236,8 +236,9 @@ func runReconciliation(
 	ingress traefik.IngressRoute,
 	config configv1.Config,
 ) {
-	reconciler := NewIngressRouteReconciler(client, zap.NewNop(), config)
-	_, err := reconciler.Reconcile(ctx, controllerruntime.Request{
+	reconciler, err := NewIngressRouteReconciler(client, zap.NewNop(), config)
+	require.Nil(t, err)
+	_, err = reconciler.Reconcile(ctx, controllerruntime.Request{
 		NamespacedName: types.NamespacedName{Name: ingress.Name, Namespace: ingress.Namespace},
 	})
 	require.Nil(t, err)
@@ -247,7 +248,7 @@ func createConfig(service *v1.Service) configv1.Config {
 	return configv1.Config{
 		Integrations: configv1.IntegrationConfigs{
 			ExternalDNS: &configv1.ExternalDNSIntegrationConfig{
-				Target: configv1.ServiceRef{
+				TargetService: &configv1.ServiceRef{
 					Name:      service.Name,
 					Namespace: service.Namespace,
 				},
diff --git a/internal/controllers/utils_test.go b/internal/controllers/utils_test.go
index 060cccd..e3e2b3c 100644
--- a/internal/controllers/utils_test.go
+++ b/internal/controllers/utils_test.go
@@ -6,6 +6,7 @@ import (
 	configv1 "github.com/borchero/switchboard/internal/config/v1"
 	"github.com/borchero/switchboard/internal/k8tests"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestIntegrationsFromConfig(t *testing.T) {
@@ -15,13 +16,15 @@ func TestIntegrationsFromConfig(t *testing.T) {
 
 	// Test all configurations of integrations
 	config := configv1.Config{}
-	integrations := integrationsFromConfig(config, client)
+	integrations, err := integrationsFromConfig(config, client)
+	require.Nil(t, err)
 	assert.Len(t, integrations, 0)
 
 	config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{
-		Target: configv1.ServiceRef{Name: "my-service", Namespace: "my-namespace"},
+		TargetService: &configv1.ServiceRef{Name: "my-service", Namespace: "my-namespace"},
 	}
-	integrations = integrationsFromConfig(config, client)
+	integrations, err = integrationsFromConfig(config, client)
+	require.Nil(t, err)
 	assert.Len(t, integrations, 1)
 	assert.Equal(t, "external-dns", integrations[0].Name())
 
@@ -29,13 +32,26 @@ func TestIntegrationsFromConfig(t *testing.T) {
 	config.Integrations.CertManager = &configv1.CertManagerIntegrationConfig{
 		Issuer: configv1.IssuerRef{Kind: "ClusterIssuer", Name: "my-issuer"},
 	}
-	integrations = integrationsFromConfig(config, client)
+	integrations, err = integrationsFromConfig(config, client)
+	require.Nil(t, err)
 	assert.Len(t, integrations, 1)
 	assert.Equal(t, "cert-manager", integrations[0].Name())
 
 	config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{
-		Target: configv1.ServiceRef{Name: "my-service", Namespace: "my-namespace"},
+		TargetIPs: []string{"127.0.0.1"},
 	}
-	integrations = integrationsFromConfig(config, client)
+	integrations, err = integrationsFromConfig(config, client)
+	require.Nil(t, err)
 	assert.Len(t, integrations, 2)
+
+	// Must fail if external DNS is not configured correctly
+	config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{}
+	_, err = integrationsFromConfig(config, client)
+	require.NotNil(t, err)
+
+	config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{
+		TargetIPs: []string{},
+	}
+	_, err = integrationsFromConfig(config, client)
+	require.NotNil(t, err)
 }
diff --git a/internal/integrations/externaldns_test.go b/internal/integrations/externaldns_test.go
index f1b2c49..bbb7191 100644
--- a/internal/integrations/externaldns_test.go
+++ b/internal/integrations/externaldns_test.go
@@ -13,7 +13,7 @@ import (
 )
 
 func TestExternalDNSWatchedObject(t *testing.T) {
-	integration := NewExternalDNS(nil, switchboard.NewTarget("my-name", "my-namespace"))
+	integration := NewExternalDNS(nil, switchboard.NewServiceTarget("my-name", "my-namespace"))
 	obj := integration.WatchedObject()
 	assert.Equal(t, "my-name", obj.GetName())
 	assert.Equal(t, "my-namespace", obj.GetNamespace())
@@ -31,7 +31,7 @@ func TestExternalDNSUpdateResource(t *testing.T) {
 	owner := k8tests.DummyService("my-service", namespace, 80)
 	err := client.Create(ctx, &owner)
 	require.Nil(t, err)
-	integration := NewExternalDNS(client, switchboard.NewTarget(owner.Name, namespace))
+	integration := NewExternalDNS(client, switchboard.NewServiceTarget(owner.Name, namespace))
 
 	// No resource should be created if no hosts are provided
 	info := IngressInfo{}
diff --git a/internal/switchboard/targets_test.go b/internal/switchboard/targets_test.go
index ec6615b..8df348e 100644
--- a/internal/switchboard/targets_test.go
+++ b/internal/switchboard/targets_test.go
@@ -11,7 +11,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
-func TestIP(t *testing.T) {
+func TestServiceTargetIP(t *testing.T) {
 	// Setup
 	ctx := context.Background()
 	scheme := k8tests.NewScheme()
@@ -25,7 +25,7 @@ func TestIP(t *testing.T) {
 	require.Nil(t, err)
 
 	// Check whether we find the cluster IP
-	target := NewTarget(service.Name, service.Namespace)
+	target := NewServiceTarget(service.Name, service.Namespace)
 	ips, err := target.IPs(ctx, ctrlClient)
 	require.Nil(t, err)
 	assert.ElementsMatch(t, service.Spec.ClusterIPs, ips)
@@ -45,9 +45,18 @@ func TestIP(t *testing.T) {
 	assert.ElementsMatch(t, []string{service.Status.LoadBalancer.Ingress[0].IP}, ips)
 }
 
-func TestNamespacedName(t *testing.T) {
-	target := NewTarget("my-service", "my-namespace")
+func TestServiceTargetNamespacedName(t *testing.T) {
+	target := NewServiceTarget("my-service", "my-namespace")
 	name := target.NamespacedName()
 	assert.Equal(t, "my-service", name.Name)
 	assert.Equal(t, "my-namespace", name.Namespace)
 }
+
+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)
+	require.Nil(t, err)
+	assert.ElementsMatch(t, expectedIPs, ips)
+}