chore(source/ingress): add fqdn specific tests for ingress source (#5507)

* chore(source/ingress): add fqdn specific tests for ingress source

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* chore(source/ingress): add fqdn specific tests for ingress source

Co-authored-by: vflaux <38909103+vflaux@users.noreply.github.com>

* chore(source/ingress): add fqdn specific tests for ingress source

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* chore(source/ingress): add fqdn specific tests for ingress source

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* chore(source/ingress): add fqdn specific tests for ingress source

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* chore(source/ingress): add fqdn specific tests for ingress source

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
Co-authored-by: vflaux <38909103+vflaux@users.noreply.github.com>
This commit is contained in:
Ivan Ka 2025-06-13 07:14:57 +01:00 committed by GitHub
parent 01f08ebf87
commit c6170cdace
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 437 additions and 36 deletions

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

347
source/ingress_fqdn_test.go Normal file
View File

@ -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)
})
}
}

View File

@ -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 {