From 9f427e5622501450011d2a620e85404bf978e402 Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Wed, 16 Apr 2025 13:11:42 +0100 Subject: [PATCH] chore(source): code cleanup --- provider/aws/aws.go | 2 +- provider/cloudflare/cloudflare.go | 20 +- provider/inmemory/inmemory.go | 8 +- registry/dynamodb_test.go | 2 +- source/ambassador_host.go | 12 +- source/ambassador_host_test.go | 5 +- source/annotations/annotations.go | 54 +++ source/annotations/processors.go | 125 ++++++ source/annotations/processors_test.go | 350 ++++++++++++++++ source/annotations/provider_specific.go | 76 ++++ source/annotations/provider_specific_test.go | 305 ++++++++++++++ source/compatibility.go | 8 +- source/contour_httpproxy.go | 22 +- source/endpoints.go | 110 +++++ source/endpoints_test.go | 255 ++++++++++++ source/f5_transportserver.go | 5 +- source/f5_virtualserver.go | 5 +- source/gateway.go | 9 +- source/gateway_httproute_test.go | 5 +- source/gloo_proxy.go | 9 +- source/ingress.go | 15 +- source/istio_gateway.go | 72 +--- source/istio_virtualservice.go | 32 +- source/kong_tcpingress.go | 22 +- source/node.go | 19 +- source/openshift_route.go | 29 +- source/pod.go | 12 +- source/service.go | 23 +- source/service_test.go | 5 +- source/skipper_routegroup.go | 15 +- source/skipper_routegroup_test.go | 51 --- source/source.go | 272 +------------ source/source_test.go | 408 +++++-------------- source/traefik_proxy.go | 64 +-- source/utils.go | 67 +++ source/utils_test.go | 151 +++++++ 36 files changed, 1787 insertions(+), 857 deletions(-) create mode 100644 source/annotations/annotations.go create mode 100644 source/annotations/processors.go create mode 100644 source/annotations/processors_test.go create mode 100644 source/annotations/provider_specific.go create mode 100644 source/annotations/provider_specific_test.go create mode 100644 source/endpoints.go create mode 100644 source/endpoints_test.go create mode 100644 source/utils.go create mode 100644 source/utils_test.go diff --git a/provider/aws/aws.go b/provider/aws/aws.go index c11977433..8abad1cc5 100644 --- a/provider/aws/aws.go +++ b/provider/aws/aws.go @@ -254,7 +254,7 @@ func (z zoneTags) filterZonesByTags(p *AWSProvider, zones map[string]*profiledZo // append adds tags to the ZoneTags for a given zoneID. func (z zoneTags) append(id string, tags []route53types.Tag) { zoneId := fmt.Sprintf("/hostedzone/%s", id) - if _, exists := z[zoneId]; !exists { + if _, ok := z[zoneId]; !ok { z[zoneId] = make(map[string]string) } for _, tag := range tags { diff --git a/provider/cloudflare/cloudflare.go b/provider/cloudflare/cloudflare.go index 0bac3f89b..590ed2279 100644 --- a/provider/cloudflare/cloudflare.go +++ b/provider/cloudflare/cloudflare.go @@ -34,7 +34,7 @@ import ( "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" - "sigs.k8s.io/external-dns/source" + "sigs.k8s.io/external-dns/source/annotations" ) const ( @@ -736,17 +736,17 @@ func (p *CloudFlareProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([] if proxied { e.RecordTTL = 0 } - e.SetProviderSpecificProperty(source.CloudflareProxiedKey, strconv.FormatBool(proxied)) + e.SetProviderSpecificProperty(annotations.CloudflareProxiedKey, strconv.FormatBool(proxied)) if p.CustomHostnamesConfig.Enabled { // sort custom hostnames in annotation to properly detect changes if customHostnames := getEndpointCustomHostnames(e); len(customHostnames) > 1 { sort.Strings(customHostnames) - e.SetProviderSpecificProperty(source.CloudflareCustomHostnameKey, strings.Join(customHostnames, ",")) + e.SetProviderSpecificProperty(annotations.CloudflareCustomHostnameKey, strings.Join(customHostnames, ",")) } } else { // ignore custom hostnames annotations if not enabled - e.DeleteProviderSpecificProperty(source.CloudflareCustomHostnameKey) + e.DeleteProviderSpecificProperty(annotations.CloudflareCustomHostnameKey) } adjustedEndpoints = append(adjustedEndpoints, e) @@ -928,10 +928,10 @@ func shouldBeProxied(ep *endpoint.Endpoint, proxiedByDefault bool) bool { proxied := proxiedByDefault for _, v := range ep.ProviderSpecific { - if v.Name == source.CloudflareProxiedKey { + if v.Name == annotations.CloudflareProxiedKey { b, err := strconv.ParseBool(v.Value) if err != nil { - log.Errorf("Failed to parse annotation [%q]: %v", source.CloudflareProxiedKey, err) + log.Errorf("Failed to parse annotation [%q]: %v", annotations.CloudflareProxiedKey, err) } else { proxied = b } @@ -951,7 +951,7 @@ func getRegionKey(endpoint *endpoint.Endpoint, defaultRegionKey string) string { } for _, v := range endpoint.ProviderSpecific { - if v.Name == source.CloudflareRegionKey { + if v.Name == annotations.CloudflareRegionKey { return v.Value } } @@ -960,7 +960,7 @@ func getRegionKey(endpoint *endpoint.Endpoint, defaultRegionKey string) string { func getEndpointCustomHostnames(ep *endpoint.Endpoint) []string { for _, v := range ep.ProviderSpecific { - if v.Name == source.CloudflareCustomHostnameKey { + if v.Name == annotations.CloudflareCustomHostnameKey { customHostnames := strings.Split(v.Value, ",") return customHostnames } @@ -1015,11 +1015,11 @@ func groupByNameAndTypeWithCustomHostnames(records DNSRecordsMap, chs CustomHost if e == nil { continue } - e = e.WithProviderSpecific(source.CloudflareProxiedKey, strconv.FormatBool(proxied)) + e = e.WithProviderSpecific(annotations.CloudflareProxiedKey, strconv.FormatBool(proxied)) // noop (customHostnames is empty) if custom hostnames feature is not in use if customHostnames, ok := customHostnames[records[0].Name]; ok { sort.Strings(customHostnames) - e = e.WithProviderSpecific(source.CloudflareCustomHostnameKey, strings.Join(customHostnames, ",")) + e = e.WithProviderSpecific(annotations.CloudflareCustomHostnameKey, strings.Join(customHostnames, ",")) } endpoints = append(endpoints, e) diff --git a/provider/inmemory/inmemory.go b/provider/inmemory/inmemory.go index 1f636dfda..a2c054f0d 100644 --- a/provider/inmemory/inmemory.go +++ b/provider/inmemory/inmemory.go @@ -312,7 +312,7 @@ func (c *inMemoryClient) validateChangeBatch(zone string, changes *plan.Changes) } mesh := sets.New[endpoint.EndpointKey]() for _, newEndpoint := range changes.Create { - if _, exists := curZone[newEndpoint.Key()]; exists { + if _, ok := curZone[newEndpoint.Key()]; ok { return ErrRecordAlreadyExists } if err := c.updateMesh(mesh, newEndpoint); err != nil { @@ -320,7 +320,7 @@ func (c *inMemoryClient) validateChangeBatch(zone string, changes *plan.Changes) } } for _, updateEndpoint := range changes.UpdateNew { - if _, exists := curZone[updateEndpoint.Key()]; !exists { + if _, ok := curZone[updateEndpoint.Key()]; !ok { return ErrRecordNotFound } if err := c.updateMesh(mesh, updateEndpoint); err != nil { @@ -328,12 +328,12 @@ func (c *inMemoryClient) validateChangeBatch(zone string, changes *plan.Changes) } } for _, updateOldEndpoint := range changes.UpdateOld { - if rec, exists := curZone[updateOldEndpoint.Key()]; !exists || rec.Targets[0] != updateOldEndpoint.Targets[0] { + if rec, ok := curZone[updateOldEndpoint.Key()]; !ok || rec.Targets[0] != updateOldEndpoint.Targets[0] { return ErrRecordNotFound } } for _, deleteEndpoint := range changes.Delete { - if rec, exists := curZone[deleteEndpoint.Key()]; !exists || rec.Targets[0] != deleteEndpoint.Targets[0] { + if rec, ok := curZone[deleteEndpoint.Key()]; !ok || rec.Targets[0] != deleteEndpoint.Targets[0] { return ErrRecordNotFound } if err := c.updateMesh(mesh, deleteEndpoint); err != nil { diff --git a/registry/dynamodb_test.go b/registry/dynamodb_test.go index 281e274d9..e6e71b47d 100644 --- a/registry/dynamodb_test.go +++ b/registry/dynamodb_test.go @@ -1248,7 +1248,7 @@ func (r *DynamoDBStub) BatchExecuteStatement(context context.Context, input *dyn var key string assert.Nil(r.t, attributevalue.Unmarshal(statement.Parameters[0], &key)) - if code, exists := r.stubConfig.ExpectInsertError[key]; exists { + if code, ok := r.stubConfig.ExpectInsertError[key]; ok { delete(r.stubConfig.ExpectInsertError, key) responses = append(responses, dynamodbtypes.BatchStatementResponse{ Error: &dynamodbtypes.BatchStatementError{ diff --git a/source/ambassador_host.go b/source/ambassador_host.go index cb0501210..d05462d3d 100644 --- a/source/ambassador_host.go +++ b/source/ambassador_host.go @@ -38,6 +38,7 @@ import ( "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) // ambHostAnnotation is the annotation in the Host that maps to a Service @@ -119,7 +120,7 @@ func (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endp } // Get a list of Ambassador Host resources - ambassadorHosts := []*ambassador.Host{} + var ambassadorHosts []*ambassador.Host for _, hostObj := range hosts { unstructuredHost, ok := hostObj.(*unstructured.Unstructured) if !ok { @@ -140,7 +141,7 @@ func (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endp return nil, errors.Wrap(err, "failed to filter Ambassador Hosts by annotation") } - endpoints := []*endpoint.Endpoint{} + var endpoints []*endpoint.Endpoint for _, host := range ambassadorHosts { fullname := fmt.Sprintf("%s/%s", host.Namespace, host.Name) @@ -152,7 +153,7 @@ func (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endp continue } - targets := getTargetsFromTargetAnnotation(host.Annotations) + targets := annotations.TargetsFromTargetAnnotation(host.Annotations) if len(targets) == 0 { targets, err = sc.targetsFromAmbassadorLoadBalancer(ctx, service) if err != nil { @@ -185,11 +186,10 @@ func (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endp // endpointsFromHost extracts the endpoints from a Host object func (sc *ambassadorHostSource) endpointsFromHost(host *ambassador.Host, targets endpoint.Targets) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint - annotations := host.Annotations resource := fmt.Sprintf("host/%s/%s", host.Namespace, host.Name) - providerSpecific, setIdentifier := getProviderSpecificAnnotations(annotations) - ttl := getTTLFromAnnotations(annotations, resource) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(host.Annotations) + ttl := annotations.TTLFromAnnotations(host.Annotations, resource) if host.Spec != nil { hostname := host.Spec.Hostname diff --git a/source/ambassador_host_test.go b/source/ambassador_host_test.go index 970bb2a19..d6246d854 100644 --- a/source/ambassador_host_test.go +++ b/source/ambassador_host_test.go @@ -33,6 +33,7 @@ import ( fakeDynamic "k8s.io/client-go/dynamic/fake" fakeKube "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) const defaultAmbassadorNamespace = "ambassador" @@ -246,8 +247,8 @@ func TestAmbassadorHostSource(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ - ambHostAnnotation: hostAnnotation, - CloudflareProxiedKey: "true", + ambHostAnnotation: hostAnnotation, + annotations.CloudflareProxiedKey: "true", }, }, Spec: &ambassador.HostSpec{ diff --git a/source/annotations/annotations.go b/source/annotations/annotations.go new file mode 100644 index 000000000..fdec57b78 --- /dev/null +++ b/source/annotations/annotations.go @@ -0,0 +1,54 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + "math" +) + +const ( + // CloudflareProxiedKey The annotation used for determining if traffic will go through Cloudflare + CloudflareProxiedKey = "external-dns.alpha.kubernetes.io/cloudflare-proxied" + CloudflareCustomHostnameKey = "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname" + CloudflareRegionKey = "external-dns.alpha.kubernetes.io/cloudflare-region-key" + + AWSPrefix = "external-dns.alpha.kubernetes.io/aws-" + SCWPrefix = "external-dns.alpha.kubernetes.io/scw-" + IBMCloudPrefix = "external-dns.alpha.kubernetes.io/ibmcloud-" + WebhookPrefix = "external-dns.alpha.kubernetes.io/webhook-" + CloudflarePrefix = "external-dns.alpha.kubernetes.io/cloudflare-" + + TtlKey = "external-dns.alpha.kubernetes.io/ttl" + ttlMinimum = 1 + ttlMaximum = math.MaxInt32 + + SetIdentifierKey = "external-dns.alpha.kubernetes.io/set-identifier" + AliasKey = "external-dns.alpha.kubernetes.io/alias" + TargetKey = "external-dns.alpha.kubernetes.io/target" + // The annotation used for figuring out which controller is responsible + ControllerKey = "external-dns.alpha.kubernetes.io/controller" + // The annotation used for defining the desired hostname + HostnameKey = "external-dns.alpha.kubernetes.io/hostname" + // The annotation used for specifying whether the public or private interface address is used + AccessKey = "external-dns.alpha.kubernetes.io/access" + // The annotation used for specifying the type of endpoints to use for headless services + EndpointsTypeKey = "external-dns.alpha.kubernetes.io/endpoints-type" + // The annotation used to determine the source of hostnames for ingresses. This is an optional field - all + // available hostname sources are used if not specified. + IngressHostnameSourceKey = "external-dns.alpha.kubernetes.io/ingress-hostname-source" + // The value of the controller annotation so that we feel responsible + ControllerValue = "dns-controller" + // The annotation used for defining the desired hostname + InternalHostnameKey = "external-dns.alpha.kubernetes.io/internal-hostname" +) diff --git a/source/annotations/processors.go b/source/annotations/processors.go new file mode 100644 index 000000000..dd704ab5a --- /dev/null +++ b/source/annotations/processors.go @@ -0,0 +1,125 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + "strconv" + "strings" + "time" + + log "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + + "sigs.k8s.io/external-dns/endpoint" +) + +func hasAliasFromAnnotations(annotations map[string]string) bool { + aliasAnnotation, ok := annotations[AliasKey] + return ok && aliasAnnotation == "true" +} + +// TTLFromAnnotations extracts the TTL from the annotations of the given resource. +func TTLFromAnnotations(annotations map[string]string, resource string) endpoint.TTL { + ttlNotConfigured := endpoint.TTL(0) + ttlAnnotation, ok := annotations[TtlKey] + if !ok { + return ttlNotConfigured + } + ttlValue, err := parseTTL(ttlAnnotation) + if err != nil { + log.Warnf("%s: %q is not a valid TTL value: %v", resource, ttlAnnotation, err) + return ttlNotConfigured + } + if ttlValue < ttlMinimum || ttlValue > ttlMaximum { + log.Warnf("TTL value %q must be between [%d, %d]", ttlValue, ttlMinimum, ttlMaximum) + return ttlNotConfigured + } + return endpoint.TTL(ttlValue) +} + +// parseTTL parses TTL from string, returning duration in seconds. +// parseTTL supports both integers like "600" and durations based +// on Go Duration like "10m", hence "600" and "10m" represent the same value. +// +// Note: for durations like "1.5s" the fraction is omitted (resulting in 1 second for the example). +func parseTTL(s string) (int64, error) { + ttlDuration, errDuration := time.ParseDuration(s) + if errDuration != nil { + ttlInt, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, errDuration + } + return ttlInt, nil + } + + return int64(ttlDuration.Seconds()), nil +} + +// ParseFilter parses an annotation filter string into a labels.Selector. +// Returns nil if the annotation filter is invalid. +func ParseFilter(annotationFilter string) (labels.Selector, error) { + labelSelector, err := metav1.ParseToLabelSelector(annotationFilter) + if err != nil { + return nil, err + } + selector, err := metav1.LabelSelectorAsSelector(labelSelector) + if err != nil { + return nil, err + } + return selector, nil +} + +// TargetsFromTargetAnnotation gets endpoints from optional "target" annotation. +// Returns empty endpoints array if none are found. +func TargetsFromTargetAnnotation(annotations map[string]string) endpoint.Targets { + var targets endpoint.Targets + // Get the desired hostname of the ingress from the annotation. + targetAnnotation, ok := annotations[TargetKey] + if ok && targetAnnotation != "" { + // splits the hostname annotation and removes the trailing periods + targetsList := SplitHostnameAnnotation(targetAnnotation) + for _, targetHostname := range targetsList { + targetHostname = strings.TrimSuffix(targetHostname, ".") + targets = append(targets, targetHostname) + } + } + return targets +} + +// HostnamesFromAnnotations extracts the hostnames from the given annotations map. +// It returns a slice of hostnames if the HostnameKey annotation is present, otherwise it returns nil. +func HostnamesFromAnnotations(input map[string]string) []string { + return extractHostnamesFromAnnotations(input, HostnameKey) +} + +// InternalHostnamesFromAnnotations extracts the internal hostnames from the given annotations map. +// It returns a slice of internal hostnames if the InternalHostnameKey annotation is present, otherwise it returns nil. +func InternalHostnamesFromAnnotations(input map[string]string) []string { + return extractHostnamesFromAnnotations(input, InternalHostnameKey) +} + +// SplitHostnameAnnotation splits a comma-separated hostname annotation string into a slice of hostnames. +// It trims any leading or trailing whitespace and removes any spaces within the anno +func SplitHostnameAnnotation(input string) []string { + return strings.Split(strings.TrimSpace(strings.ReplaceAll(input, " ", "")), ",") +} + +func extractHostnamesFromAnnotations(input map[string]string, key string) []string { + annotation, ok := input[key] + if !ok { + return nil + } + return SplitHostnameAnnotation(annotation) +} diff --git a/source/annotations/processors_test.go b/source/annotations/processors_test.go new file mode 100644 index 000000000..d423edc71 --- /dev/null +++ b/source/annotations/processors_test.go @@ -0,0 +1,350 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/external-dns/endpoint" +) + +func TestParseAnnotationFilter(t *testing.T) { + tests := []struct { + name string + annotationFilter string + expectedSelector labels.Selector + expectError bool + }{ + { + name: "valid annotation filter", + annotationFilter: "key1=value1,key2=value2", + expectedSelector: labels.Set{"key1": "value1", "key2": "value2"}.AsSelector(), + expectError: false, + }, + { + name: "invalid annotation filter", + annotationFilter: "key1==value1", + expectedSelector: labels.Set{"key1": "value1"}.AsSelector(), + expectError: false, + }, + { + name: "empty annotation filter", + annotationFilter: "", + expectedSelector: labels.Set{}.AsSelector(), + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selector, err := ParseFilter(tt.annotationFilter) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedSelector, selector) + } + }) + } +} + +func TestTargetsFromTargetAnnotation(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected endpoint.Targets + }{ + { + name: "no target annotation", + annotations: map[string]string{}, + expected: endpoint.Targets(nil), + }, + { + name: "single target annotation", + annotations: map[string]string{ + TargetKey: "example.com", + }, + expected: endpoint.Targets{"example.com"}, + }, + { + name: "multiple target annotations", + annotations: map[string]string{ + TargetKey: "example.com,example.org", + }, + expected: endpoint.Targets{"example.com", "example.org"}, + }, + { + name: "target annotation with trailing periods", + annotations: map[string]string{ + TargetKey: "example.com.,example.org.", + }, + expected: endpoint.Targets{"example.com", "example.org"}, + }, + { + name: "target annotation with spaces", + annotations: map[string]string{ + TargetKey: " example.com , example.org ", + }, + expected: endpoint.Targets{"example.com", "example.org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TargetsFromTargetAnnotation(tt.annotations) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestTTLFromAnnotations(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + resource string + expectedTTL endpoint.TTL + }{ + { + name: "no TTL annotation", + annotations: map[string]string{}, + resource: "test-resource", + expectedTTL: endpoint.TTL(0), + }, + { + name: "valid TTL annotation", + annotations: map[string]string{ + TtlKey: "600", + }, + resource: "test-resource", + expectedTTL: endpoint.TTL(600), + }, + { + name: "invalid TTL annotation", + annotations: map[string]string{ + TtlKey: "invalid", + }, + resource: "test-resource", + expectedTTL: endpoint.TTL(0), + }, + { + name: "TTL annotation out of range", + annotations: map[string]string{ + TtlKey: "999999", + }, + resource: "test-resource", + expectedTTL: endpoint.TTL(999999), + }, + { + name: "TTL annotation not present", + annotations: map[string]string{"foo": "bar"}, + expectedTTL: endpoint.TTL(0), + }, + { + name: "TTL annotation value is not a number", + annotations: map[string]string{TtlKey: "foo"}, + expectedTTL: endpoint.TTL(0), + }, + { + name: "TTL annotation value is empty", + annotations: map[string]string{TtlKey: ""}, + expectedTTL: endpoint.TTL(0), + }, + { + name: "TTL annotation value is negative number", + annotations: map[string]string{TtlKey: "-1"}, + expectedTTL: endpoint.TTL(0), + }, + { + name: "TTL annotation value is too high", + annotations: map[string]string{TtlKey: fmt.Sprintf("%d", 1<<32)}, + expectedTTL: endpoint.TTL(0), + }, + { + name: "TTL annotation value is set correctly using integer", + annotations: map[string]string{TtlKey: "60"}, + expectedTTL: endpoint.TTL(60), + }, + { + name: "TTL annotation value is set correctly using duration (whole)", + annotations: map[string]string{TtlKey: "10m"}, + expectedTTL: endpoint.TTL(600), + }, + { + name: "TTL annotation value is set correctly using duration (fractional)", + annotations: map[string]string{TtlKey: "20.5s"}, + expectedTTL: endpoint.TTL(20), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ttl := TTLFromAnnotations(tt.annotations, tt.resource) + assert.Equal(t, tt.expectedTTL, ttl) + }) + } +} + +func TestGetAliasFromAnnotations(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected bool + }{ + { + name: "alias annotation exists and is true", + annotations: map[string]string{AliasKey: "true"}, + expected: true, + }, + { + name: "alias annotation exists and is false", + annotations: map[string]string{AliasKey: "false"}, + expected: false, + }, + { + name: "alias annotation does not exist", + annotations: map[string]string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasAliasFromAnnotations(tt.annotations) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHostnamesFromAnnotations(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected []string + }{ + { + name: "no hostname annotation", + annotations: map[string]string{}, + expected: nil, + }, + { + name: "single hostname annotation", + annotations: map[string]string{ + HostnameKey: "example.com", + }, + expected: []string{"example.com"}, + }, + { + name: "multiple hostname annotations", + annotations: map[string]string{ + HostnameKey: "example.com,example.org", + }, + expected: []string{"example.com", "example.org"}, + }, + { + name: "hostname annotation with spaces", + annotations: map[string]string{ + HostnameKey: " example.com , example.org ", + }, + expected: []string{"example.com", "example.org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HostnamesFromAnnotations(tt.annotations) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSplitHostnameAnnotation(t *testing.T) { + tests := []struct { + name string + annotation string + expected []string + }{ + { + name: "empty annotation", + annotation: "", + expected: []string{""}, + }, + { + name: "single hostname", + annotation: "example.com", + expected: []string{"example.com"}, + }, + { + name: "multiple hostnames", + annotation: "example.com,example.org", + expected: []string{"example.com", "example.org"}, + }, + { + name: "hostnames with spaces", + annotation: " example.com , example.org ", + expected: []string{"example.com", "example.org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SplitHostnameAnnotation(tt.annotation) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestInternalHostnamesFromAnnotations(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected []string + }{ + { + name: "no internal hostname annotation", + annotations: map[string]string{}, + expected: nil, + }, + { + name: "single internal hostname annotation", + annotations: map[string]string{ + InternalHostnameKey: "internal.example.com", + }, + expected: []string{"internal.example.com"}, + }, + { + name: "multiple internal hostname annotations", + annotations: map[string]string{ + InternalHostnameKey: "internal.example.com,internal.example.org", + }, + expected: []string{"internal.example.com", "internal.example.org"}, + }, + { + name: "internal hostname annotation with spaces", + annotations: map[string]string{ + InternalHostnameKey: " internal.example.com , internal.example.org ", + }, + expected: []string{"internal.example.com", "internal.example.org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := InternalHostnamesFromAnnotations(tt.annotations) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/source/annotations/provider_specific.go b/source/annotations/provider_specific.go new file mode 100644 index 000000000..c714e0c65 --- /dev/null +++ b/source/annotations/provider_specific.go @@ -0,0 +1,76 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + "fmt" + "strings" + + "sigs.k8s.io/external-dns/endpoint" +) + +func ProviderSpecificAnnotations(annotations map[string]string) (endpoint.ProviderSpecific, string) { + providerSpecificAnnotations := endpoint.ProviderSpecific{} + + if hasAliasFromAnnotations(annotations) { + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: "alias", + Value: "true", + }) + } + setIdentifier := "" + for k, v := range annotations { + if k == SetIdentifierKey { + setIdentifier = v + } else if strings.HasPrefix(k, AWSPrefix) { + attr := strings.TrimPrefix(k, AWSPrefix) + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: fmt.Sprintf("aws/%s", attr), + Value: v, + }) + } else if strings.HasPrefix(k, SCWPrefix) { + attr := strings.TrimPrefix(k, SCWPrefix) + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: fmt.Sprintf("scw/%s", attr), + Value: v, + }) + } else if strings.HasPrefix(k, IBMCloudPrefix) { + attr := strings.TrimPrefix(k, IBMCloudPrefix) + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: fmt.Sprintf("ibmcloud-%s", attr), + Value: v, + }) + } else if strings.HasPrefix(k, WebhookPrefix) { + // Support for wildcard annotations for webhook providers + attr := strings.TrimPrefix(k, WebhookPrefix) + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: fmt.Sprintf("webhook/%s", attr), + Value: v, + }) + } else if strings.HasPrefix(k, CloudflarePrefix) { + if strings.Contains(k, CloudflareCustomHostnameKey) { + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: CloudflareCustomHostnameKey, + Value: v, + }) + } else if strings.Contains(k, CloudflareProxiedKey) { + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: CloudflareProxiedKey, + Value: v, + }) + } + } + } + return providerSpecificAnnotations, setIdentifier +} diff --git a/source/annotations/provider_specific_test.go b/source/annotations/provider_specific_test.go new file mode 100644 index 000000000..6f07e0992 --- /dev/null +++ b/source/annotations/provider_specific_test.go @@ -0,0 +1,305 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package annotations + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "sigs.k8s.io/external-dns/endpoint" +) + +func TestProviderSpecificAnnotations(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected endpoint.ProviderSpecific + setIdentifier string + }{ + { + name: "no annotations", + annotations: map[string]string{}, + expected: endpoint.ProviderSpecific{}, + setIdentifier: "", + }, + { + name: "Cloudflare proxied annotation", + annotations: map[string]string{ + CloudflareProxiedKey: "true", + }, + expected: endpoint.ProviderSpecific{ + {Name: CloudflareProxiedKey, Value: "true"}, + }, + setIdentifier: "", + }, + { + name: "Cloudflare custom hostname annotation", + annotations: map[string]string{ + CloudflareCustomHostnameKey: "custom.example.com", + }, + expected: endpoint.ProviderSpecific{ + {Name: CloudflareCustomHostnameKey, Value: "custom.example.com"}, + }, + setIdentifier: "", + }, + { + name: "AWS annotation", + annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/aws-weight": "100", + }, + expected: endpoint.ProviderSpecific{ + {Name: "aws/weight", Value: "100"}, + }, + setIdentifier: "", + }, + { + name: "Set identifier annotation", + annotations: map[string]string{ + SetIdentifierKey: "identifier", + }, + expected: endpoint.ProviderSpecific{}, + setIdentifier: "identifier", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, setIdentifier := ProviderSpecificAnnotations(tt.annotations) + assert.Equal(t, tt.expected, result) + assert.Equal(t, tt.setIdentifier, setIdentifier) + }) + } +} + +func TestGetProviderSpecificCloudflareAnnotations(t *testing.T) { + for _, tc := range []struct { + title string + annotations map[string]string + expectedKey string + expectedValue bool + }{ + { + title: "Cloudflare proxied annotation is set correctly to true", + annotations: map[string]string{CloudflareProxiedKey: "true"}, + expectedKey: CloudflareProxiedKey, + expectedValue: true, + }, + { + title: "Cloudflare proxied annotation is set correctly to false", + annotations: map[string]string{CloudflareProxiedKey: "false"}, + expectedKey: CloudflareProxiedKey, + expectedValue: false, + }, + { + title: "Cloudflare proxied annotation among another annotations is set correctly to true", + annotations: map[string]string{ + "random annotation 1": "random value 1", + CloudflareProxiedKey: "false", + "random annotation 2": "random value 2", + }, + expectedKey: CloudflareProxiedKey, + expectedValue: false, + }, + } { + t.Run(tc.title, func(t *testing.T) { + providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) + for _, providerSpecificAnnotation := range providerSpecificAnnotations { + if providerSpecificAnnotation.Name == tc.expectedKey { + assert.Equal(t, strconv.FormatBool(tc.expectedValue), providerSpecificAnnotation.Value) + return + } + } + t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %v", tc.expectedKey, tc.expectedValue) + }) + } + + for _, tc := range []struct { + title string + annotations map[string]string + expectedKey string + expectedValue string + }{ + { + title: "Cloudflare custom hostname annotation is set correctly", + annotations: map[string]string{CloudflareCustomHostnameKey: "a.foo.fancybar.com"}, + expectedKey: CloudflareCustomHostnameKey, + expectedValue: "a.foo.fancybar.com", + }, + { + title: "Cloudflare custom hostname annotation among another annotations is set correctly", + annotations: map[string]string{ + "random annotation 1": "random value 1", + CloudflareCustomHostnameKey: "a.foo.fancybar.com", + "random annotation 2": "random value 2"}, + expectedKey: CloudflareCustomHostnameKey, + expectedValue: "a.foo.fancybar.com", + }, + } { + t.Run(tc.title, func(t *testing.T) { + providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) + for _, providerSpecificAnnotation := range providerSpecificAnnotations { + if providerSpecificAnnotation.Name == tc.expectedKey { + assert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value) + return + } + } + t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %s", tc.expectedKey, tc.expectedValue) + }) + } +} + +func TestGetProviderSpecificAliasAnnotations(t *testing.T) { + for _, tc := range []struct { + title string + annotations map[string]string + expectedKey string + expectedValue bool + }{ + { + title: "alias annotation is set correctly to true", + annotations: map[string]string{AliasKey: "true"}, + expectedKey: AliasKey, + expectedValue: true, + }, + { + title: "alias annotation among another annotations is set correctly to true", + annotations: map[string]string{ + "random annotation 1": "random value 1", + AliasKey: "true", + "random annotation 2": "random value 2", + }, + expectedKey: AliasKey, + expectedValue: true, + }, + } { + t.Run(tc.title, func(t *testing.T) { + providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) + for _, providerSpecificAnnotation := range providerSpecificAnnotations { + if providerSpecificAnnotation.Name == "alias" { + assert.Equal(t, strconv.FormatBool(tc.expectedValue), providerSpecificAnnotation.Value) + return + } + } + t.Errorf("provider specific annotation alias is not set correctly to %v", tc.expectedValue) + }) + } + + for _, tc := range []struct { + title string + annotations map[string]string + }{ + { + title: "alias annotation is set to false", + annotations: map[string]string{AliasKey: "false"}, + }, + { + title: "alias annotation is not set", + annotations: map[string]string{ + "random annotation 1": "random value 1", + "random annotation 2": "random value 2", + }, + }, + } { + t.Run(tc.title, func(t *testing.T) { + providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) + for _, providerSpecificAnnotation := range providerSpecificAnnotations { + if providerSpecificAnnotation.Name == "alias" { + t.Error("provider specific annotation alias is not expected to be set") + } + } + + }) + } +} + +func TestGetProviderSpecificIdentifierAnnotations(t *testing.T) { + for _, tc := range []struct { + title string + annotations map[string]string + expectedResult map[string]string + expectedIdentifier string + }{ + { + title: "aws- provider specific annotations are set correctly", + annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/aws-annotation-1": "value 1", + SetIdentifierKey: "id1", + "external-dns.alpha.kubernetes.io/aws-annotation-2": "value 2", + }, + expectedResult: map[string]string{ + "aws/annotation-1": "value 1", + "aws/annotation-2": "value 2", + }, + expectedIdentifier: "id1", + }, + { + title: "scw- provider specific annotations are set correctly", + annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/scw-annotation-1": "value 1", + SetIdentifierKey: "id1", + "external-dns.alpha.kubernetes.io/scw-annotation-2": "value 2", + }, + expectedResult: map[string]string{ + "scw/annotation-1": "value 1", + "scw/annotation-2": "value 2", + }, + expectedIdentifier: "id1", + }, + { + title: "ibmcloud- provider specific annotations are set correctly", + annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/ibmcloud-annotation-1": "value 1", + SetIdentifierKey: "id1", + "external-dns.alpha.kubernetes.io/ibmcloud-annotation-2": "value 2", + }, + expectedResult: map[string]string{ + "ibmcloud-annotation-1": "value 1", + "ibmcloud-annotation-2": "value 2", + }, + expectedIdentifier: "id1", + }, + { + title: "webhook- provider specific annotations are set correctly", + annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/webhook-annotation-1": "value 1", + SetIdentifierKey: "id1", + "external-dns.alpha.kubernetes.io/webhook-annotation-2": "value 2", + }, + expectedResult: map[string]string{ + "webhook/annotation-1": "value 1", + "webhook/annotation-2": "value 2", + }, + expectedIdentifier: "id1", + }, + } { + t.Run(tc.title, func(t *testing.T) { + providerSpecificAnnotations, identifier := ProviderSpecificAnnotations(tc.annotations) + assert.Equal(t, tc.expectedIdentifier, identifier) + for expectedAnnotationKey, expectedAnnotationValue := range tc.expectedResult { + expectedResultFound := false + for _, providerSpecificAnnotation := range providerSpecificAnnotations { + if providerSpecificAnnotation.Name == expectedAnnotationKey { + assert.Equal(t, expectedAnnotationValue, providerSpecificAnnotation.Value) + expectedResultFound = true + break + } + } + if !expectedResultFound { + t.Errorf("provider specific annotation %s has not been set", expectedAnnotationKey) + } + } + }) + } +} diff --git a/source/compatibility.go b/source/compatibility.go index d7f4351c8..373daba0f 100644 --- a/source/compatibility.go +++ b/source/compatibility.go @@ -55,8 +55,8 @@ func legacyEndpointsFromMateService(svc *v1.Service) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint // Get the desired hostname of the service from the annotation. - hostname, exists := svc.Annotations[mateAnnotationKey] - if !exists { + hostname, ok := svc.Annotations[mateAnnotationKey] + if !ok { return nil } @@ -84,8 +84,8 @@ func legacyEndpointsFromMoleculeService(svc *v1.Service) []*endpoint.Endpoint { } // Get the desired hostname of the service from the annotation. - hostnameAnnotation, exists := svc.Annotations[moleculeAnnotationKey] - if !exists { + hostnameAnnotation, ok := svc.Annotations[moleculeAnnotationKey] + if !ok { return nil } diff --git a/source/contour_httpproxy.go b/source/contour_httpproxy.go index fb028dc64..99995a590 100644 --- a/source/contour_httpproxy.go +++ b/source/contour_httpproxy.go @@ -34,6 +34,7 @@ import ( "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) // HTTPProxySource is an implementation of Source for ProjectContour HTTPProxy objects. @@ -185,9 +186,9 @@ func (sc *httpProxySource) endpointsFromTemplate(httpProxy *projectcontour.HTTPP resource := fmt.Sprintf("HTTPProxy/%s/%s", httpProxy.Namespace, httpProxy.Name) - ttl := getTTLFromAnnotations(httpProxy.Annotations, resource) + ttl := annotations.TTLFromAnnotations(httpProxy.Annotations, resource) - targets := getTargetsFromTargetAnnotation(httpProxy.Annotations) + targets := annotations.TargetsFromTargetAnnotation(httpProxy.Annotations) if len(targets) == 0 { for _, lb := range httpProxy.Status.LoadBalancer.Ingress { if lb.IP != "" { @@ -199,7 +200,7 @@ func (sc *httpProxySource) endpointsFromTemplate(httpProxy *projectcontour.HTTPP } } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(httpProxy.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(httpProxy.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { @@ -224,14 +225,11 @@ func (sc *httpProxySource) filterByAnnotations(httpProxies []*projectcontour.HTT return httpProxies, nil } - filteredList := []*projectcontour.HTTPProxy{} + var filteredList []*projectcontour.HTTPProxy for _, httpProxy := range httpProxies { - // convert the HTTPProxy's annotations to an equivalent label selector - annotations := labels.Set(httpProxy.Annotations) - // include HTTPProxy if its annotations match the selector - if selector.Matches(annotations) { + if selector.Matches(labels.Set(httpProxy.Annotations)) { filteredList = append(filteredList, httpProxy) } } @@ -243,9 +241,9 @@ func (sc *httpProxySource) filterByAnnotations(httpProxies []*projectcontour.HTT func (sc *httpProxySource) endpointsFromHTTPProxy(httpProxy *projectcontour.HTTPProxy) ([]*endpoint.Endpoint, error) { resource := fmt.Sprintf("HTTPProxy/%s/%s", httpProxy.Namespace, httpProxy.Name) - ttl := getTTLFromAnnotations(httpProxy.Annotations, resource) + ttl := annotations.TTLFromAnnotations(httpProxy.Annotations, resource) - targets := getTargetsFromTargetAnnotation(httpProxy.Annotations) + targets := annotations.TargetsFromTargetAnnotation(httpProxy.Annotations) if len(targets) == 0 { for _, lb := range httpProxy.Status.LoadBalancer.Ingress { @@ -258,7 +256,7 @@ func (sc *httpProxySource) endpointsFromHTTPProxy(httpProxy *projectcontour.HTTP } } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(httpProxy.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(httpProxy.Annotations) var endpoints []*endpoint.Endpoint @@ -270,7 +268,7 @@ func (sc *httpProxySource) endpointsFromHTTPProxy(httpProxy *projectcontour.HTTP // Skip endpoints if we do not want entries from annotations if !sc.ignoreHostnameAnnotation { - hostnameList := getHostnamesFromAnnotations(httpProxy.Annotations) + hostnameList := annotations.HostnamesFromAnnotations(httpProxy.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } diff --git a/source/endpoints.go b/source/endpoints.go new file mode 100644 index 000000000..c1fdd4260 --- /dev/null +++ b/source/endpoints.go @@ -0,0 +1,110 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/labels" + coreinformers "k8s.io/client-go/informers/core/v1" + + "sigs.k8s.io/external-dns/endpoint" +) + +func EndpointsForHostname(hostname string, targets endpoint.Targets, ttl endpoint.TTL, providerSpecific endpoint.ProviderSpecific, setIdentifier string, resource string) []*endpoint.Endpoint { + var ( + endpoints []*endpoint.Endpoint + aTargets endpoint.Targets + aaaaTargets endpoint.Targets + cnameTargets endpoint.Targets + ) + + for _, t := range targets { + switch suitableType(t) { + case endpoint.RecordTypeA: + aTargets = append(aTargets, t) + case endpoint.RecordTypeAAAA: + aaaaTargets = append(aaaaTargets, t) + default: + cnameTargets = append(cnameTargets, t) + } + } + + if len(aTargets) > 0 { + epA := endpoint.NewEndpointWithTTL(hostname, endpoint.RecordTypeA, ttl, aTargets...) + if epA != nil { + epA.ProviderSpecific = providerSpecific + epA.SetIdentifier = setIdentifier + if resource != "" { + epA.Labels[endpoint.ResourceLabelKey] = resource + } + endpoints = append(endpoints, epA) + } + } + + if len(aaaaTargets) > 0 { + epAAAA := endpoint.NewEndpointWithTTL(hostname, endpoint.RecordTypeAAAA, ttl, aaaaTargets...) + if epAAAA != nil { + epAAAA.ProviderSpecific = providerSpecific + epAAAA.SetIdentifier = setIdentifier + if resource != "" { + epAAAA.Labels[endpoint.ResourceLabelKey] = resource + } + endpoints = append(endpoints, epAAAA) + } + } + + if len(cnameTargets) > 0 { + epCNAME := endpoint.NewEndpointWithTTL(hostname, endpoint.RecordTypeCNAME, ttl, cnameTargets...) + if epCNAME != nil { + epCNAME.ProviderSpecific = providerSpecific + epCNAME.SetIdentifier = setIdentifier + if resource != "" { + epCNAME.Labels[endpoint.ResourceLabelKey] = resource + } + endpoints = append(endpoints, epCNAME) + } + } + + return endpoints +} + +func EndpointTargetsFromServices(svcInformer coreinformers.ServiceInformer, namespace string, selector map[string]string) (endpoint.Targets, error) { + targets := endpoint.Targets{} + + services, err := svcInformer.Lister().Services(namespace).List(labels.Everything()) + if err != nil { + return nil, fmt.Errorf("failed to list labels for services in namespace %q: %w", namespace, err) + } + + for _, service := range services { + if !MatchesServiceSelector(selector, service.Spec.Selector) { + continue + } + + if len(service.Spec.ExternalIPs) > 0 { + targets = append(targets, service.Spec.ExternalIPs...) + continue + } + + for _, lb := range service.Status.LoadBalancer.Ingress { + if lb.IP != "" { + targets = append(targets, lb.IP) + } else if lb.Hostname != "" { + targets = append(targets, lb.Hostname) + } + } + } + return targets, nil +} diff --git a/source/endpoints_test.go b/source/endpoints_test.go new file mode 100644 index 000000000..b9c42340b --- /dev/null +++ b/source/endpoints_test.go @@ -0,0 +1,255 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubeinformers "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/endpoint" +) + +func TestEndpointsForHostname(t *testing.T) { + tests := []struct { + name string + hostname string + targets endpoint.Targets + ttl endpoint.TTL + providerSpecific endpoint.ProviderSpecific + setIdentifier string + resource string + expected []*endpoint.Endpoint + }{ + { + name: "A record targets", + hostname: "example.com", + targets: endpoint.Targets{"192.0.2.1", "192.0.2.2"}, + ttl: endpoint.TTL(300), + providerSpecific: endpoint.ProviderSpecific{ + {Name: "provider", Value: "value"}, + }, + setIdentifier: "identifier", + resource: "resource", + expected: []*endpoint.Endpoint{ + { + DNSName: "example.com", + Targets: endpoint.Targets{"192.0.2.1", "192.0.2.2"}, + RecordType: endpoint.RecordTypeA, + RecordTTL: endpoint.TTL(300), + ProviderSpecific: endpoint.ProviderSpecific{{Name: "provider", Value: "value"}}, + SetIdentifier: "identifier", + Labels: map[string]string{endpoint.ResourceLabelKey: "resource"}, + }, + }, + }, + { + name: "AAAA record targets", + hostname: "example.com", + targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}, + ttl: endpoint.TTL(300), + providerSpecific: endpoint.ProviderSpecific{ + {Name: "provider", Value: "value"}, + }, + setIdentifier: "identifier", + resource: "resource", + expected: []*endpoint.Endpoint{ + { + DNSName: "example.com", + Targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}, + RecordType: endpoint.RecordTypeAAAA, + RecordTTL: endpoint.TTL(300), + ProviderSpecific: endpoint.ProviderSpecific{{Name: "provider", Value: "value"}}, + SetIdentifier: "identifier", + Labels: map[string]string{endpoint.ResourceLabelKey: "resource"}, + }, + }, + }, + { + name: "CNAME record targets", + hostname: "example.com", + targets: endpoint.Targets{"cname.example.com"}, + ttl: endpoint.TTL(300), + providerSpecific: endpoint.ProviderSpecific{ + {Name: "provider", Value: "value"}, + }, + setIdentifier: "identifier", + resource: "resource", + expected: []*endpoint.Endpoint{ + { + DNSName: "example.com", + Targets: endpoint.Targets{"cname.example.com"}, + RecordType: endpoint.RecordTypeCNAME, + RecordTTL: endpoint.TTL(300), + ProviderSpecific: endpoint.ProviderSpecific{{Name: "provider", Value: "value"}}, + SetIdentifier: "identifier", + Labels: map[string]string{endpoint.ResourceLabelKey: "resource"}, + }, + }, + }, + { + name: "No targets", + hostname: "example.com", + targets: endpoint.Targets{}, + ttl: endpoint.TTL(300), + providerSpecific: endpoint.ProviderSpecific{}, + setIdentifier: "", + resource: "", + expected: []*endpoint.Endpoint(nil), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := EndpointsForHostname(tt.hostname, tt.targets, tt.ttl, tt.providerSpecific, tt.setIdentifier, tt.resource) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEndpointTargetsFromServices(t *testing.T) { + tests := []struct { + name string + services []*corev1.Service + namespace string + selector map[string]string + expected endpoint.Targets + wantErr bool + }{ + { + name: "no services", + services: []*corev1.Service{}, + namespace: "default", + selector: map[string]string{"app": "nginx"}, + expected: endpoint.Targets{}, + wantErr: false, + }, + { + name: "matching service with external IPs", + services: []*corev1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "svc1", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "nginx"}, + ExternalIPs: []string{"192.0.2.1", "158.123.32.23"}, + }, + }, + }, + namespace: "default", + selector: map[string]string{"app": "nginx"}, + expected: endpoint.Targets{"192.0.2.1", "158.123.32.23"}, + wantErr: false, + }, + { + name: "matching service with load balancer IP", + services: []*corev1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "svc2", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "nginx"}, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + {IP: "192.0.2.2"}, + }, + }, + }, + }, + }, + namespace: "default", + selector: map[string]string{"app": "nginx"}, + expected: endpoint.Targets{"192.0.2.2"}, + wantErr: false, + }, + { + name: "matching service with load balancer hostname", + services: []*corev1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "svc3", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "nginx"}, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + {Hostname: "lb.example.com"}, + }, + }, + }, + }, + }, + namespace: "default", + selector: map[string]string{"app": "nginx"}, + expected: endpoint.Targets{"lb.example.com"}, + wantErr: false, + }, + { + name: "no matching services", + services: []*corev1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "svc4", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "apache"}, + }, + }, + }, + namespace: "default", + selector: map[string]string{"app": "nginx"}, + expected: endpoint.Targets{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewSimpleClientset() + informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(client, 0, + kubeinformers.WithNamespace(tt.namespace)) + serviceInformer := informerFactory.Core().V1().Services() + + for _, svc := range tt.services { + _, err := client.CoreV1().Services(tt.namespace).Create(context.Background(), svc, metav1.CreateOptions{}) + assert.NoError(t, err) + + err = serviceInformer.Informer().GetIndexer().Add(svc) + assert.NoError(t, err) + } + + result, err := EndpointTargetsFromServices(serviceInformer, tt.namespace, tt.selector) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} diff --git a/source/f5_transportserver.go b/source/f5_transportserver.go index 1c4a7cc85..4ab0d16e7 100644 --- a/source/f5_transportserver.go +++ b/source/f5_transportserver.go @@ -38,6 +38,7 @@ import ( f5 "github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) var f5TransportServerGVR = schema.GroupVersionResource{ @@ -150,9 +151,9 @@ func (ts *f5TransportServerSource) endpointsFromTransportServers(transportServer resource := fmt.Sprintf("f5-transportserver/%s/%s", transportServer.Namespace, transportServer.Name) - ttl := getTTLFromAnnotations(transportServer.Annotations, resource) + ttl := annotations.TTLFromAnnotations(transportServer.Annotations, resource) - targets := getTargetsFromTargetAnnotation(transportServer.Annotations) + targets := annotations.TargetsFromTargetAnnotation(transportServer.Annotations) if len(targets) == 0 && transportServer.Spec.VirtualServerAddress != "" { targets = append(targets, transportServer.Spec.VirtualServerAddress) } diff --git a/source/f5_virtualserver.go b/source/f5_virtualserver.go index f4ada2f85..866394bdd 100644 --- a/source/f5_virtualserver.go +++ b/source/f5_virtualserver.go @@ -39,6 +39,7 @@ import ( f5 "github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) var f5VirtualServerGVR = schema.GroupVersionResource{ @@ -156,9 +157,9 @@ func (vs *f5VirtualServerSource) endpointsFromVirtualServers(virtualServers []*f resource := fmt.Sprintf("f5-virtualserver/%s/%s", virtualServer.Namespace, virtualServer.Name) - ttl := getTTLFromAnnotations(virtualServer.Annotations, resource) + ttl := annotations.TTLFromAnnotations(virtualServer.Annotations, resource) - targets := getTargetsFromTargetAnnotation(virtualServer.Annotations) + targets := annotations.TargetsFromTargetAnnotation(virtualServer.Annotations) if len(targets) == 0 && virtualServer.Spec.VirtualServerAddress != "" { targets = append(targets, virtualServer.Spec.VirtualServerAddress) } diff --git a/source/gateway.go b/source/gateway.go index 2a7135212..0d89eef1d 100644 --- a/source/gateway.go +++ b/source/gateway.go @@ -40,6 +40,7 @@ import ( informers_v1beta1 "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1beta1" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) const ( @@ -236,8 +237,8 @@ func (src *gatewayRouteSource) Endpoints(ctx context.Context) ([]*endpoint.Endpo // Create endpoints from hostnames and targets. var routeEndpoints []*endpoint.Endpoint resource := fmt.Sprintf("%s/%s/%s", kind, meta.Namespace, meta.Name) - providerSpecific, setIdentifier := getProviderSpecificAnnotations(annots) - ttl := getTTLFromAnnotations(annots, resource) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(annots) + ttl := annotations.TTLFromAnnotations(annots, resource) for host, targets := range hostTargets { routeEndpoints = append(routeEndpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } @@ -373,7 +374,7 @@ func (c *gatewayRouteResolver) resolve(rt gatewayRoute) (map[string]endpoint.Tar if !ok { continue } - override := getTargetsFromTargetAnnotation(gw.gateway.Annotations) + override := annotations.TargetsFromTargetAnnotation(gw.gateway.Annotations) hostTargets[host] = append(hostTargets[host], override...) if len(override) == 0 { for _, addr := range gw.gateway.Status.Addresses { @@ -403,7 +404,7 @@ func (c *gatewayRouteResolver) hosts(rt gatewayRoute) ([]string, error) { // TODO: The ignore-hostname-annotation flag help says "valid only when using fqdn-template" // but other sources don't check if fqdn-template is set. Which should it be? if !c.src.ignoreHostnameAnnotation { - hostnames = append(hostnames, getHostnamesFromAnnotations(rt.Metadata().Annotations)...) + hostnames = append(hostnames, annotations.HostnamesFromAnnotations(rt.Metadata().Annotations)...) } // TODO: The combine-fqdn-annotation flag is similarly vague. if c.src.fqdnTemplate != nil && (len(hostnames) == 0 || c.src.combineFQDNAnnotation) { diff --git a/source/gateway_httproute_test.go b/source/gateway_httproute_test.go index a09f25bc4..5343743ac 100644 --- a/source/gateway_httproute_test.go +++ b/source/gateway_httproute_test.go @@ -29,6 +29,7 @@ import ( kubefake "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" + "sigs.k8s.io/external-dns/source/annotations" v1 "sigs.k8s.io/gateway-api/apis/v1" v1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" @@ -1034,8 +1035,8 @@ func TestGatewayHTTPRouteSourceEndpoints(t *testing.T) { Name: "provider-annotations", Namespace: "default", Annotations: map[string]string{ - SetIdentifierKey: "test-set-identifier", - aliasAnnotationKey: "true", + annotations.SetIdentifierKey: "test-set-identifier", + aliasAnnotationKey: "true", }, }, Spec: v1.HTTPRouteSpec{ diff --git a/source/gloo_proxy.go b/source/gloo_proxy.go index 74754c19a..3df385066 100644 --- a/source/gloo_proxy.go +++ b/source/gloo_proxy.go @@ -30,6 +30,7 @@ import ( "k8s.io/client-go/kubernetes" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) var ( @@ -134,7 +135,7 @@ func (gs *glooSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, erro } log.Debugf("Gloo: Find %s proxy", proxy.Metadata.Name) - proxyTargets := getTargetsFromTargetAnnotation(proxy.Metadata.Annotations) + proxyTargets := annotations.TargetsFromTargetAnnotation(proxy.Metadata.Annotations) if len(proxyTargets) == 0 { proxyTargets, err = gs.proxyTargets(ctx, proxy.Metadata.Name, ns) if err != nil { @@ -161,12 +162,12 @@ func (gs *glooSource) generateEndpointsFromProxy(ctx context.Context, proxy *pro for _, listener := range proxy.Spec.Listeners { for _, virtualHost := range listener.HTTPListener.VirtualHosts { - annotations, err := gs.annotationsFromProxySource(ctx, virtualHost) + ants, err := gs.annotationsFromProxySource(ctx, virtualHost) if err != nil { return nil, err } - ttl := getTTLFromAnnotations(annotations, resource) - providerSpecific, setIdentifier := getProviderSpecificAnnotations(annotations) + ttl := annotations.TTLFromAnnotations(ants, resource) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ants) for _, domain := range virtualHost.Domains { endpoints = append(endpoints, endpointsForHostname(strings.TrimSuffix(domain, "."), targets, ttl, providerSpecific, setIdentifier, "")...) } diff --git a/source/ingress.go b/source/ingress.go index df0a39c9c..01d763d10 100644 --- a/source/ingress.go +++ b/source/ingress.go @@ -34,6 +34,7 @@ import ( "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) const ( @@ -184,14 +185,14 @@ func (sc *ingressSource) endpointsFromTemplate(ing *networkv1.Ingress) ([]*endpo resource := fmt.Sprintf("ingress/%s/%s", ing.Namespace, ing.Name) - ttl := getTTLFromAnnotations(ing.Annotations, resource) + ttl := annotations.TTLFromAnnotations(ing.Annotations, resource) - targets := getTargetsFromTargetAnnotation(ing.Annotations) + targets := annotations.TargetsFromTargetAnnotation(ing.Annotations) if len(targets) == 0 { targets = targetsFromIngressStatus(ing.Status) } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(ing.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ing.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { @@ -272,15 +273,15 @@ func (sc *ingressSource) filterByIngressClass(ingresses []*networkv1.Ingress) ([ func endpointsFromIngress(ing *networkv1.Ingress, ignoreHostnameAnnotation bool, ignoreIngressTLSSpec bool, ignoreIngressRulesSpec bool) []*endpoint.Endpoint { resource := fmt.Sprintf("ingress/%s/%s", ing.Namespace, ing.Name) - ttl := getTTLFromAnnotations(ing.Annotations, resource) + ttl := annotations.TTLFromAnnotations(ing.Annotations, resource) - targets := getTargetsFromTargetAnnotation(ing.Annotations) + targets := annotations.TargetsFromTargetAnnotation(ing.Annotations) if len(targets) == 0 { targets = targetsFromIngressStatus(ing.Status) } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(ing.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ing.Annotations) // Gather endpoints defined on hosts sections of the ingress var definedHostsEndpoints []*endpoint.Endpoint @@ -309,7 +310,7 @@ func endpointsFromIngress(ing *networkv1.Ingress, ignoreHostnameAnnotation bool, // Gather endpoints defined on annotations in the ingress var annotationEndpoints []*endpoint.Endpoint if !ignoreHostnameAnnotation { - for _, hostname := range getHostnamesFromAnnotations(ing.Annotations) { + for _, hostname := range annotations.HostnamesFromAnnotations(ing.Annotations) { annotationEndpoints = append(annotationEndpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } diff --git a/source/istio_gateway.go b/source/istio_gateway.go index d158b30a7..fb16798e4 100644 --- a/source/istio_gateway.go +++ b/source/istio_gateway.go @@ -36,6 +36,7 @@ import ( "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) // IstioGatewayIngressSource is the annotation used to determine if the gateway is implemented by an Ingress object @@ -200,11 +201,7 @@ func (sc *gatewaySource) AddEventHandler(ctx context.Context, handler func()) { // filterByAnnotations filters a list of configs by a given annotation selector. func (sc *gatewaySource) filterByAnnotations(gateways []*networkingv1alpha3.Gateway) ([]*networkingv1alpha3.Gateway, error) { - labelSelector, err := metav1.ParseToLabelSelector(sc.annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(sc.annotationFilter) if err != nil { return nil, err } @@ -217,11 +214,8 @@ func (sc *gatewaySource) filterByAnnotations(gateways []*networkingv1alpha3.Gate var filteredList []*networkingv1alpha3.Gateway for _, gw := range gateways { - // convert the annotations to an equivalent label selector - annotations := labels.Set(gw.Annotations) - // include if the annotations match the selector - if selector.Matches(annotations) { + if selector.Matches(labels.Set(gw.Annotations)) { filteredList = append(filteredList, gw) } } @@ -229,21 +223,8 @@ func (sc *gatewaySource) filterByAnnotations(gateways []*networkingv1alpha3.Gate return filteredList, nil } -func parseIngress(ingress string) (namespace, name string, err error) { - parts := strings.Split(ingress, "/") - if len(parts) == 2 { - namespace, name = parts[0], parts[1] - } else if len(parts) == 1 { - name = parts[0] - } else { - err = fmt.Errorf("invalid ingress name (name or namespace/name) found %q", ingress) - } - - return -} - func (sc *gatewaySource) targetsFromIngress(ctx context.Context, ingressStr string, gateway *networkingv1alpha3.Gateway) (targets endpoint.Targets, err error) { - namespace, name, err := parseIngress(ingressStr) + namespace, name, err := ParseIngress(ingressStr) if err != nil { return nil, fmt.Errorf("failed to parse Ingress annotation on Gateway (%s/%s): %w", gateway.Namespace, gateway.Name, err) } @@ -266,44 +247,24 @@ func (sc *gatewaySource) targetsFromIngress(ctx context.Context, ingressStr stri return } -func (sc *gatewaySource) targetsFromGateway(ctx context.Context, gateway *networkingv1alpha3.Gateway) (targets endpoint.Targets, err error) { - targets = getTargetsFromTargetAnnotation(gateway.Annotations) +func (sc *gatewaySource) targetsFromGateway(ctx context.Context, gateway *networkingv1alpha3.Gateway) (endpoint.Targets, error) { + targets := annotations.TargetsFromTargetAnnotation(gateway.Annotations) if len(targets) > 0 { - return + return targets, nil } ingressStr, ok := gateway.Annotations[IstioGatewayIngressSource] if ok && ingressStr != "" { - targets, err = sc.targetsFromIngress(ctx, ingressStr, gateway) - return + return sc.targetsFromIngress(ctx, ingressStr, gateway) } - services, err := sc.serviceInformer.Lister().Services(sc.namespace).List(labels.Everything()) + targets, err := EndpointTargetsFromServices(sc.serviceInformer, sc.namespace, gateway.Spec.Selector) + if err != nil { - log.Error(err) - return + return nil, err } - for _, service := range services { - if !gatewaySelectorMatchesServiceSelector(gateway.Spec.Selector, service.Spec.Selector) { - continue - } - - if len(service.Spec.ExternalIPs) > 0 { - targets = append(targets, service.Spec.ExternalIPs...) - continue - } - - for _, lb := range service.Status.LoadBalancer.Ingress { - if lb.IP != "" { - targets = append(targets, lb.IP) - } else if lb.Hostname != "" { - targets = append(targets, lb.Hostname) - } - } - } - - return + return targets, nil } // endpointsFromGatewayConfig extracts the endpoints from an Istio Gateway Config object @@ -313,10 +274,9 @@ func (sc *gatewaySource) endpointsFromGateway(ctx context.Context, hostnames []s resource := fmt.Sprintf("gateway/%s/%s", gateway.Namespace, gateway.Name) - annotations := gateway.Annotations - ttl := getTTLFromAnnotations(annotations, resource) + ttl := annotations.TTLFromAnnotations(gateway.Annotations, resource) - targets := getTargetsFromTargetAnnotation(annotations) + targets := annotations.TargetsFromTargetAnnotation(gateway.Annotations) if len(targets) == 0 { targets, err = sc.targetsFromGateway(ctx, gateway) if err != nil { @@ -324,7 +284,7 @@ func (sc *gatewaySource) endpointsFromGateway(ctx context.Context, hostnames []s } } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(gateway.Annotations) for _, host := range hostnames { endpoints = append(endpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) @@ -356,7 +316,7 @@ func (sc *gatewaySource) hostNamesFromGateway(gateway *networkingv1alpha3.Gatewa } if !sc.ignoreHostnameAnnotation { - hostnames = append(hostnames, getHostnamesFromAnnotations(gateway.Annotations)...) + hostnames = append(hostnames, annotations.HostnamesFromAnnotations(gateway.Annotations)...) } return hostnames, nil diff --git a/source/istio_virtualservice.go b/source/istio_virtualservice.go index 039aa6f3c..574855d04 100644 --- a/source/istio_virtualservice.go +++ b/source/istio_virtualservice.go @@ -37,6 +37,7 @@ import ( "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) // IstioMeshGateway is the built in gateway for all sidecars @@ -235,9 +236,9 @@ func (sc *virtualServiceSource) endpointsFromTemplate(ctx context.Context, virtu resource := fmt.Sprintf("virtualservice/%s/%s", virtualService.Namespace, virtualService.Name) - ttl := getTTLFromAnnotations(virtualService.Annotations, resource) + ttl := annotations.TTLFromAnnotations(virtualService.Annotations, resource) - providerSpecific, setIdentifier := getProviderSpecificAnnotations(virtualService.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(virtualService.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { @@ -252,11 +253,7 @@ func (sc *virtualServiceSource) endpointsFromTemplate(ctx context.Context, virtu // filterByAnnotations filters a list of configs by a given annotation selector. func (sc *virtualServiceSource) filterByAnnotations(virtualservices []*networkingv1alpha3.VirtualService) ([]*networkingv1alpha3.VirtualService, error) { - labelSelector, err := metav1.ParseToLabelSelector(sc.annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(sc.annotationFilter) if err != nil { return nil, err } @@ -268,13 +265,10 @@ func (sc *virtualServiceSource) filterByAnnotations(virtualservices []*networkin var filteredList []*networkingv1alpha3.VirtualService - for _, virtualservice := range virtualservices { - // convert the annotations to an equivalent label selector - annotations := labels.Set(virtualservice.Annotations) - + for _, vs := range virtualservices { // include if the annotations match the selector - if selector.Matches(annotations) { - filteredList = append(filteredList, virtualservice) + if selector.Matches(labels.Set(vs.Annotations)) { + filteredList = append(filteredList, vs) } } @@ -324,11 +318,11 @@ func (sc *virtualServiceSource) endpointsFromVirtualService(ctx context.Context, resource := fmt.Sprintf("virtualservice/%s/%s", virtualservice.Namespace, virtualservice.Name) - ttl := getTTLFromAnnotations(virtualservice.Annotations, resource) + ttl := annotations.TTLFromAnnotations(virtualservice.Annotations, resource) - targetsFromAnnotation := getTargetsFromTargetAnnotation(virtualservice.Annotations) + targetsFromAnnotation := annotations.TargetsFromTargetAnnotation(virtualservice.Annotations) - providerSpecific, setIdentifier := getProviderSpecificAnnotations(virtualservice.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(virtualservice.Annotations) for _, host := range virtualservice.Spec.Hosts { if host == "" || host == "*" { @@ -356,7 +350,7 @@ func (sc *virtualServiceSource) endpointsFromVirtualService(ctx context.Context, // Skip endpoints if we do not want entries from annotations if !sc.ignoreHostnameAnnotation { - hostnameList := getHostnamesFromAnnotations(virtualservice.Annotations) + hostnameList := annotations.HostnamesFromAnnotations(virtualservice.Annotations) for _, hostname := range hostnameList { targets := targetsFromAnnotation if len(targets) == 0 { @@ -435,7 +429,7 @@ func parseGateway(gateway string) (namespace, name string, err error) { } func (sc *virtualServiceSource) targetsFromIngress(ctx context.Context, ingressStr string, gateway *networkingv1alpha3.Gateway) (targets endpoint.Targets, err error) { - namespace, name, err := parseIngress(ingressStr) + namespace, name, err := ParseIngress(ingressStr) if err != nil { return nil, fmt.Errorf("failed to parse Ingress annotation on Gateway (%s/%s): %w", gateway.Namespace, gateway.Name, err) } @@ -459,7 +453,7 @@ func (sc *virtualServiceSource) targetsFromIngress(ctx context.Context, ingressS } func (sc *virtualServiceSource) targetsFromGateway(ctx context.Context, gateway *networkingv1alpha3.Gateway) (targets endpoint.Targets, err error) { - targets = getTargetsFromTargetAnnotation(gateway.Annotations) + targets = annotations.TargetsFromTargetAnnotation(gateway.Annotations) if len(targets) > 0 { return } diff --git a/source/kong_tcpingress.go b/source/kong_tcpingress.go index 7d81832be..b4320392c 100644 --- a/source/kong_tcpingress.go +++ b/source/kong_tcpingress.go @@ -37,6 +37,7 @@ import ( "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) var kongGroupdVersionResource = schema.GroupVersionResource{ @@ -126,7 +127,7 @@ func (sc *kongTCPIngressSource) Endpoints(ctx context.Context) ([]*endpoint.Endp var endpoints []*endpoint.Endpoint for _, tcpIngress := range tcpIngresses { - targets := getTargetsFromTargetAnnotation(tcpIngress.Annotations) + targets := annotations.TargetsFromTargetAnnotation(tcpIngress.Annotations) if len(targets) == 0 { for _, lb := range tcpIngress.Status.LoadBalancer.Ingress { if lb.IP != "" { @@ -162,11 +163,7 @@ func (sc *kongTCPIngressSource) Endpoints(ctx context.Context) ([]*endpoint.Endp // filterByAnnotations filters a list of TCPIngresses by a given annotation selector. func (sc *kongTCPIngressSource) filterByAnnotations(tcpIngresses []*TCPIngress) ([]*TCPIngress, error) { - labelSelector, err := metav1.ParseToLabelSelector(sc.annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(sc.annotationFilter) if err != nil { return nil, err } @@ -176,14 +173,11 @@ func (sc *kongTCPIngressSource) filterByAnnotations(tcpIngresses []*TCPIngress) return tcpIngresses, nil } - filteredList := []*TCPIngress{} + var filteredList []*TCPIngress for _, tcpIngress := range tcpIngresses { - // convert the TCPIngress's annotations to an equivalent label selector - annotations := labels.Set(tcpIngress.Annotations) - // include TCPIngress if its annotations match the selector - if selector.Matches(annotations) { + if selector.Matches(labels.Set(tcpIngress.Annotations)) { filteredList = append(filteredList, tcpIngress) } } @@ -197,12 +191,12 @@ func (sc *kongTCPIngressSource) endpointsFromTCPIngress(tcpIngress *TCPIngress, resource := fmt.Sprintf("tcpingress/%s/%s", tcpIngress.Namespace, tcpIngress.Name) - ttl := getTTLFromAnnotations(tcpIngress.Annotations, resource) + ttl := annotations.TTLFromAnnotations(tcpIngress.Annotations, resource) - providerSpecific, setIdentifier := getProviderSpecificAnnotations(tcpIngress.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(tcpIngress.Annotations) if !sc.ignoreHostnameAnnotation { - hostnameList := getHostnamesFromAnnotations(tcpIngress.Annotations) + hostnameList := annotations.HostnamesFromAnnotations(tcpIngress.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } diff --git a/source/node.go b/source/node.go index a0e426f17..ee4004a8a 100644 --- a/source/node.go +++ b/source/node.go @@ -23,7 +23,6 @@ import ( log "github.com/sirupsen/logrus" v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" @@ -31,6 +30,7 @@ import ( "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) const warningMsg = "The default behavior of exposing internal IPv6 addresses will change in the next minor version. Use --no-expose-internal-ipv6 flag to opt-in to the new behavior." @@ -115,7 +115,7 @@ func (ns *nodeSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, erro log.Debugf("creating endpoint for node %s", node.Name) - ttl := getTTLFromAnnotations(node.Annotations, fmt.Sprintf("node/%s", node.Name)) + ttl := annotations.TTLFromAnnotations(node.Annotations, fmt.Sprintf("node/%s", node.Name)) // create new endpoint with the information we already have ep := &endpoint.Endpoint{ @@ -138,7 +138,7 @@ func (ns *nodeSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, erro log.Debugf("not applying template for %s", node.Name) } - addrs := getTargetsFromTargetAnnotation(node.Annotations) + addrs := annotations.TargetsFromTargetAnnotation(node.Annotations) if len(addrs) == 0 { addrs, err = ns.nodeAddresses(node) if err != nil { @@ -208,11 +208,7 @@ func (ns *nodeSource) nodeAddresses(node *v1.Node) ([]string, error) { // filterByAnnotations filters a list of nodes by a given annotation selector. func (ns *nodeSource) filterByAnnotations(nodes []*v1.Node) ([]*v1.Node, error) { - labelSelector, err := metav1.ParseToLabelSelector(ns.annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(ns.annotationFilter) if err != nil { return nil, err } @@ -222,14 +218,11 @@ func (ns *nodeSource) filterByAnnotations(nodes []*v1.Node) ([]*v1.Node, error) return nodes, nil } - filteredList := []*v1.Node{} + var filteredList []*v1.Node for _, node := range nodes { - // convert the node's annotations to an equivalent label selector - annotations := labels.Set(node.Annotations) - // include node if its annotations match the selector - if selector.Matches(annotations) { + if selector.Matches(labels.Set(node.Annotations)) { filteredList = append(filteredList, node) } } diff --git a/source/openshift_route.go b/source/openshift_route.go index dd50913f6..902e4f1df 100644 --- a/source/openshift_route.go +++ b/source/openshift_route.go @@ -24,16 +24,16 @@ import ( "time" routev1 "github.com/openshift/api/route/v1" - versioned "github.com/openshift/client-go/route/clientset/versioned" + "github.com/openshift/client-go/route/clientset/versioned" extInformers "github.com/openshift/client-go/route/informers/externalversions" routeInformer "github.com/openshift/client-go/route/informers/externalversions/route/v1" log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) // ocpRouteSource is an implementation of Source for OpenShift Route objects. @@ -176,15 +176,15 @@ func (ors *ocpRouteSource) endpointsFromTemplate(ocpRoute *routev1.Route) ([]*en resource := fmt.Sprintf("route/%s/%s", ocpRoute.Namespace, ocpRoute.Name) - ttl := getTTLFromAnnotations(ocpRoute.Annotations, resource) + ttl := annotations.TTLFromAnnotations(ocpRoute.Annotations, resource) - targets := getTargetsFromTargetAnnotation(ocpRoute.Annotations) + targets := annotations.TargetsFromTargetAnnotation(ocpRoute.Annotations) if len(targets) == 0 { targetsFromRoute, _ := ors.getTargetsFromRouteStatus(ocpRoute.Status) targets = targetsFromRoute } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(ocpRoute.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ocpRoute.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { @@ -194,11 +194,7 @@ func (ors *ocpRouteSource) endpointsFromTemplate(ocpRoute *routev1.Route) ([]*en } func (ors *ocpRouteSource) filterByAnnotations(ocpRoutes []*routev1.Route) ([]*routev1.Route, error) { - labelSelector, err := metav1.ParseToLabelSelector(ors.annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(ors.annotationFilter) if err != nil { return nil, err } @@ -211,11 +207,8 @@ func (ors *ocpRouteSource) filterByAnnotations(ocpRoutes []*routev1.Route) ([]*r filteredList := []*routev1.Route{} for _, ocpRoute := range ocpRoutes { - // convert the Route's annotations to an equivalent label selector - annotations := labels.Set(ocpRoute.Annotations) - // include ocpRoute if its annotations match the selector - if selector.Matches(annotations) { + if selector.Matches(labels.Set(ocpRoute.Annotations)) { filteredList = append(filteredList, ocpRoute) } } @@ -229,16 +222,16 @@ func (ors *ocpRouteSource) endpointsFromOcpRoute(ocpRoute *routev1.Route, ignore resource := fmt.Sprintf("route/%s/%s", ocpRoute.Namespace, ocpRoute.Name) - ttl := getTTLFromAnnotations(ocpRoute.Annotations, resource) + ttl := annotations.TTLFromAnnotations(ocpRoute.Annotations, resource) - targets := getTargetsFromTargetAnnotation(ocpRoute.Annotations) + targets := annotations.TargetsFromTargetAnnotation(ocpRoute.Annotations) targetsFromRoute, host := ors.getTargetsFromRouteStatus(ocpRoute.Status) if len(targets) == 0 { targets = targetsFromRoute } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(ocpRoute.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ocpRoute.Annotations) if host != "" { endpoints = append(endpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) @@ -246,7 +239,7 @@ func (ors *ocpRouteSource) endpointsFromOcpRoute(ocpRoute *routev1.Route, ignore // Skip endpoints if we do not want entries from annotations if !ignoreHostnameAnnotation { - hostnameList := getHostnamesFromAnnotations(ocpRoute.Annotations) + hostnameList := annotations.HostnamesFromAnnotations(ocpRoute.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } diff --git a/source/pod.go b/source/pod.go index 3a34a985b..d2a74f783 100644 --- a/source/pod.go +++ b/source/pod.go @@ -28,6 +28,8 @@ import ( coreinformers "k8s.io/client-go/informers/core/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" + + "sigs.k8s.io/external-dns/source/annotations" ) type podSource struct { @@ -93,10 +95,10 @@ func (ps *podSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error continue } - targets := getTargetsFromTargetAnnotation(pod.Annotations) + targets := annotations.TargetsFromTargetAnnotation(pod.Annotations) if domainAnnotation, ok := pod.Annotations[internalHostnameAnnotationKey]; ok { - domainList := splitHostnameAnnotation(domainAnnotation) + domainList := annotations.SplitHostnameAnnotation(domainAnnotation) for _, domain := range domainList { if len(targets) == 0 { addToEndpointMap(endpointMap, domain, suitableType(pod.Status.PodIP), pod.Status.PodIP) @@ -109,7 +111,7 @@ func (ps *podSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error } if domainAnnotation, ok := pod.Annotations[hostnameAnnotationKey]; ok { - domainList := splitHostnameAnnotation(domainAnnotation) + domainList := annotations.SplitHostnameAnnotation(domainAnnotation) for _, domain := range domainList { if len(targets) == 0 { node, _ := ps.nodeInformer.Lister().Get(pod.Spec.NodeName) @@ -130,14 +132,14 @@ func (ps *podSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error if ps.compatibility == "kops-dns-controller" { if domainAnnotation, ok := pod.Annotations[kopsDNSControllerInternalHostnameAnnotationKey]; ok { - domainList := splitHostnameAnnotation(domainAnnotation) + domainList := annotations.SplitHostnameAnnotation(domainAnnotation) for _, domain := range domainList { addToEndpointMap(endpointMap, domain, suitableType(pod.Status.PodIP), pod.Status.PodIP) } } if domainAnnotation, ok := pod.Annotations[kopsDNSControllerHostnameAnnotationKey]; ok { - domainList := splitHostnameAnnotation(domainAnnotation) + domainList := annotations.SplitHostnameAnnotation(domainAnnotation) for _, domain := range domainList { node, _ := ps.nodeInformer.Lister().Get(pod.Spec.NodeName) for _, address := range node.Status.Addresses { diff --git a/source/service.go b/source/service.go index e0f38c69a..a0e615dfb 100644 --- a/source/service.go +++ b/source/service.go @@ -33,6 +33,8 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" + "sigs.k8s.io/external-dns/source/annotations" + "sigs.k8s.io/external-dns/endpoint" ) @@ -307,7 +309,7 @@ func (sc *serviceSource) extractHeadlessEndpoints(svc *v1.Service, hostname stri } for _, headlessDomain := range headlessDomains { - targets := getTargetsFromTargetAnnotation(pod.Annotations) + targets := annotations.TargetsFromTargetAnnotation(pod.Annotations) if len(targets) == 0 { if endpointsType == EndpointsTypeNodeExternalIP { node, err := sc.nodeInformer.Lister().Get(pod.Spec.NodeName) @@ -386,7 +388,7 @@ func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service) ([]*endpoint.End return nil, err } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(svc.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(svc.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { @@ -401,16 +403,16 @@ func (sc *serviceSource) endpoints(svc *v1.Service) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint // Skip endpoints if we do not want entries from annotations if !sc.ignoreHostnameAnnotation { - providerSpecific, setIdentifier := getProviderSpecificAnnotations(svc.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(svc.Annotations) var hostnameList []string var internalHostnameList []string - hostnameList = getHostnamesFromAnnotations(svc.Annotations) + hostnameList = annotations.HostnamesFromAnnotations(svc.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, providerSpecific, setIdentifier, false)...) } - internalHostnameList = getInternalHostnamesFromAnnotations(svc.Annotations) + internalHostnameList = annotations.InternalHostnamesFromAnnotations(svc.Annotations) for _, hostname := range internalHostnameList { endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, providerSpecific, setIdentifier, true)...) } @@ -434,14 +436,11 @@ func (sc *serviceSource) filterByAnnotations(services []*v1.Service) ([]*v1.Serv return services, nil } - filteredList := []*v1.Service{} + var filteredList []*v1.Service for _, service := range services { - // convert the service's annotations to an equivalent label selector - annotations := labels.Set(service.Annotations) - // include service if its annotations match the selector - if selector.Matches(annotations) { + if selector.Matches(labels.Set(service.Annotations)) { filteredList = append(filteredList, service) } } @@ -473,9 +472,9 @@ func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, pro resource := fmt.Sprintf("service/%s/%s", svc.Namespace, svc.Name) - ttl := getTTLFromAnnotations(svc.Annotations, resource) + ttl := annotations.TTLFromAnnotations(svc.Annotations, resource) - targets := getTargetsFromTargetAnnotation(svc.Annotations) + targets := annotations.TargetsFromTargetAnnotation(svc.Annotations) if len(targets) == 0 { switch svc.Spec.Type { diff --git a/source/service_test.go b/source/service_test.go index 4b199916e..5e9948abe 100644 --- a/source/service_test.go +++ b/source/service_test.go @@ -33,6 +33,7 @@ import ( "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" + "sigs.k8s.io/external-dns/source/annotations" ) type ServiceSuite struct { @@ -1264,8 +1265,8 @@ func testMultipleServicesEndpoints(t *testing.T) { map[string]string{}, "", map[string]map[string]string{ - "1.2.3.5": {hostnameAnnotationKey: "foo.example.org", SetIdentifierKey: "a"}, - "10.1.1.3": {hostnameAnnotationKey: "foo.example.org", SetIdentifierKey: "b"}, + "1.2.3.5": {hostnameAnnotationKey: "foo.example.org", annotations.SetIdentifierKey: "a"}, + "10.1.1.3": {hostnameAnnotationKey: "foo.example.org", annotations.SetIdentifierKey: "b"}, }, []string{}, []*endpoint.Endpoint{ diff --git a/source/skipper_routegroup.go b/source/skipper_routegroup.go index 943b98862..7a3db9d4f 100644 --- a/source/skipper_routegroup.go +++ b/source/skipper_routegroup.go @@ -36,6 +36,7 @@ import ( log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) const ( @@ -303,15 +304,15 @@ func (sc *routeGroupSource) endpointsFromTemplate(rg *routeGroup) ([]*endpoint.E resource := fmt.Sprintf("routegroup/%s/%s", rg.Metadata.Namespace, rg.Metadata.Name) // error handled in endpointsFromRouteGroup(), otherwise duplicate log - ttl := getTTLFromAnnotations(rg.Metadata.Annotations, resource) + ttl := annotations.TTLFromAnnotations(rg.Metadata.Annotations, resource) - targets := getTargetsFromTargetAnnotation(rg.Metadata.Annotations) + targets := annotations.TargetsFromTargetAnnotation(rg.Metadata.Annotations) if len(targets) == 0 { targets = targetsFromRouteGroupStatus(rg.Status) } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(rg.Metadata.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(rg.Metadata.Annotations) var endpoints []*endpoint.Endpoint // splits the FQDN template and removes the trailing periods @@ -329,9 +330,9 @@ func (sc *routeGroupSource) endpointsFromRouteGroup(rg *routeGroup) []*endpoint. resource := fmt.Sprintf("routegroup/%s/%s", rg.Metadata.Namespace, rg.Metadata.Name) - ttl := getTTLFromAnnotations(rg.Metadata.Annotations, resource) + ttl := annotations.TTLFromAnnotations(rg.Metadata.Annotations, resource) - targets := getTargetsFromTargetAnnotation(rg.Metadata.Annotations) + targets := annotations.TargetsFromTargetAnnotation(rg.Metadata.Annotations) if len(targets) == 0 { for _, lb := range rg.Status.LoadBalancer.RouteGroup { if lb.IP != "" { @@ -343,7 +344,7 @@ func (sc *routeGroupSource) endpointsFromRouteGroup(rg *routeGroup) []*endpoint. } } - providerSpecific, setIdentifier := getProviderSpecificAnnotations(rg.Metadata.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(rg.Metadata.Annotations) for _, src := range rg.Spec.Hosts { if src == "" { @@ -354,7 +355,7 @@ func (sc *routeGroupSource) endpointsFromRouteGroup(rg *routeGroup) []*endpoint. // Skip endpoints if we do not want entries from annotations if !sc.ignoreHostnameAnnotation { - hostnameList := getHostnamesFromAnnotations(rg.Metadata.Annotations) + hostnameList := annotations.HostnamesFromAnnotations(rg.Metadata.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } diff --git a/source/skipper_routegroup_test.go b/source/skipper_routegroup_test.go index 68f70bc48..fd733bb57 100644 --- a/source/skipper_routegroup_test.go +++ b/source/skipper_routegroup_test.go @@ -21,7 +21,6 @@ import ( "testing" "github.com/pkg/errors" - "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" ) @@ -836,53 +835,3 @@ func TestResourceLabelIsSet(t *testing.T) { } } } - -func TestParseTemplate(t *testing.T) { - for _, tt := range []struct { - name string - annotationFilter string - fqdnTemplate string - combineFQDNAndAnnotation bool - expectError bool - }{ - { - name: "invalid template", - expectError: true, - fqdnTemplate: "{{.Name", - }, - { - name: "valid empty template", - expectError: false, - }, - { - name: "valid template", - expectError: false, - fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", - }, - { - name: "valid template", - expectError: false, - fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", - }, - { - name: "valid template", - expectError: false, - fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", - combineFQDNAndAnnotation: true, - }, - { - name: "non-empty annotation filter label", - expectError: false, - annotationFilter: "kubernetes.io/ingress.class=nginx", - }, - } { - t.Run(tt.name, func(t *testing.T) { - _, err := parseTemplate(tt.fqdnTemplate) - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/source/source.go b/source/source.go index 4f0b69d42..f5808583f 100644 --- a/source/source.go +++ b/source/source.go @@ -20,68 +20,37 @@ import ( "bytes" "context" "fmt" - "math" - "net/netip" "reflect" - "strconv" "strings" "text/template" "time" "unicode" - log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) const ( - // The annotation used for figuring out which controller is responsible - controllerAnnotationKey = "external-dns.alpha.kubernetes.io/controller" - // The annotation used for defining the desired hostname - hostnameAnnotationKey = "external-dns.alpha.kubernetes.io/hostname" - // The annotation used for specifying whether the public or private interface address is used - accessAnnotationKey = "external-dns.alpha.kubernetes.io/access" - // The annotation used for specifying the type of endpoints to use for headless services - endpointsTypeAnnotationKey = "external-dns.alpha.kubernetes.io/endpoints-type" - // The annotation used for defining the desired ingress/service target - targetAnnotationKey = "external-dns.alpha.kubernetes.io/target" - // The annotation used for defining the desired DNS record TTL - ttlAnnotationKey = "external-dns.alpha.kubernetes.io/ttl" - // The annotation used for switching to the alias record types e. g. AWS Alias records instead of a normal CNAME - aliasAnnotationKey = "external-dns.alpha.kubernetes.io/alias" - // The annotation used to determine the source of hostnames for ingresses. This is an optional field - all - // available hostname sources are used if not specified. - ingressHostnameSourceKey = "external-dns.alpha.kubernetes.io/ingress-hostname-source" - // The value of the controller annotation so that we feel responsible - controllerAnnotationValue = "dns-controller" - // The annotation used for defining the desired hostname - internalHostnameAnnotationKey = "external-dns.alpha.kubernetes.io/internal-hostname" -) + controllerAnnotationKey = annotations.ControllerKey + hostnameAnnotationKey = annotations.HostnameKey + accessAnnotationKey = annotations.AccessKey + endpointsTypeAnnotationKey = annotations.EndpointsTypeKey + targetAnnotationKey = annotations.TargetKey + ttlAnnotationKey = annotations.TtlKey + aliasAnnotationKey = annotations.AliasKey + ingressHostnameSourceKey = annotations.IngressHostnameSourceKey + controllerAnnotationValue = annotations.ControllerValue + internalHostnameAnnotationKey = annotations.InternalHostnameKey -const ( EndpointsTypeNodeExternalIP = "NodeExternalIP" EndpointsTypeHostIP = "HostIP" ) -// Provider-specific annotations -const ( - // The annotation used for determining if traffic will go through Cloudflare - CloudflareProxiedKey = "external-dns.alpha.kubernetes.io/cloudflare-proxied" - CloudflareCustomHostnameKey = "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname" - CloudflareRegionKey = "external-dns.alpha.kubernetes.io/cloudflare-region-key" - - SetIdentifierKey = "external-dns.alpha.kubernetes.io/set-identifier" -) - -const ( - ttlMinimum = 1 - ttlMaximum = math.MaxInt32 -) - // Source defines the interface Endpoint sources should implement. type Source interface { Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) @@ -89,43 +58,6 @@ type Source interface { AddEventHandler(context.Context, func()) } -func getTTLFromAnnotations(annotations map[string]string, resource string) endpoint.TTL { - ttlNotConfigured := endpoint.TTL(0) - ttlAnnotation, exists := annotations[ttlAnnotationKey] - if !exists { - return ttlNotConfigured - } - ttlValue, err := parseTTL(ttlAnnotation) - if err != nil { - log.Warnf("%s: \"%v\" is not a valid TTL value: %v", resource, ttlAnnotation, err) - return ttlNotConfigured - } - if ttlValue < ttlMinimum || ttlValue > ttlMaximum { - log.Warnf("TTL value %q must be between [%d, %d]", ttlValue, ttlMinimum, ttlMaximum) - return ttlNotConfigured - } - return endpoint.TTL(ttlValue) -} - -// parseTTL parses TTL from string, returning duration in seconds. -// parseTTL supports both integers like "600" and durations based -// on Go Duration like "10m", hence "600" and "10m" represent the same value. -// -// Note: for durations like "1.5s" the fraction is omitted (resulting in 1 second -// for the example). -func parseTTL(s string) (ttlSeconds int64, err error) { - ttlDuration, errDuration := time.ParseDuration(s) - if errDuration != nil { - ttlInt, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return 0, errDuration - } - return ttlInt, nil - } - - return int64(ttlDuration.Seconds()), nil -} - type kubeObject interface { runtime.Object metav1.Object @@ -155,186 +87,17 @@ func parseTemplate(fqdnTemplate string) (tmpl *template.Template, err error) { return template.New("endpoint").Funcs(funcs).Parse(fqdnTemplate) } -func getHostnamesFromAnnotations(annotations map[string]string) []string { - hostnameAnnotation, exists := annotations[hostnameAnnotationKey] - if !exists { - return nil - } - return splitHostnameAnnotation(hostnameAnnotation) +func getAccessFromAnnotations(input map[string]string) string { + return input[accessAnnotationKey] } -func getAccessFromAnnotations(annotations map[string]string) string { - return annotations[accessAnnotationKey] -} - -func getEndpointsTypeFromAnnotations(annotations map[string]string) string { - return annotations[endpointsTypeAnnotationKey] -} - -func getInternalHostnamesFromAnnotations(annotations map[string]string) []string { - internalHostnameAnnotation, exists := annotations[internalHostnameAnnotationKey] - if !exists { - return nil - } - return splitHostnameAnnotation(internalHostnameAnnotation) -} - -func splitHostnameAnnotation(annotation string) []string { - return strings.Split(strings.ReplaceAll(annotation, " ", ""), ",") -} - -func getAliasFromAnnotations(annotations map[string]string) bool { - aliasAnnotation, exists := annotations[aliasAnnotationKey] - return exists && aliasAnnotation == "true" -} - -func getProviderSpecificAnnotations(annotations map[string]string) (endpoint.ProviderSpecific, string) { - providerSpecificAnnotations := endpoint.ProviderSpecific{} - - if v, exists := annotations[CloudflareProxiedKey]; exists { - providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ - Name: CloudflareProxiedKey, - Value: v, - }) - } - if v, exists := annotations[CloudflareCustomHostnameKey]; exists { - providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ - Name: CloudflareCustomHostnameKey, - Value: v, - }) - } - if v, exists := annotations[CloudflareRegionKey]; exists { - providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ - Name: CloudflareRegionKey, - Value: v, - }) - } - if getAliasFromAnnotations(annotations) { - providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ - Name: "alias", - Value: "true", - }) - } - setIdentifier := "" - for k, v := range annotations { - if k == SetIdentifierKey { - setIdentifier = v - } else if strings.HasPrefix(k, "external-dns.alpha.kubernetes.io/aws-") { - attr := strings.TrimPrefix(k, "external-dns.alpha.kubernetes.io/aws-") - providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ - Name: fmt.Sprintf("aws/%s", attr), - Value: v, - }) - } else if strings.HasPrefix(k, "external-dns.alpha.kubernetes.io/scw-") { - attr := strings.TrimPrefix(k, "external-dns.alpha.kubernetes.io/scw-") - providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ - Name: fmt.Sprintf("scw/%s", attr), - Value: v, - }) - } else if strings.HasPrefix(k, "external-dns.alpha.kubernetes.io/ibmcloud-") { - attr := strings.TrimPrefix(k, "external-dns.alpha.kubernetes.io/ibmcloud-") - providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ - Name: fmt.Sprintf("ibmcloud-%s", attr), - Value: v, - }) - } else if strings.HasPrefix(k, "external-dns.alpha.kubernetes.io/webhook-") { - // Support for wildcard annotations for webhook providers - attr := strings.TrimPrefix(k, "external-dns.alpha.kubernetes.io/webhook-") - providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ - Name: fmt.Sprintf("webhook/%s", attr), - Value: v, - }) - } - } - return providerSpecificAnnotations, setIdentifier -} - -// getTargetsFromTargetAnnotation gets endpoints from optional "target" annotation. -// Returns empty endpoints array if none are found. -func getTargetsFromTargetAnnotation(annotations map[string]string) endpoint.Targets { - var targets endpoint.Targets - - // Get the desired hostname of the ingress from the annotation. - targetAnnotation, exists := annotations[targetAnnotationKey] - if exists && targetAnnotation != "" { - // splits the hostname annotation and removes the trailing periods - targetsList := strings.Split(strings.ReplaceAll(targetAnnotation, " ", ""), ",") - for _, targetHostname := range targetsList { - targetHostname = strings.TrimSuffix(targetHostname, ".") - targets = append(targets, targetHostname) - } - } - return targets -} - -// suitableType returns the DNS resource record type suitable for the target. -// In this case type A/AAAA for IPs and type CNAME for everything else. -func suitableType(target string) string { - netIP, err := netip.ParseAddr(target) - if err == nil && netIP.Is4() { - return endpoint.RecordTypeA - } else if err == nil && netIP.Is6() { - return endpoint.RecordTypeAAAA - } - return endpoint.RecordTypeCNAME +func getEndpointsTypeFromAnnotations(input map[string]string) string { + return input[endpointsTypeAnnotationKey] } // endpointsForHostname returns the endpoint objects for each host-target combination. func endpointsForHostname(hostname string, targets endpoint.Targets, ttl endpoint.TTL, providerSpecific endpoint.ProviderSpecific, setIdentifier string, resource string) []*endpoint.Endpoint { - var endpoints []*endpoint.Endpoint - - var aTargets endpoint.Targets - var aaaaTargets endpoint.Targets - var cnameTargets endpoint.Targets - - for _, t := range targets { - switch suitableType(t) { - case endpoint.RecordTypeA: - aTargets = append(aTargets, t) - case endpoint.RecordTypeAAAA: - aaaaTargets = append(aaaaTargets, t) - default: - cnameTargets = append(cnameTargets, t) - } - } - - if len(aTargets) > 0 { - epA := endpoint.NewEndpointWithTTL(hostname, endpoint.RecordTypeA, ttl, aTargets...) - if epA != nil { - epA.ProviderSpecific = providerSpecific - epA.SetIdentifier = setIdentifier - if resource != "" { - epA.Labels[endpoint.ResourceLabelKey] = resource - } - endpoints = append(endpoints, epA) - } - } - - if len(aaaaTargets) > 0 { - epAAAA := endpoint.NewEndpointWithTTL(hostname, endpoint.RecordTypeAAAA, ttl, aaaaTargets...) - if epAAAA != nil { - epAAAA.ProviderSpecific = providerSpecific - epAAAA.SetIdentifier = setIdentifier - if resource != "" { - epAAAA.Labels[endpoint.ResourceLabelKey] = resource - } - endpoints = append(endpoints, epAAAA) - } - } - - if len(cnameTargets) > 0 { - epCNAME := endpoint.NewEndpointWithTTL(hostname, endpoint.RecordTypeCNAME, ttl, cnameTargets...) - if epCNAME != nil { - epCNAME.ProviderSpecific = providerSpecific - epCNAME.SetIdentifier = setIdentifier - if resource != "" { - epCNAME.Labels[endpoint.ResourceLabelKey] = resource - } - endpoints = append(endpoints, epCNAME) - } - } - - return endpoints + return EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource) } func getLabelSelector(annotationFilter string) (labels.Selector, error) { @@ -346,8 +109,7 @@ func getLabelSelector(annotationFilter string) (labels.Selector, error) { } func matchLabelSelector(selector labels.Selector, srcAnnotations map[string]string) bool { - annotations := labels.Set(srcAnnotations) - return selector.Matches(annotations) + return selector.Matches(labels.Set(srcAnnotations)) } type eventHandlerFunc func() diff --git a/source/source_test.go b/source/source_test.go index 413995a63..e92647ebc 100644 --- a/source/source_test.go +++ b/source/source_test.go @@ -17,340 +17,144 @@ limitations under the License. package source import ( - "fmt" - "strconv" + "context" + "reflect" "testing" + "time" "github.com/stretchr/testify/assert" - - "sigs.k8s.io/external-dns/endpoint" ) -func TestGetTTLFromAnnotations(t *testing.T) { - for _, tc := range []struct { - title string - annotations map[string]string - expectedTTL endpoint.TTL +func TestParseTemplate(t *testing.T) { + for _, tt := range []struct { + name string + annotationFilter string + fqdnTemplate string + combineFQDNAndAnnotation bool + expectError bool }{ { - title: "TTL annotation not present", - annotations: map[string]string{"foo": "bar"}, - expectedTTL: endpoint.TTL(0), + name: "invalid template", + expectError: true, + fqdnTemplate: "{{.Name", }, { - title: "TTL annotation value is not a number", - annotations: map[string]string{ttlAnnotationKey: "foo"}, - expectedTTL: endpoint.TTL(0), + name: "valid empty template", + expectError: false, }, { - title: "TTL annotation value is empty", - annotations: map[string]string{ttlAnnotationKey: ""}, - expectedTTL: endpoint.TTL(0), + name: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { - title: "TTL annotation value is negative number", - annotations: map[string]string{ttlAnnotationKey: "-1"}, - expectedTTL: endpoint.TTL(0), + name: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", }, { - title: "TTL annotation value is too high", - annotations: map[string]string{ttlAnnotationKey: fmt.Sprintf("%d", 1<<32)}, - expectedTTL: endpoint.TTL(0), + name: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", + combineFQDNAndAnnotation: true, }, { - title: "TTL annotation value is set correctly using integer", - annotations: map[string]string{ttlAnnotationKey: "60"}, - expectedTTL: endpoint.TTL(60), - }, - { - title: "TTL annotation value is set correctly using duration (whole)", - annotations: map[string]string{ttlAnnotationKey: "10m"}, - expectedTTL: endpoint.TTL(600), - }, - { - title: "TTL annotation value is set correctly using duration (fractional)", - annotations: map[string]string{ttlAnnotationKey: "20.5s"}, - expectedTTL: endpoint.TTL(20), + name: "non-empty annotation filter label", + expectError: false, + annotationFilter: "kubernetes.io/ingress.class=nginx", }, } { - t.Run(tc.title, func(t *testing.T) { - ttl := getTTLFromAnnotations(tc.annotations, "resource/test") - assert.Equal(t, tc.expectedTTL, ttl) + t.Run(tt.name, func(t *testing.T) { + _, err := parseTemplate(tt.fqdnTemplate) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } }) } } -func TestSuitableType(t *testing.T) { - for _, tc := range []struct { - target, recordType, expected string - }{ - {"8.8.8.8", "", "A"}, - {"2001:db8::1", "", "AAAA"}, - {"::ffff:c0a8:101", "", "AAAA"}, - {"foo.example.org", "", "CNAME"}, - {"bar.eu-central-1.elb.amazonaws.com", "", "CNAME"}, - } { - - recordType := suitableType(tc.target) - - if recordType != tc.expected { - t.Errorf("expected %s, got %s", tc.expected, recordType) - } - } -} - -func TestGetProviderSpecificCloudflareAnnotations(t *testing.T) { - for _, tc := range []struct { - title string - annotations map[string]string - expectedKey string - expectedValue bool +func TestFqdnTemplate(t *testing.T) { + tests := []struct { + name string + fqdnTemplate string + expectedError bool }{ { - title: "Cloudflare proxied annotation is set correctly to true", - annotations: map[string]string{CloudflareProxiedKey: "true"}, - expectedKey: CloudflareProxiedKey, - expectedValue: true, + name: "empty template", + fqdnTemplate: "", + expectedError: false, }, { - title: "Cloudflare proxied annotation is set correctly to false", - annotations: map[string]string{CloudflareProxiedKey: "false"}, - expectedKey: CloudflareProxiedKey, - expectedValue: false, + name: "valid template", + fqdnTemplate: "{{ .Name }}.example.com", + expectedError: false, }, - { - title: "Cloudflare proxied annotation among another annotations is set correctly to true", - annotations: map[string]string{ - "random annotation 1": "random value 1", - CloudflareProxiedKey: "false", - "random annotation 2": "random value 2", - }, - expectedKey: CloudflareProxiedKey, - expectedValue: false, - }, - } { - t.Run(tc.title, func(t *testing.T) { - providerSpecificAnnotations, _ := getProviderSpecificAnnotations(tc.annotations) - for _, providerSpecificAnnotation := range providerSpecificAnnotations { - if providerSpecificAnnotation.Name == tc.expectedKey { - assert.Equal(t, strconv.FormatBool(tc.expectedValue), providerSpecificAnnotation.Value) - return - } - } - t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %v", tc.expectedKey, tc.expectedValue) - }) } - for _, tc := range []struct { - title string - annotations map[string]string - expectedKey string - expectedValue string - }{ - { - title: "Cloudflare custom hostname annotation is set correctly", - annotations: map[string]string{CloudflareCustomHostnameKey: "a.foo.fancybar.com"}, - expectedKey: CloudflareCustomHostnameKey, - expectedValue: "a.foo.fancybar.com", - }, - { - title: "Cloudflare custom hostname annotation among another annotations is set correctly", - annotations: map[string]string{ - "random annotation 1": "random value 1", - CloudflareCustomHostnameKey: "a.foo.fancybar.com", - "random annotation 2": "random value 2"}, - expectedKey: CloudflareCustomHostnameKey, - expectedValue: "a.foo.fancybar.com", - }, - } { - t.Run(tc.title, func(t *testing.T) { - providerSpecificAnnotations, _ := getProviderSpecificAnnotations(tc.annotations) - for _, providerSpecificAnnotation := range providerSpecificAnnotations { - if providerSpecificAnnotation.Name == tc.expectedKey { - assert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value) - return - } - } - t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %s", tc.expectedKey, tc.expectedValue) - }) - } - - for _, tc := range []struct { - title string - annotations map[string]string - expectedKey string - expectedValue string - }{ - { - title: "Cloudflare region key annotation is set correctly", - annotations: map[string]string{CloudflareRegionKey: "us"}, - expectedKey: CloudflareRegionKey, - expectedValue: "us", - }, - { - title: "Cloudflare region key annotation among another annotations is set correctly", - annotations: map[string]string{ - "random annotation 1": "random value 1", - CloudflareRegionKey: "us", - "random annotation 2": "random value 2", - }, - expectedKey: CloudflareRegionKey, - expectedValue: "us", - }, - } { - t.Run(tc.title, func(t *testing.T) { - providerSpecificAnnotations, _ := getProviderSpecificAnnotations(tc.annotations) - for _, providerSpecificAnnotation := range providerSpecificAnnotations { - if providerSpecificAnnotation.Name == tc.expectedKey { - assert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value) - return - } - } - t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %v", tc.expectedKey, tc.expectedValue) - }) - } -} - -func TestGetProviderSpecificAliasAnnotations(t *testing.T) { - for _, tc := range []struct { - title string - annotations map[string]string - expectedKey string - expectedValue bool - }{ - { - title: "alias annotation is set correctly to true", - annotations: map[string]string{aliasAnnotationKey: "true"}, - expectedKey: aliasAnnotationKey, - expectedValue: true, - }, - { - title: "alias annotation among another annotations is set correctly to true", - annotations: map[string]string{ - "random annotation 1": "random value 1", - aliasAnnotationKey: "true", - "random annotation 2": "random value 2", - }, - expectedKey: aliasAnnotationKey, - expectedValue: true, - }, - } { - t.Run(tc.title, func(t *testing.T) { - providerSpecificAnnotations, _ := getProviderSpecificAnnotations(tc.annotations) - for _, providerSpecificAnnotation := range providerSpecificAnnotations { - if providerSpecificAnnotation.Name == "alias" { - assert.Equal(t, strconv.FormatBool(tc.expectedValue), providerSpecificAnnotation.Value) - return - } - } - t.Errorf("provider specific annotation alias is not set correctly to %v", tc.expectedValue) - }) - } - - for _, tc := range []struct { - title string - annotations map[string]string - }{ - { - title: "alias annotation is set to false", - annotations: map[string]string{aliasAnnotationKey: "false"}, - }, - { - title: "alias annotation is not set", - annotations: map[string]string{ - "random annotation 1": "random value 1", - "random annotation 2": "random value 2", - }, - }, - } { - t.Run(tc.title, func(t *testing.T) { - providerSpecificAnnotations, _ := getProviderSpecificAnnotations(tc.annotations) - for _, providerSpecificAnnotation := range providerSpecificAnnotations { - if providerSpecificAnnotation.Name == "alias" { - t.Error("provider specific annotation alias is not expected to be set") - } - } - - }) - } -} - -func TestGetProviderSpecificIdentifierAnnotations(t *testing.T) { - for _, tc := range []struct { - title string - annotations map[string]string - expectedResult map[string]string - expectedIdentifier string - }{ - { - title: "aws- provider specific annotations are set correctly", - annotations: map[string]string{ - "external-dns.alpha.kubernetes.io/aws-annotation-1": "value 1", - SetIdentifierKey: "id1", - "external-dns.alpha.kubernetes.io/aws-annotation-2": "value 2", - }, - expectedResult: map[string]string{ - "aws/annotation-1": "value 1", - "aws/annotation-2": "value 2", - }, - expectedIdentifier: "id1", - }, - { - title: "scw- provider specific annotations are set correctly", - annotations: map[string]string{ - "external-dns.alpha.kubernetes.io/scw-annotation-1": "value 1", - SetIdentifierKey: "id1", - "external-dns.alpha.kubernetes.io/scw-annotation-2": "value 2", - }, - expectedResult: map[string]string{ - "scw/annotation-1": "value 1", - "scw/annotation-2": "value 2", - }, - expectedIdentifier: "id1", - }, - { - title: "ibmcloud- provider specific annotations are set correctly", - annotations: map[string]string{ - "external-dns.alpha.kubernetes.io/ibmcloud-annotation-1": "value 1", - SetIdentifierKey: "id1", - "external-dns.alpha.kubernetes.io/ibmcloud-annotation-2": "value 2", - }, - expectedResult: map[string]string{ - "ibmcloud-annotation-1": "value 1", - "ibmcloud-annotation-2": "value 2", - }, - expectedIdentifier: "id1", - }, - { - title: "webhook- provider specific annotations are set correctly", - annotations: map[string]string{ - "external-dns.alpha.kubernetes.io/webhook-annotation-1": "value 1", - SetIdentifierKey: "id1", - "external-dns.alpha.kubernetes.io/webhook-annotation-2": "value 2", - }, - expectedResult: map[string]string{ - "webhook/annotation-1": "value 1", - "webhook/annotation-2": "value 2", - }, - expectedIdentifier: "id1", - }, - } { - t.Run(tc.title, func(t *testing.T) { - providerSpecificAnnotations, identifier := getProviderSpecificAnnotations(tc.annotations) - assert.Equal(t, tc.expectedIdentifier, identifier) - for expectedAnnotationKey, expectedAnnotationValue := range tc.expectedResult { - expectedResultFound := false - for _, providerSpecificAnnotation := range providerSpecificAnnotations { - if providerSpecificAnnotation.Name == expectedAnnotationKey { - assert.Equal(t, expectedAnnotationValue, providerSpecificAnnotation.Value) - expectedResultFound = true - break - } - } - if !expectedResultFound { - t.Errorf("provider specific annotation %s has not been set", expectedAnnotationKey) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl, err := parseTemplate(tt.fqdnTemplate) + if tt.expectedError { + assert.Error(t, err) + assert.Nil(t, tmpl) + } else { + assert.NoError(t, err) + if tt.fqdnTemplate == "" { + assert.Nil(t, tmpl) + } else { + assert.NotNil(t, tmpl) } } }) } } + +type mockInformerFactory struct { + syncResults map[reflect.Type]bool +} + +func (m *mockInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + return m.syncResults +} + +func TestWaitForCacheSync(t *testing.T) { + tests := []struct { + name string + syncResults map[reflect.Type]bool + expectError bool + }{ + { + name: "all caches synced", + syncResults: map[reflect.Type]bool{reflect.TypeOf(""): true}, + expectError: false, + }, + { + name: "some caches not synced", + syncResults: map[reflect.Type]bool{reflect.TypeOf(""): false}, + expectError: true, + }, + { + name: "context timeout", + syncResults: map[reflect.Type]bool{reflect.TypeOf(""): false}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + factory := &mockInformerFactory{syncResults: tt.syncResults} + err := waitForCacheSync(ctx, factory) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/source/traefik_proxy.go b/source/traefik_proxy.go index a63142ef8..595ffabec 100644 --- a/source/traefik_proxy.go +++ b/source/traefik_proxy.go @@ -38,6 +38,7 @@ import ( "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source/annotations" ) var ( @@ -255,7 +256,7 @@ func (ts *traefikSource) ingressRouteEndpoints() ([]*endpoint.Endpoint, error) { for _, ingressRoute := range ingressRoutes { var targets endpoint.Targets - targets = append(targets, getTargetsFromTargetAnnotation(ingressRoute.Annotations)...) + targets = append(targets, annotations.TargetsFromTargetAnnotation(ingressRoute.Annotations)...) fullname := fmt.Sprintf("%s/%s", ingressRoute.Namespace, ingressRoute.Name) @@ -307,7 +308,7 @@ func (ts *traefikSource) ingressRouteTCPEndpoints() ([]*endpoint.Endpoint, error for _, ingressRouteTCP := range ingressRouteTCPs { var targets endpoint.Targets - targets = append(targets, getTargetsFromTargetAnnotation(ingressRouteTCP.Annotations)...) + targets = append(targets, annotations.TargetsFromTargetAnnotation(ingressRouteTCP.Annotations)...) fullname := fmt.Sprintf("%s/%s", ingressRouteTCP.Namespace, ingressRouteTCP.Name) @@ -359,7 +360,7 @@ func (ts *traefikSource) ingressRouteUDPEndpoints() ([]*endpoint.Endpoint, error for _, ingressRouteUDP := range ingressRouteUDPs { var targets endpoint.Targets - targets = append(targets, getTargetsFromTargetAnnotation(ingressRouteUDP.Annotations)...) + targets = append(targets, annotations.TargetsFromTargetAnnotation(ingressRouteUDP.Annotations)...) fullname := fmt.Sprintf("%s/%s", ingressRouteUDP.Namespace, ingressRouteUDP.Name) @@ -411,7 +412,7 @@ func (ts *traefikSource) oldIngressRouteEndpoints() ([]*endpoint.Endpoint, error for _, ingressRoute := range ingressRoutes { var targets endpoint.Targets - targets = append(targets, getTargetsFromTargetAnnotation(ingressRoute.Annotations)...) + targets = append(targets, annotations.TargetsFromTargetAnnotation(ingressRoute.Annotations)...) fullname := fmt.Sprintf("%s/%s", ingressRoute.Namespace, ingressRoute.Name) @@ -463,7 +464,7 @@ func (ts *traefikSource) oldIngressRouteTCPEndpoints() ([]*endpoint.Endpoint, er for _, ingressRouteTCP := range ingressRouteTCPs { var targets endpoint.Targets - targets = append(targets, getTargetsFromTargetAnnotation(ingressRouteTCP.Annotations)...) + targets = append(targets, annotations.TargetsFromTargetAnnotation(ingressRouteTCP.Annotations)...) fullname := fmt.Sprintf("%s/%s", ingressRouteTCP.Namespace, ingressRouteTCP.Name) @@ -515,7 +516,7 @@ func (ts *traefikSource) oldIngressRouteUDPEndpoints() ([]*endpoint.Endpoint, er for _, ingressRouteUDP := range ingressRouteUDPs { var targets endpoint.Targets - targets = append(targets, getTargetsFromTargetAnnotation(ingressRouteUDP.Annotations)...) + targets = append(targets, annotations.TargetsFromTargetAnnotation(ingressRouteUDP.Annotations)...) fullname := fmt.Sprintf("%s/%s", ingressRouteUDP.Namespace, ingressRouteUDP.Name) @@ -537,11 +538,7 @@ func (ts *traefikSource) oldIngressRouteUDPEndpoints() ([]*endpoint.Endpoint, er // filterIngressRouteByAnnotation filters a list of IngressRoute by a given annotation selector. func (ts *traefikSource) filterIngressRouteByAnnotation(ingressRoutes []*IngressRoute) ([]*IngressRoute, error) { - labelSelector, err := metav1.ParseToLabelSelector(ts.annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(ts.annotationFilter) if err != nil { return nil, err } @@ -554,11 +551,8 @@ func (ts *traefikSource) filterIngressRouteByAnnotation(ingressRoutes []*Ingress filteredList := []*IngressRoute{} for _, ingressRoute := range ingressRoutes { - // convert the IngressRoute's annotations to an equivalent label selector - annotations := labels.Set(ingressRoute.Annotations) - // include IngressRoute if its annotations match the selector - if selector.Matches(annotations) { + if selector.Matches(labels.Set(ingressRoute.Annotations)) { filteredList = append(filteredList, ingressRoute) } } @@ -568,11 +562,7 @@ func (ts *traefikSource) filterIngressRouteByAnnotation(ingressRoutes []*Ingress // filterIngressRouteTcpByAnnotations filters a list of IngressRouteTCP by a given annotation selector. func (ts *traefikSource) filterIngressRouteTcpByAnnotations(ingressRoutes []*IngressRouteTCP) ([]*IngressRouteTCP, error) { - labelSelector, err := metav1.ParseToLabelSelector(ts.annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(ts.annotationFilter) if err != nil { return nil, err } @@ -585,11 +575,8 @@ func (ts *traefikSource) filterIngressRouteTcpByAnnotations(ingressRoutes []*Ing filteredList := []*IngressRouteTCP{} for _, ingressRoute := range ingressRoutes { - // convert the IngressRoute's annotations to an equivalent label selector - annotations := labels.Set(ingressRoute.Annotations) - // include IngressRoute if its annotations match the selector - if selector.Matches(annotations) { + if selector.Matches(labels.Set(ingressRoute.Annotations)) { filteredList = append(filteredList, ingressRoute) } } @@ -599,11 +586,7 @@ func (ts *traefikSource) filterIngressRouteTcpByAnnotations(ingressRoutes []*Ing // filterIngressRouteUdpByAnnotations filters a list of IngressRoute by a given annotation selector. func (ts *traefikSource) filterIngressRouteUdpByAnnotations(ingressRoutes []*IngressRouteUDP) ([]*IngressRouteUDP, error) { - labelSelector, err := metav1.ParseToLabelSelector(ts.annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(ts.annotationFilter) if err != nil { return nil, err } @@ -616,11 +599,8 @@ func (ts *traefikSource) filterIngressRouteUdpByAnnotations(ingressRoutes []*Ing filteredList := []*IngressRouteUDP{} for _, ingressRoute := range ingressRoutes { - // convert the IngressRoute's annotations to an equivalent label selector - annotations := labels.Set(ingressRoute.Annotations) - // include IngressRoute if its annotations match the selector - if selector.Matches(annotations) { + if selector.Matches(labels.Set(ingressRoute.Annotations)) { filteredList = append(filteredList, ingressRoute) } } @@ -634,12 +614,12 @@ func (ts *traefikSource) endpointsFromIngressRoute(ingressRoute *IngressRoute, t resource := fmt.Sprintf("ingressroute/%s/%s", ingressRoute.Namespace, ingressRoute.Name) - ttl := getTTLFromAnnotations(ingressRoute.Annotations, resource) + ttl := annotations.TTLFromAnnotations(ingressRoute.Annotations, resource) - providerSpecific, setIdentifier := getProviderSpecificAnnotations(ingressRoute.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ingressRoute.Annotations) if !ts.ignoreHostnameAnnotation { - hostnameList := getHostnamesFromAnnotations(ingressRoute.Annotations) + hostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } @@ -670,12 +650,12 @@ func (ts *traefikSource) endpointsFromIngressRouteTCP(ingressRoute *IngressRoute resource := fmt.Sprintf("ingressroutetcp/%s/%s", ingressRoute.Namespace, ingressRoute.Name) - ttl := getTTLFromAnnotations(ingressRoute.Annotations, resource) + ttl := annotations.TTLFromAnnotations(ingressRoute.Annotations, resource) - providerSpecific, setIdentifier := getProviderSpecificAnnotations(ingressRoute.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ingressRoute.Annotations) if !ts.ignoreHostnameAnnotation { - hostnameList := getHostnamesFromAnnotations(ingressRoute.Annotations) + hostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } @@ -707,12 +687,12 @@ func (ts *traefikSource) endpointsFromIngressRouteUDP(ingressRoute *IngressRoute resource := fmt.Sprintf("ingressrouteudp/%s/%s", ingressRoute.Namespace, ingressRoute.Name) - ttl := getTTLFromAnnotations(ingressRoute.Annotations, resource) + ttl := annotations.TTLFromAnnotations(ingressRoute.Annotations, resource) - providerSpecific, setIdentifier := getProviderSpecificAnnotations(ingressRoute.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ingressRoute.Annotations) if !ts.ignoreHostnameAnnotation { - hostnameList := getHostnamesFromAnnotations(ingressRoute.Annotations) + hostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } diff --git a/source/utils.go b/source/utils.go new file mode 100644 index 000000000..2dc04cf95 --- /dev/null +++ b/source/utils.go @@ -0,0 +1,67 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "fmt" + "net/netip" + "strings" + + "sigs.k8s.io/external-dns/endpoint" +) + +// suitableType returns the DNS resource record type suitable for the target. +// In this case type A/AAAA for IPs and type CNAME for everything else. +func suitableType(target string) string { + netIP, err := netip.ParseAddr(target) + if err != nil { + return endpoint.RecordTypeCNAME + } + switch { + case netIP.Is4(): + return endpoint.RecordTypeA + case netIP.Is6(): + return endpoint.RecordTypeAAAA + default: + return endpoint.RecordTypeCNAME + } +} + +// ParseIngress parses an ingress string in the format "namespace/name" or "name". +// It returns the namespace and name extracted from the string, or an error if the format is invalid. +// If the namespace is not provided, it defaults to an empty string. +func ParseIngress(ingress string) (namespace, name string, err error) { + parts := strings.Split(ingress, "/") + if len(parts) == 2 { + namespace, name = parts[0], parts[1] + } else if len(parts) == 1 { + name = parts[0] + } else { + err = fmt.Errorf("invalid ingress name (name or namespace/name) found %q", ingress) + } + + return +} + +// MatchesServiceSelector checks if all key-value pairs in the selector map +// are present and match the corresponding key-value pairs in the svcSelector map. +// It returns true if all pairs match, otherwise it returns false. +func MatchesServiceSelector(selector, svcSelector map[string]string) bool { + for k, v := range selector { + if lbl, ok := svcSelector[k]; !ok || lbl != v { + return false + } + } + return true +} diff --git a/source/utils_test.go b/source/utils_test.go new file mode 100644 index 000000000..ba6447c2e --- /dev/null +++ b/source/utils_test.go @@ -0,0 +1,151 @@ +/* +Copyright 2025 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "sigs.k8s.io/external-dns/endpoint" +) + +func TestSuitableType(t *testing.T) { + tests := []struct { + name string + target string + expected string + }{ + { + name: "valid IPv4 address", + target: "192.168.1.1", + expected: endpoint.RecordTypeA, + }, + { + name: "valid IPv6 address", + target: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + expected: endpoint.RecordTypeAAAA, + }, + { + name: "invalid IP address, should return CNAME", + target: "example.com", + expected: endpoint.RecordTypeCNAME, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := suitableType(tt.target) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseIngress(t *testing.T) { + tests := []struct { + name string + ingress string + wantNS string + wantName string + wantError bool + }{ + { + name: "valid namespace and name", + ingress: "default/test-ingress", + wantNS: "default", + wantName: "test-ingress", + wantError: false, + }, + { + name: "only name provided", + ingress: "test-ingress", + wantNS: "", + wantName: "test-ingress", + wantError: false, + }, + { + name: "invalid format", + ingress: "default/test/ingress", + wantNS: "", + wantName: "", + wantError: true, + }, + { + name: "empty string", + ingress: "", + wantNS: "", + wantName: "", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotNS, gotName, err := ParseIngress(tt.ingress) + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantNS, gotNS) + assert.Equal(t, tt.wantName, gotName) + }) + } +} + +func TestSelectorMatchesService(t *testing.T) { + tests := []struct { + name string + selector map[string]string + svcSelector map[string]string + expected bool + }{ + { + name: "all key-value pairs match", + selector: map[string]string{"app": "nginx", "env": "prod"}, + svcSelector: map[string]string{"app": "nginx", "env": "prod"}, + expected: true, + }, + { + name: "one key-value pair does not match", + selector: map[string]string{"app": "nginx", "env": "prod"}, + svcSelector: map[string]string{"app": "nginx", "env": "dev"}, + expected: false, + }, + { + name: "key not present in svcSelector", + selector: map[string]string{"app": "nginx", "env": "prod"}, + svcSelector: map[string]string{"app": "nginx"}, + expected: false, + }, + { + name: "empty selector", + selector: map[string]string{}, + svcSelector: map[string]string{"app": "nginx", "env": "prod"}, + expected: true, + }, + { + name: "empty svcSelector", + selector: map[string]string{"app": "nginx", "env": "prod"}, + svcSelector: map[string]string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MatchesServiceSelector(tt.selector, tt.svcSelector) + assert.Equal(t, tt.expected, result) + }) + } +}