diff --git a/go.mod b/go.mod index 2067700..c67f01d 100644 --- a/go.mod +++ b/go.mod @@ -50,6 +50,7 @@ require ( github.com/gorilla/mux v1.8.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/mabels/ipaddress/go/ipaddress v0.0.0-20211229223036-692af3b12a67 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index 4b0d94b..b574adb 100644 --- a/go.sum +++ b/go.sum @@ -1037,6 +1037,8 @@ github.com/looplab/fsm v0.1.0/go.mod h1:m2VaOfDHxqXBBMgc26m6yUOwkFn8H2AlJDE+jd/u github.com/lucas-clemente/quic-go v0.23.0/go.mod h1:paZuzjXCE5mj6sikVLMvqXk8lJV2AsqtJ6bDhjEfxx0= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/mabels/ipaddress/go/ipaddress v0.0.0-20211229223036-692af3b12a67 h1:bVDE1M+1x2gmAsV2pdNa/9X4z5K2IitUSfC8L19FkhE= +github.com/mabels/ipaddress/go/ipaddress v0.0.0-20211229223036-692af3b12a67/go.mod h1:1mdRW3hfT/mDTtpLkQ8y0Ez5WHZvH9wx9YMw9zq9OLQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= diff --git a/internal/controllers/ingressroute.go b/internal/controllers/ingressroute.go index 73f52ac..a5bc10e 100644 --- a/internal/controllers/ingressroute.go +++ b/internal/controllers/ingressroute.go @@ -27,7 +27,7 @@ type IngressRouteReconciler struct { func NewIngressRouteReconciler( client client.Client, logger *zap.Logger, config configv1.Config, ) (IngressRouteReconciler, error) { - integrations, err := integrationsFromConfig(config, client) + integrations, err := integrationsFromConfig(config, client, logger) if err != nil { return IngressRouteReconciler{}, fmt.Errorf("failed to initialize integrations: %s", err) } @@ -68,7 +68,7 @@ func (r *IngressRouteReconciler) Reconcile( Hosts: switchboard.NewHostCollection(). WithTLSHostsIfAvailable(ingressRoute.Spec.TLS). WithRouteHostsIfRequired(ingressRoute.Spec.Routes). - Hosts(), + Hosts(ingressRoute.ObjectMeta.Annotations), TLSSecretName: ext.AndThen(ingressRoute.Spec.TLS, func(tls traefik.TLS) string { return tls.SecretName }), diff --git a/internal/controllers/ingressroute_test.go b/internal/controllers/ingressroute_test.go index 7ab3b24..643c8bc 100644 --- a/internal/controllers/ingressroute_test.go +++ b/internal/controllers/ingressroute_test.go @@ -3,6 +3,7 @@ package controllers import ( "context" "fmt" + "strings" "testing" configv1 "github.com/borchero/switchboard/internal/config/v1" @@ -157,6 +158,42 @@ func TestIngressMultipleRules(t *testing.T) { }) } +func TestIngressTargetAnnotation(t *testing.T) { + targets := endpoint.Targets{"3.3.3.3", "4.4.4.4"} + runTest(t, testCase{ + Targets: &targets, + Ingress: traefik.IngressRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress", + Annotations: map[string]string{ + "switchboard.borchero.com/target": strings.Join(targets, ","), + }, + }, + Spec: traefik.IngressRouteSpec{ + Routes: []traefik.Route{{ + Kind: "Rule", + Match: "Host(`example.com`)", + Services: []traefik.Service{{ + LoadBalancerSpec: traefik.LoadBalancerSpec{ + Name: "nginx", + }, + }}, + }}, + TLS: &traefik.TLS{ + SecretName: "www-tls-certificate", + Domains: []traefiktypes.Domain{{ + Main: "example.net", + SANs: []string{ + "*.example.net", + }, + }}, + }, + }, + }, + DNSNames: []string{"example.net", "*.example.net"}, + }) +} + //------------------------------------------------------------------------------------------------- // TESTING UTILITIES //------------------------------------------------------------------------------------------------- @@ -164,6 +201,7 @@ func TestIngressMultipleRules(t *testing.T) { type testCase struct { Ingress traefik.IngressRoute DNSNames []string + Targets *endpoint.Targets } func runTest(t *testing.T, test testCase) { @@ -220,8 +258,12 @@ func runTest(t *testing.T, test testCase) { assert.Nil(t, err) assert.Len(t, endpoint.Spec.Endpoints, len(test.DNSNames)) for _, ep := range endpoint.Spec.Endpoints { - assert.Len(t, ep.Targets, 1) - assert.Equal(t, service.Spec.ClusterIP, ep.Targets[0]) + if test.Targets != nil { + assert.Equal(t, *test.Targets, ep.Targets) + } else { + assert.Len(t, ep.Targets, 1) + assert.Equal(t, service.Spec.ClusterIP, ep.Targets[0]) + } } } } diff --git a/internal/controllers/utils.go b/internal/controllers/utils.go index 09c5bd7..e09e8e9 100644 --- a/internal/controllers/utils.go +++ b/internal/controllers/utils.go @@ -17,7 +17,7 @@ import ( ) func integrationsFromConfig( - config configv1.Config, client client.Client, + config configv1.Config, client client.Client, logger *zap.Logger, ) ([]integrations.Integration, error) { result := make([]integrations.Integration, 0) externalDNS := config.Integrations.ExternalDNS @@ -32,8 +32,8 @@ func integrationsFromConfig( client, switchboard.NewServiceTarget( externalDNS.TargetService.Name, externalDNS.TargetService.Namespace, - ), - )) + logger, + ))) } else { result = append(result, integrations.NewExternalDNS( client, switchboard.NewStaticTarget(externalDNS.TargetIPs...), diff --git a/internal/controllers/utils_test.go b/internal/controllers/utils_test.go index c3f040f..85275b9 100644 --- a/internal/controllers/utils_test.go +++ b/internal/controllers/utils_test.go @@ -1,10 +1,12 @@ package controllers import ( + "context" "testing" configv1 "github.com/borchero/switchboard/internal/config/v1" "github.com/borchero/switchboard/internal/k8tests" + "github.com/borchero/zeus/pkg/zeus" certmanager "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1" cmmeta "github.com/jetstack/cert-manager/pkg/apis/meta/v1" "github.com/stretchr/testify/assert" @@ -18,14 +20,14 @@ func TestIntegrationsFromConfig(t *testing.T) { // Test all configurations of integrations config := configv1.Config{} - integrations, err := integrationsFromConfig(config, client) + integrations, err := integrationsFromConfig(config, client, zeus.Logger(context.Background())) require.Nil(t, err) assert.Len(t, integrations, 0) config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{ TargetService: &configv1.ServiceRef{Name: "my-service", Namespace: "my-namespace"}, } - integrations, err = integrationsFromConfig(config, client) + integrations, err = integrationsFromConfig(config, client, zeus.Logger(context.Background())) require.Nil(t, err) assert.Len(t, integrations, 1) assert.Equal(t, "external-dns", integrations[0].Name()) @@ -41,7 +43,7 @@ func TestIntegrationsFromConfig(t *testing.T) { }, }, } - integrations, err = integrationsFromConfig(config, client) + integrations, err = integrationsFromConfig(config, client, zeus.Logger(context.Background())) require.Nil(t, err) assert.Len(t, integrations, 1) assert.Equal(t, "cert-manager", integrations[0].Name()) @@ -49,18 +51,18 @@ func TestIntegrationsFromConfig(t *testing.T) { config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{ TargetIPs: []string{"127.0.0.1"}, } - integrations, err = integrationsFromConfig(config, client) + integrations, err = integrationsFromConfig(config, client, zeus.Logger(context.Background())) 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) + _, err = integrationsFromConfig(config, client, zeus.Logger(context.Background())) require.NotNil(t, err) config.Integrations.ExternalDNS = &configv1.ExternalDNSIntegrationConfig{ TargetIPs: []string{}, } - _, err = integrationsFromConfig(config, client) + _, err = integrationsFromConfig(config, client, zeus.Logger(context.Background())) require.NotNil(t, err) } diff --git a/internal/integrations/certmanager.go b/internal/integrations/certmanager.go index 4844bf9..9f3e9ef 100644 --- a/internal/integrations/certmanager.go +++ b/internal/integrations/certmanager.go @@ -41,7 +41,7 @@ func (c *certManager) UpdateResource( ) error { // If the ingress does not specify a TLS secret name or specifies no hosts, no certificate // needs to be created. - if info.TLSSecretName == nil || len(info.Hosts) == 0 { + if info.TLSSecretName == nil || len(info.Hosts.Names) == 0 { certificate := certmanager.Certificate{ObjectMeta: c.objectMeta(owner)} if err := k8s.DeleteIfFound(ctx, c.client, &certificate); err != nil { return fmt.Errorf("failed to delete TLS certificate: %w", err) @@ -62,7 +62,7 @@ func (c *certManager) UpdateResource( // Spec template := c.template.Spec.DeepCopy() template.SecretName = *info.TLSSecretName - template.DNSNames = info.Hosts + template.DNSNames = info.Hosts.Names if err := mergo.Merge(&resource.Spec, template, mergo.WithOverride); err != nil { return fmt.Errorf("failed to reconcile specification: %s", err) } diff --git a/internal/integrations/certmanager_test.go b/internal/integrations/certmanager_test.go index 6009b64..8c1ef0a 100644 --- a/internal/integrations/certmanager_test.go +++ b/internal/integrations/certmanager_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/borchero/switchboard/internal/k8tests" + "github.com/borchero/switchboard/internal/switchboard" certmanager "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1" cmmeta "github.com/jetstack/cert-manager/pkg/apis/meta/v1" "github.com/stretchr/testify/assert" @@ -47,13 +48,13 @@ func TestCertManagerUpdateResource(t *testing.T) { require.Nil(t, err) assert.Len(t, getCertificates(ctx, t, client, namespace), 0) - info = IngressInfo{Hosts: []string{"example.com"}} + info = IngressInfo{Hosts: switchboard.HostsTarget{Names: []string{"example.com"}}} err = integration.UpdateResource(ctx, &owner, info) require.Nil(t, err) assert.Len(t, getCertificates(ctx, t, client, namespace), 0) // If both are set, we should see a certificate created - info = IngressInfo{Hosts: []string{"example.com"}, TLSSecretName: &tlsName} + info = IngressInfo{Hosts: switchboard.HostsTarget{Names: []string{"example.com"}}, TLSSecretName: &tlsName} err = integration.UpdateResource(ctx, &owner, info) require.Nil(t, err) @@ -63,16 +64,16 @@ func TestCertManagerUpdateResource(t *testing.T) { assert.Equal(t, tlsName, certificates[0].Spec.SecretName) assert.Equal(t, "ClusterIssuer", certificates[0].Spec.IssuerRef.Kind) assert.Equal(t, "my-issuer", certificates[0].Spec.IssuerRef.Name) - assert.ElementsMatch(t, info.Hosts, certificates[0].Spec.DNSNames) + assert.ElementsMatch(t, info.Hosts.Names, certificates[0].Spec.DNSNames) // We should see an update if we change any info - info.Hosts = []string{"example.com", "www.example.com"} + info.Hosts = switchboard.HostsTarget{Names: []string{"example.com", "www.example.com"}} err = integration.UpdateResource(ctx, &owner, info) require.Nil(t, err) certificates = getCertificates(ctx, t, client, namespace) assert.Len(t, certificates, 1) assert.Equal(t, tlsName, certificates[0].Spec.SecretName) - assert.ElementsMatch(t, info.Hosts, certificates[0].Spec.DNSNames) + assert.ElementsMatch(t, info.Hosts.Names, certificates[0].Spec.DNSNames) updatedTLSName := "new-test-tls" info.TLSSecretName = &updatedTLSName @@ -81,10 +82,10 @@ func TestCertManagerUpdateResource(t *testing.T) { certificates = getCertificates(ctx, t, client, namespace) assert.Len(t, certificates, 1) assert.Equal(t, updatedTLSName, certificates[0].Spec.SecretName) - assert.ElementsMatch(t, info.Hosts, certificates[0].Spec.DNSNames) + assert.ElementsMatch(t, info.Hosts.Names, certificates[0].Spec.DNSNames) // When no hosts are set, the certificate should be removed again - info.Hosts = nil + info.Hosts = switchboard.HostsTarget{} err = integration.UpdateResource(ctx, &owner, info) require.Nil(t, err) assert.Len(t, getCertificates(ctx, t, client, namespace), 0) diff --git a/internal/integrations/externaldns.go b/internal/integrations/externaldns.go index 39704e6..23e6e3d 100644 --- a/internal/integrations/externaldns.go +++ b/internal/integrations/externaldns.go @@ -52,7 +52,7 @@ func (e *externalDNS) UpdateResource( ) error { // If the ingress specifies no hosts, there should be no endpoint. We try deleting it and // ignore any error if it was not found. - if len(info.Hosts) == 0 { + if len(info.Hosts.Names) == 0 { dnsEndpoint := endpoint.DNSEndpoint{ObjectMeta: e.objectMeta(owner)} if err := k8s.DeleteIfFound(ctx, e.client, &dnsEndpoint); err != nil { return fmt.Errorf("failed to delete DNS endpoint: %w", err) @@ -61,7 +61,7 @@ func (e *externalDNS) UpdateResource( } // Get the IPs of the target service - targets, err := e.target.Targets(ctx, e.client) + targets, err := e.target.Targets(ctx, e.client, info.Hosts.Target) if err != nil { return fmt.Errorf("failed to query IP for DNS A record: %w", err) } @@ -75,7 +75,7 @@ func (e *externalDNS) UpdateResource( } // Spec - resource.Spec.Endpoints = e.endpoints(info.Hosts, targets) + resource.Spec.Endpoints = e.endpoints(info.Hosts.Names, targets) return nil }); err != nil { return fmt.Errorf("failed to upsert DNS endpoint: %w", err) diff --git a/internal/integrations/externaldns_test.go b/internal/integrations/externaldns_test.go index 59f90d1..5e90cdc 100644 --- a/internal/integrations/externaldns_test.go +++ b/internal/integrations/externaldns_test.go @@ -6,6 +6,7 @@ import ( "github.com/borchero/switchboard/internal/k8tests" "github.com/borchero/switchboard/internal/switchboard" + "github.com/borchero/zeus/pkg/zeus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/controller-runtime/pkg/client" @@ -13,7 +14,7 @@ import ( ) func TestExternalDNSWatchedObject(t *testing.T) { - integration := NewExternalDNS(nil, switchboard.NewServiceTarget("my-name", "my-namespace")) + integration := NewExternalDNS(nil, switchboard.NewServiceTarget("my-name", "my-namespace", zeus.Logger(context.Background()))) obj := integration.WatchedObject() assert.Equal(t, "my-name", obj.GetName()) assert.Equal(t, "my-namespace", obj.GetNamespace()) @@ -31,7 +32,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.NewServiceTarget(owner.Name, namespace)) + integration := NewExternalDNS(client, switchboard.NewServiceTarget(owner.Name, namespace, zeus.Logger(ctx))) // No resource should be created if no hosts are provided info := IngressInfo{} @@ -40,25 +41,25 @@ func TestExternalDNSUpdateResource(t *testing.T) { assert.Len(t, getDNSEndpoints(ctx, t, client, namespace), 0) // A resource with the name of the service should be created for at least one host - info.Hosts = []string{"example.com"} + info.Hosts = switchboard.HostsTarget{Names: []string{"example.com"}} err = integration.UpdateResource(ctx, &owner, info) require.Nil(t, err) endpoints := getDNSEndpoints(ctx, t, client, namespace) assert.Len(t, endpoints, 1) assert.Contains(t, endpoints, owner.Name) - assert.ElementsMatch(t, endpoints[owner.Name], info.Hosts) + assert.ElementsMatch(t, endpoints[owner.Name], info.Hosts.Names) // When the hosts are changed, more endpoints should be added - info.Hosts = []string{"example.com", "www.example.com"} + info.Hosts = switchboard.HostsTarget{Names: []string{"example.com", "www.example.com"}} err = integration.UpdateResource(ctx, &owner, info) require.Nil(t, err) endpoints = getDNSEndpoints(ctx, t, client, namespace) assert.Len(t, endpoints, 1) assert.Contains(t, endpoints, owner.Name) - assert.ElementsMatch(t, endpoints[owner.Name], info.Hosts) + assert.ElementsMatch(t, endpoints[owner.Name], info.Hosts.Names) // When no hosts are set, the endpoints should be removed - info.Hosts = nil + info.Hosts = switchboard.HostsTarget{} err = integration.UpdateResource(ctx, &owner, info) require.Nil(t, err) assert.Len(t, getDNSEndpoints(ctx, t, client, namespace), 0) @@ -66,48 +67,48 @@ func TestExternalDNSUpdateResource(t *testing.T) { func TestExternalDNSEndpoints(t *testing.T) { integration := externalDNS{ttl: 250} - hosts := []string{"example.com", "www.example.com"} + hosts := switchboard.HostsTarget{Names: []string{"example.com", "www.example.com"}} - endpoints := integration.endpoints(hosts, []string{"127.0.0.1"}) + endpoints := integration.endpoints(hosts.Names, []string{"127.0.0.1"}) assert.Len(t, endpoints, 2) for _, ep := range endpoints { assert.ElementsMatch(t, ep.Targets, []string{"127.0.0.1"}) assert.Equal(t, ep.RecordTTL, endpoint.TTL(250)) assert.Equal(t, ep.RecordType, "A") - assert.Contains(t, hosts, ep.DNSName) + assert.Contains(t, hosts.Names, ep.DNSName) } - endpoints = integration.endpoints(hosts, []string{"2001:db8::1"}) + endpoints = integration.endpoints(hosts.Names, []string{"2001:db8::1"}) assert.Len(t, endpoints, 2) for _, ep := range endpoints { 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) + assert.Contains(t, hosts.Names, ep.DNSName) } - endpoints = integration.endpoints(hosts, []string{"127.0.0.1", "2001:db8::1"}) + endpoints = integration.endpoints(hosts.Names, []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) + assert.Contains(t, hosts.Names, 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) + assert.Contains(t, hosts.Names, ep.DNSName) } } - endpoints = integration.endpoints(hosts, []string{"example.lb.identifier.amazonaws.com"}) + endpoints = integration.endpoints(hosts.Names, []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) + assert.Contains(t, hosts.Names, ep.DNSName) } } @@ -119,6 +120,28 @@ func TestExternalDNSRecordType(t *testing.T) { assert.Equal(t, "CNAME", integration.recordType("example.lb.identifier.amazonaws.com")) } +func TestWithTargetAnnotation(t *testing.T) { + // Setup + ctx := context.Background() + scheme := k8tests.NewScheme() + client := k8tests.NewClient(t, scheme) + namespace, shutdown := k8tests.NewNamespace(ctx, t, client) + defer shutdown() + + // Create a dummy service as owner and target + owner := k8tests.DummyService("my-service", namespace, 80) + err := client.Create(ctx, &owner) + require.Nil(t, err) + integration := NewExternalDNS(client, switchboard.NewServiceTarget(owner.Name, namespace, zeus.Logger(ctx))) + + // No resource should be created if no hosts are provided + info := IngressInfo{} + err = integration.UpdateResource(ctx, &owner, info) + require.Nil(t, err) + assert.Len(t, getDNSEndpoints(ctx, t, client, namespace), 0) + +} + //------------------------------------------------------------------------------------------------- // UTILS //------------------------------------------------------------------------------------------------- diff --git a/internal/integrations/interface.go b/internal/integrations/interface.go index 1cdafca..5a07bb8 100644 --- a/internal/integrations/interface.go +++ b/internal/integrations/interface.go @@ -5,6 +5,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/borchero/switchboard/internal/switchboard" ) const ( @@ -14,7 +16,7 @@ const ( // IngressInfo encapsulates information extracted from ingress objects that integrations act upon. type IngressInfo struct { - Hosts []string + Hosts switchboard.HostsTarget TLSSecretName *string } diff --git a/internal/switchboard/hosts.go b/internal/switchboard/hosts.go index d298e5d..abbe9a0 100644 --- a/internal/switchboard/hosts.go +++ b/internal/switchboard/hosts.go @@ -7,6 +7,10 @@ import ( traefik "github.com/traefik/traefik/v2/pkg/provider/kubernetes/crd/traefik/v1alpha1" ) +const ( + targetAnnotation = "switchboard.borchero.com/target" +) + const ( hostRegex = "`((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9])`" ) @@ -17,6 +21,11 @@ var ( ) ) +type HostsTarget struct { + Target *string + Names []string +} + // HostCollection allows to aggregate the hosts from ingress resources. type HostCollection struct { hosts map[string]struct{} @@ -69,10 +78,18 @@ func (a *HostCollection) Len() int { } // Hosts returns all hosts managed by this aggregator. -func (a *HostCollection) Hosts() []string { - hosts := make([]string, 0, len(a.hosts)) +func (a *HostCollection) Hosts(annotations map[string]string) HostsTarget { + var target *string = nil + fromAnnotation, ok := annotations[targetAnnotation] + if ok { + target = &fromAnnotation + } + ret := HostsTarget{ + Names: make([]string, 0, len(a.hosts)), + Target: target, + } for host := range a.hosts { - hosts = append(hosts, host) + ret.Names = append(ret.Names, host) } - return hosts + return ret } diff --git a/internal/switchboard/hosts_test.go b/internal/switchboard/hosts_test.go index c00db32..c625748 100644 --- a/internal/switchboard/hosts_test.go +++ b/internal/switchboard/hosts_test.go @@ -23,7 +23,7 @@ func TestParseTLSHosts(t *testing.T) { SANs: []string{"www.example.com"}, }}, }) - assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com", "www.example.com"}) + assert.ElementsMatch(t, hosts.Hosts(map[string]string{}).Names, []string{"example.com", "www.example.com"}) } func TestParseRouteHosts(t *testing.T) { @@ -31,13 +31,13 @@ func TestParseRouteHosts(t *testing.T) { Kind: "Rule", Match: "Host(`example.com`)", }}) - assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com"}) + assert.ElementsMatch(t, hosts.Hosts(map[string]string{}).Names, []string{"example.com"}) hosts = NewHostCollection().WithRouteHostsIfRequired([]traefik.Route{{ Kind: "Rule", Match: "Host(`example.com`, `www.example.com`)", }}) - assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com", "www.example.com"}) + assert.ElementsMatch(t, hosts.Hosts(map[string]string{}).Names, []string{"example.com", "www.example.com"}) hosts = NewHostCollection().WithRouteHostsIfRequired([]traefik.Route{{ Kind: "Rule", @@ -47,8 +47,21 @@ func TestParseRouteHosts(t *testing.T) { Match: "Host(`v2.example.com`, `www.example.com`) && Prefix(`/test`)", }}) assert.ElementsMatch( - t, hosts.Hosts(), []string{"example.com", "www.example.com", "v2.example.com"}, - ) + t, hosts.Hosts(map[string]string{}).Names, []string{"example.com", "www.example.com", "v2.example.com"}) +} + +func TestHostTargetAnnotation(t *testing.T) { + hosts := NewHostCollection().WithRouteHostsIfRequired([]traefik.Route{{ + Kind: "Rule", + Match: "Host(`example.com`)", + }}) + testService := "TestService" + assert.Equal(t, *hosts.Hosts(map[string]string{ + "switchboard.borchero.com/target": testService, + }).Target, testService) + assert.ElementsMatch(t, hosts.Hosts(map[string]string{ + "switchboard.borchero.com/target": testService, + }).Names, []string{"example.com"}) } func TestParseRouteHostsNoop(t *testing.T) { @@ -58,5 +71,5 @@ func TestParseRouteHostsNoop(t *testing.T) { Kind: "Rule", Match: "Host(`www.example.com`)", }}) - assert.ElementsMatch(t, hosts.Hosts(), []string{"example.com"}) + assert.ElementsMatch(t, hosts.Hosts(map[string]string{}).Names, []string{"example.com"}) } diff --git a/internal/switchboard/targets.go b/internal/switchboard/targets.go index 72803c5..a1011ad 100644 --- a/internal/switchboard/targets.go +++ b/internal/switchboard/targets.go @@ -3,7 +3,12 @@ package switchboard import ( "context" "fmt" + "net" + "regexp" + "sort" + "strings" + "go.uber.org/zap" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -13,7 +18,7 @@ import ( type Target interface { // 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) + Targets(ctx context.Context, client client.Client, target *string) ([]string, error) // NamespacedName returns the namespaced name of the dynamic target service or none if the IP // is not retrieved dynamically. NamespacedName() *types.NamespacedName @@ -24,24 +29,86 @@ type Target interface { //------------------------------------------------------------------------------------------------- type serviceTarget struct { - name types.NamespacedName + name types.NamespacedName + logger *zap.Logger } // NewServiceTarget creates a new target which dynamically sources the IP from the provided // Kubernetes service. -func NewServiceTarget(name, namespace string) Target { +func NewServiceTarget(name, namespace string, log *zap.Logger) Target { return serviceTarget{ - name: types.NamespacedName{Name: name, Namespace: namespace}, + logger: log, + name: types.NamespacedName{Name: name, Namespace: namespace}, } } -func (t serviceTarget) Targets(ctx context.Context, client client.Client) ([]string, error) { +func (t serviceTarget) Targets(ctx context.Context, client client.Client, targets *string) ([]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) + if targets == nil { + tmp := fmt.Sprintf("%s/%s", t.name.Namespace, t.name.Name) + targets = &tmp } - return t.targetsFromService(service), nil + out := []string{} + for _, target := range strings.Split(*targets, ",") { + logger := t.logger.With(zap.String("target", target)) + target = strings.TrimSpace(target) + if len(target) == 0 { + continue + } + if net.ParseIP(target) != nil { + logger.Debug("target is ip address") + out = append(out, target) + continue + } + // very bad regex + if found, _ := regexp.MatchString("[0-9a-zA-Z\\-]+\\.[0-9a-zA-Z\\-]+.*", target); found { + logger.Debug("target is cname ") + out = append(out, target) + continue + } + parts := strings.Split(target, "/") + nsName := types.NamespacedName{ + Namespace: t.name.Namespace, + Name: parts[0], + } + if len(parts) > 1 { + nsName.Namespace = parts[0] + nsName.Name = parts[1] + } + var service v1.Service + if err := client.Get(ctx, nsName, &service); err != nil { + return nil, fmt.Errorf("failed to query service: %s:%s:%w", nsName.Namespace, nsName.Name, err) + } + logger.Debug("target is serviceaddress", zap.String("namespace", nsName.Namespace), zap.String("name", nsName.Name)) + targets := t.targetsFromService(service) + out = append(out, targets...) + } + reduce := map[string]string{} + for _, target := range out { + class := "CNAME" + if net.ParseIP(target) != nil { + class = "IP" + } + reduce[target] = class + } + var itsCnameOrIp string + countClass := 0 + out = []string{} + for target, class := range reduce { + out = append(out, target) + countClass++ + if itsCnameOrIp == "" { + itsCnameOrIp = class + } + if itsCnameOrIp != class { + return nil, fmt.Errorf("cannot mix CNAME and IP addresses in target: %s", target) + } + if itsCnameOrIp == "CNAME" && countClass > 1 { + return nil, fmt.Errorf("CNAME allows only one target: %s", target) + } + } + sort.Strings(out) + return out, nil } func (t serviceTarget) targetsFromService(service v1.Service) []string { @@ -80,10 +147,10 @@ type staticTarget struct { // 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} + return staticTarget{ips: ips} } -func (t staticTarget) Targets(ctx context.Context, client client.Client) ([]string, error) { +func (t staticTarget) Targets(ctx context.Context, client client.Client, targets *string) ([]string, error) { return t.ips, nil } diff --git a/internal/switchboard/targets_test.go b/internal/switchboard/targets_test.go index 630141c..c64d8f7 100644 --- a/internal/switchboard/targets_test.go +++ b/internal/switchboard/targets_test.go @@ -2,10 +2,12 @@ package switchboard import ( "context" + "fmt" "testing" "time" "github.com/borchero/switchboard/internal/k8tests" + "github.com/borchero/zeus/pkg/zeus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" @@ -26,8 +28,8 @@ func TestServiceTargetTargets(t *testing.T) { require.Nil(t, err) // Check whether we find the cluster IP - target := NewServiceTarget(service.Name, service.Namespace) - targets, err := target.Targets(ctx, ctrlClient) + target := NewServiceTarget(service.Name, service.Namespace, zeus.Logger(ctx)) + targets, err := target.Targets(ctx, ctrlClient, nil) require.Nil(t, err) assert.ElementsMatch(t, service.Spec.ClusterIPs, targets) @@ -37,13 +39,51 @@ func TestServiceTargetTargets(t *testing.T) { require.Nil(t, err) // Check whether we find the load balancer IP - time.Sleep(time.Second) - name := client.ObjectKeyFromObject(&service) - err = ctrlClient.Get(ctx, name, &service) + for i := 0; i < 15 && len(service.Status.LoadBalancer.Ingress) == 0; i++ { + time.Sleep(time.Second) // Wait for service to be ready + // time.Sleep(time.Second) + name := client.ObjectKeyFromObject(&service) + err = ctrlClient.Get(ctx, name, &service) + require.Nil(t, err) + } + + targets, err = target.Targets(ctx, ctrlClient, nil) require.Nil(t, err) - targets, err = target.Targets(ctx, ctrlClient) + var external string + if len(service.Status.LoadBalancer.Ingress[0].IP) > 0 { + external = service.Status.LoadBalancer.Ingress[0].IP + } + if len(service.Status.LoadBalancer.Ingress[0].Hostname) > 0 { + external = service.Status.LoadBalancer.Ingress[0].Hostname + } + assert.ElementsMatch(t, []string{external}, targets) + + explictTarget := fmt.Sprintf("1.1.1.1, 1::1 , www.test.bla, %s/my-service, my-service, 2.2.2.2, 2::2 , www.test.blub, ,", namespace) + _, err = target.Targets(ctx, ctrlClient, &explictTarget) + require.NotNil(t, err) + + explictTarget = fmt.Sprintf("www.test.bla, %s/my-service, my-service, ", namespace) + _, err = target.Targets(ctx, ctrlClient, &explictTarget) + require.NotNil(t, err) + + explictTarget = fmt.Sprintf("1.1.1.1, 1::1, %s/my-service, my-service, ", namespace) + _, err = target.Targets(ctx, ctrlClient, &explictTarget) + require.NotNil(t, err) + + explictTarget = fmt.Sprintf("%s/my-service, my-service, ", namespace) + ips, err := target.Targets(ctx, ctrlClient, &explictTarget) + require.Nil(t, err) + assert.ElementsMatch(t, ips, []string{external}) + + explictTarget = "www.test.bla, " + ips, err = target.Targets(ctx, ctrlClient, &explictTarget) require.Nil(t, err) - assert.ElementsMatch(t, []string{service.Status.LoadBalancer.Ingress[0].IP}, targets) + assert.ElementsMatch(t, ips, []string{"www.test.bla"}) + + explictTarget = "2::2, 1.1.1.1, 1.1.1.1, 1::1, 1::1, " + ips, err = target.Targets(ctx, ctrlClient, &explictTarget) + require.Nil(t, err) + assert.ElementsMatch(t, ips, []string{"1.1.1.1", "1::1", "2::2"}) } func TestServiceTargetTargetsFromService(t *testing.T) { @@ -91,10 +131,11 @@ func TestServiceTargetTargetsFromService(t *testing.T) { }} 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") + target := NewServiceTarget("my-service", "my-namespace", zeus.Logger(context.Background())) name := target.NamespacedName() assert.Equal(t, "my-service", name.Name) assert.Equal(t, "my-namespace", name.Namespace) @@ -104,7 +145,7 @@ func TestStaticTargetIPs(t *testing.T) { ctx := context.Background() expectedIPs := []string{"127.0.0.1", "2001:db8::1"} target := NewStaticTarget(expectedIPs...) - ips, err := target.Targets(ctx, nil) + ips, err := target.Targets(ctx, nil, nil) require.Nil(t, err) assert.ElementsMatch(t, expectedIPs, ips) }