diff --git a/internal/testutils/helpers.go b/internal/testutils/helpers.go new file mode 100644 index 000000000..0b49ca942 --- /dev/null +++ b/internal/testutils/helpers.go @@ -0,0 +1,27 @@ +/* +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 testutils + +// ToPtr returns a pointer to the given value of any type. +// Example usage: +// +// foo := 42 +// fooPtr := ToPtr(foo) +// fmt.Println(*fooPtr) // Output: 42 +func ToPtr[T any](v T) *T { + return &v +} diff --git a/internal/testutils/helpers_test.go b/internal/testutils/helpers_test.go new file mode 100644 index 000000000..15c05bc67 --- /dev/null +++ b/internal/testutils/helpers_test.go @@ -0,0 +1,47 @@ +/* +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 testutils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringPtr(t *testing.T) { + original := "hello" + ptr := ToPtr(original) + if ptr == nil { + t.Fatal("StringPtr returned nil") + } + if *ptr != original { + t.Fatalf("expected %q, got %q", original, *ptr) + } + + // Ensure the pointer value is independent of the original variable + original = "world" + if *ptr == original { + t.Error("pointer value changed with the original variable") + } +} + +func TestIsPointer(t *testing.T) { + value := "test" + ptr := ToPtr(value) + + assert.IsType(t, *ptr, value) +} diff --git a/source/ingress.go b/source/ingress.go index 03070bc46..d3b8f8d09 100644 --- a/source/ingress.go +++ b/source/ingress.go @@ -67,7 +67,13 @@ type ingressSource struct { } // NewIngressSource creates a new ingressSource with the given config. -func NewIngressSource(ctx context.Context, kubeClient kubernetes.Interface, namespace, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool, ignoreHostnameAnnotation bool, ignoreIngressTLSSpec bool, ignoreIngressRulesSpec bool, labelSelector labels.Selector, ingressClassNames []string) (Source, error) { +func NewIngressSource( + ctx context.Context, + kubeClient kubernetes.Interface, + namespace, annotationFilter, fqdnTemplate string, + combineFqdnAnnotation, ignoreHostnameAnnotation, ignoreIngressTLSSpec, ignoreIngressRulesSpec bool, + labelSelector labels.Selector, + ingressClassNames []string) (Source, error) { tmpl, err := fqdn.ParseTemplate(fqdnTemplate) if err != nil { return nil, err @@ -126,7 +132,7 @@ func NewIngressSource(ctx context.Context, kubeClient kubernetes.Interface, name // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all ingress resources on all namespaces -func (sc *ingressSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { +func (sc *ingressSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { ingresses, err := sc.ingressInformer.Lister().Ingresses(sc.namespace).List(sc.labelSelector) if err != nil { return nil, err @@ -144,9 +150,8 @@ func (sc *ingressSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e endpoints := []*endpoint.Endpoint{} for _, ing := range ingresses { - // Check controller annotation to see if we are responsible. - controller, ok := ing.Annotations[controllerAnnotationKey] - if ok && controller != controllerAnnotationValue { + // Check the controller annotation to see if we are responsible. + if controller, ok := ing.Annotations[controllerAnnotationKey]; ok && controller != controllerAnnotationValue { log.Debugf("Skipping ingress %s/%s because controller value does not match, found: %s, required: %s", ing.Namespace, ing.Name, controller, controllerAnnotationValue) continue diff --git a/source/ingress_fqdn_test.go b/source/ingress_fqdn_test.go new file mode 100644 index 000000000..653f48e4d --- /dev/null +++ b/source/ingress_fqdn_test.go @@ -0,0 +1,347 @@ +/* +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" + "github.com/stretchr/testify/require" + "sigs.k8s.io/external-dns/internal/testutils" + + networkv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/endpoint" +) + +func TestIngressSourceNewNodeSourceWithFqdn(t *testing.T) { + for _, tt := range []struct { + title string + annotationFilter string + fqdnTemplate string + expectError bool + }{ + { + title: "invalid template", + expectError: true, + fqdnTemplate: "{{.Name", + }, + { + title: "valid empty template", + expectError: false, + }, + { + title: "valid template", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", + }, + { + title: "complex template", + expectError: false, + fqdnTemplate: "{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.ext-dns.test.com", + }, + { + title: "valid template with multiple hosts", + expectError: false, + fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", + }, + } { + t.Run(tt.title, func(t *testing.T) { + _, err := NewIngressSource( + t.Context(), + fake.NewClientset(), + "", + "", + tt.fqdnTemplate, + false, + false, + false, + false, + labels.Everything(), + []string{}, + ) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestIngressSourceFqdnTemplatingExamples(t *testing.T) { + + for _, tt := range []struct { + title string + ingresses []*networkv1.Ingress + fqdnTemplate string + expected []*endpoint.Endpoint + }{ + { + title: "templating resolve Ingress source hostnames to IP", + ingresses: []*networkv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress", + Namespace: "default", + }, + Spec: networkv1.IngressSpec{ + IngressClassName: testutils.ToPtr("my-ingress"), + Rules: []networkv1.IngressRule{ + { + Host: "example.org", + IngressRuleValue: networkv1.IngressRuleValue{ + HTTP: &networkv1.HTTPIngressRuleValue{ + Paths: []networkv1.HTTPIngressPath{ + { + Backend: networkv1.IngressBackend{ + Service: &networkv1.IngressServiceBackend{ + Name: "my-service", + Port: networkv1.ServiceBackendPort{ + Name: "http", + }, + }, + }, + PathType: testutils.ToPtr(networkv1.PathTypePrefix), + Path: "/", + }, + }, + }, + }, + }, + }, + }, + Status: networkv1.IngressStatus{ + LoadBalancer: networkv1.IngressLoadBalancerStatus{ + Ingress: []networkv1.IngressLoadBalancerIngress{ + {Hostname: "10.200.130.84.nip.io"}, + }, + }, + }, + }, + }, + fqdnTemplate: "{{.Name }}.nip.io", + expected: []*endpoint.Endpoint{ + {DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.200.130.84.nip.io"}}, + {DNSName: "my-ingress.nip.io", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.200.130.84.nip.io"}}, + }, + }, + { + title: "templating resolve hostnames with nip.io", + ingresses: []*networkv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress", + Namespace: "default", + }, + Spec: networkv1.IngressSpec{ + IngressClassName: testutils.ToPtr("my-ingress"), + Rules: []networkv1.IngressRule{ + {Host: "example.org"}, + }, + }, + Status: networkv1.IngressStatus{ + LoadBalancer: networkv1.IngressLoadBalancerStatus{ + Ingress: []networkv1.IngressLoadBalancerIngress{ + {Hostname: "10.200.130.84.nip.io"}, + }, + }, + }, + }, + }, + fqdnTemplate: `{{ range .Status.LoadBalancer.Ingress }}{{ if contains .Hostname "nip.io" }}example.org{{end}}{{end}}`, + expected: []*endpoint.Endpoint{ + {DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.200.130.84.nip.io"}}, + {DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.200.130.84.nip.io"}}, + }, + }, + { + title: "templating resolve hostnames with nip.io and target annotation", + ingresses: []*networkv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress", + Namespace: "default", + Annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/target": "10.200.130.84", + }, + }, + Spec: networkv1.IngressSpec{ + IngressClassName: testutils.ToPtr("my-ingress"), + Rules: []networkv1.IngressRule{ + {Host: "example.org"}, + }, + }, + Status: networkv1.IngressStatus{ + LoadBalancer: networkv1.IngressLoadBalancerStatus{ + Ingress: []networkv1.IngressLoadBalancerIngress{ + {Hostname: "10.200.130.84.nip.io"}, + }, + }, + }, + }, + }, + fqdnTemplate: `{{ range .Status.LoadBalancer.Ingress }}{{ if contains .Hostname "nip.io" }}tld.org{{break}}{{end}}{{end}}`, + expected: []*endpoint.Endpoint{ + {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.200.130.84"}}, + {DNSName: "tld.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.200.130.84"}}, + }, + }, + { + title: "templating resolve hostnames with nip.io and status IP", + ingresses: []*networkv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress", + Namespace: "default", + }, + Spec: networkv1.IngressSpec{ + IngressClassName: testutils.ToPtr("my-ingress"), + Rules: []networkv1.IngressRule{ + { + Host: "example.org", + }, + }, + }, + Status: networkv1.IngressStatus{ + LoadBalancer: networkv1.IngressLoadBalancerStatus{ + Ingress: []networkv1.IngressLoadBalancerIngress{ + { + IP: "10.200.130.84", + }, + }, + }, + }, + }, + }, + fqdnTemplate: "nip.io", + expected: []*endpoint.Endpoint{ + {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.200.130.84"}}, + {DNSName: "nip.io", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.200.130.84"}}, + }, + }, + { + title: "templating resolve with different hostnames and rules", + ingresses: []*networkv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress", + Namespace: "default", + }, + Spec: networkv1.IngressSpec{ + IngressClassName: testutils.ToPtr("ingress-with-override"), + Rules: []networkv1.IngressRule{ + {Host: "foo.bar.com"}, + {Host: "bar.bar.com"}, + {Host: "bar.baz.com"}, + }, + }, + Status: networkv1.IngressStatus{ + LoadBalancer: networkv1.IngressLoadBalancerStatus{ + Ingress: []networkv1.IngressLoadBalancerIngress{ + {IP: "192.16.15.25"}, + {Hostname: "abc.org"}, + }, + }, + }, + }, + }, + fqdnTemplate: `{{ range .Spec.Rules }}{{ if contains .Host "bar.com" }}{{ .Host }}.internal{{break}}{{end}}{{end}}`, + expected: []*endpoint.Endpoint{ + {DNSName: "foo.bar.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.16.15.25"}}, + {DNSName: "foo.bar.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"abc.org"}}, + {DNSName: "bar.bar.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.16.15.25"}}, + {DNSName: "bar.bar.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"abc.org"}}, + {DNSName: "bar.baz.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.16.15.25"}}, + {DNSName: "bar.baz.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"abc.org"}}, + {DNSName: "foo.bar.com.internal", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.16.15.25"}}, + {DNSName: "foo.bar.com.internal", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"abc.org"}}, + }, + }, + { + title: "templating resolve with rules and tls", + ingresses: []*networkv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ingress", + Namespace: "default", + }, + Spec: networkv1.IngressSpec{ + IngressClassName: testutils.ToPtr("ingress-with-override"), + Rules: []networkv1.IngressRule{ + { + Host: "foo.bar.com", + }, + }, + TLS: []networkv1.IngressTLS{ + { + Hosts: []string{"https-example.foo.com", "https-example.bar.com"}, + }, + }, + }, + Status: networkv1.IngressStatus{ + LoadBalancer: networkv1.IngressLoadBalancerStatus{ + Ingress: []networkv1.IngressLoadBalancerIngress{ + { + IP: "10.09.15.25", + }, + }, + }, + }, + }, + }, + fqdnTemplate: `{{ .Name }}.test.org,{{ range .Spec.TLS }}{{ range $value := .Hosts }}{{ $value | replace "." "-" }}.internal{{break}}{{end}}{{end}}`, + expected: []*endpoint.Endpoint{ + {DNSName: "foo.bar.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, + {DNSName: "https-example.foo.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, + {DNSName: "https-example.bar.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, + {DNSName: "my-ingress.test.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, + {DNSName: "https-example-foo-com.internal", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, + }, + }, + } { + t.Run(tt.title, func(t *testing.T) { + kubeClient := fake.NewClientset() + + for _, el := range tt.ingresses { + _, err := kubeClient.NetworkingV1().Ingresses(el.Namespace).Create(t.Context(), el, metav1.CreateOptions{}) + require.NoError(t, err) + } + + src, err := NewIngressSource( + t.Context(), + kubeClient, + "", + "", + tt.fqdnTemplate, + true, + false, + false, + false, + labels.Everything(), + []string{}, + ) + + require.NoError(t, err) + + endpoints, err := src.Endpoints(t.Context()) + require.NoError(t, err) + + validateEndpoints(t, endpoints, tt.expected) + }) + } +} diff --git a/source/ingress_test.go b/source/ingress_test.go index 6fd9a1703..6452383e2 100644 --- a/source/ingress_test.go +++ b/source/ingress_test.go @@ -41,7 +41,7 @@ type IngressSuite struct { } func (suite *IngressSuite) SetupTest() { - fakeClient := fake.NewSimpleClientset() + fakeClient := fake.NewClientset() suite.fooWithTargets = (fakeIngress{ name: "foo-with-targets", @@ -96,31 +96,6 @@ func TestNewIngressSource(t *testing.T) { expectError bool ingressClassNames []string }{ - { - title: "invalid template", - expectError: true, - fqdnTemplate: "{{.Name", - }, - { - title: "valid empty template", - expectError: false, - }, - { - title: "valid template", - expectError: false, - fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", - }, - { - title: "valid template", - expectError: false, - fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", - }, - { - title: "valid template", - expectError: false, - fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", - combineFQDNAndAnnotation: true, - }, { title: "non-empty annotation filter label", expectError: false, @@ -143,8 +118,8 @@ func TestNewIngressSource(t *testing.T) { t.Parallel() _, err := NewIngressSource( - context.TODO(), - fake.NewSimpleClientset(), + t.Context(), + fake.NewClientset(), "", ti.annotationFilter, ti.fqdnTemplate, @@ -1428,10 +1403,10 @@ func testIngressEndpoints(t *testing.T) { t.Run(ti.title, func(t *testing.T) { t.Parallel() - fakeClient := fake.NewSimpleClientset() + fakeClient := fake.NewClientset() for _, item := range ti.ingressItems { ingress := item.Ingress() - _, err := fakeClient.NetworkingV1().Ingresses(ingress.Namespace).Create(context.Background(), ingress, metav1.CreateOptions{}) + _, err := fakeClient.NetworkingV1().Ingresses(ingress.Namespace).Create(t.Context(), ingress, metav1.CreateOptions{}) require.NoError(t, err) } @@ -1453,7 +1428,7 @@ func testIngressEndpoints(t *testing.T) { ti.ingressClassNames, ) // Informer cache has all of the ingresses. Retrieve and validate their endpoints. - res, err := source.Endpoints(context.Background()) + res, err := source.Endpoints(t.Context()) if ti.expectError { require.Error(t, err) } else {