Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions endpoint/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
Copyright 2026 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 endpoint

import (
log "github.com/sirupsen/logrus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

const (
msg = "No endpoints could be generated from '%s/%s/%s'"
)

// HasNoEmptyEndpoints checks if the endpoint list is empty and logs
// a debug message if so. Returns true if empty, false otherwise.
func HasNoEmptyEndpoints(
endpoints []*Endpoint,
rType string, entity metav1.ObjectMetaAccessor,
) bool {
if len(endpoints) == 0 {
log.Debugf(msg, rType, entity.GetObjectMeta().GetNamespace(), entity.GetObjectMeta().GetName())
return true
}
return false
}
88 changes: 88 additions & 0 deletions endpoint/utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
Copyright 2026 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 endpoint

import (
"testing"

"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

type mockObjectMetaAccessor struct {
namespace string
name string
}

func (m *mockObjectMetaAccessor) GetObjectMeta() metav1.Object {
return &metav1.ObjectMeta{
Namespace: m.namespace,
Name: m.name,
}
}
Comment thread
ivankatliarchuk marked this conversation as resolved.

func TestHasEmptyEndpoints(t *testing.T) {
tests := []struct {
name string
endpoints []*Endpoint
rType string
entity metav1.ObjectMetaAccessor
expected bool
}{
{
name: "nil endpoints returns true",
endpoints: nil,
rType: "Service",
entity: &mockObjectMetaAccessor{namespace: "default", name: "my-service"},
expected: true,
},
{
name: "empty slice returns true",
endpoints: []*Endpoint{},
rType: "Ingress",
entity: &mockObjectMetaAccessor{namespace: "kube-system", name: "my-ingress"},
expected: true,
},
{
name: "single endpoint returns false",
endpoints: []*Endpoint{
NewEndpoint("example.org", "A", "1.2.3.4"),
},
rType: "Service",
entity: &mockObjectMetaAccessor{namespace: "default", name: "my-service"},
expected: false,
},
{
name: "multiple endpoints returns false",
endpoints: []*Endpoint{
NewEndpoint("example.org", "A", "1.2.3.4"),
NewEndpoint("test.example.org", "CNAME", "example.org"),
},
rType: "Ingress",
entity: &mockObjectMetaAccessor{namespace: "production", name: "frontend"},
expected: false,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := HasNoEmptyEndpoints(tc.endpoints, tc.rType, tc.entity)
assert.Equal(t, tc.expected, result)
Comment thread
ivankatliarchuk marked this conversation as resolved.
// TODO: Add log capture and verification
})
}
}
5 changes: 3 additions & 2 deletions source/ambassador_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import (
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/cache"

"sigs.k8s.io/external-dns/source/types"

"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/source/annotations"
"sigs.k8s.io/external-dns/source/informers"
Expand Down Expand Up @@ -176,8 +178,7 @@ func (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endp
log.Warningf("Could not get endpoints for Host %s", err)
continue
}
if len(hostEndpoints) == 0 {
log.Debugf("No endpoints could be generated from Host %s", fullname)
if endpoint.HasNoEmptyEndpoints(hostEndpoints, types.AmbassadorHost, host) {
continue
}

Expand Down
19 changes: 19 additions & 0 deletions source/annotations/processors.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import (
"sigs.k8s.io/external-dns/endpoint"
)

const (
skipCtrlMsg = "Skipping '%s/%s/%s' because controller '%s' value does not match, found: '%s', required: '%s'"
)

func hasAliasFromAnnotations(annotations map[string]string) bool {
aliasAnnotation, ok := annotations[AliasKey]
return ok && aliasAnnotation == "true"
Expand All @@ -49,6 +53,21 @@ func TTLFromAnnotations(annotations map[string]string, resource string) endpoint
return endpoint.TTL(ttlValue)
}

// IsControllerMismatch returns true when the resource should be skipped because
// the controller annotation is present and does not match the expected controller value.
// It also logs the reason.
func IsControllerMismatch(
entity metav1.ObjectMetaAccessor,
rType string,
) bool {
value, ok := entity.GetObjectMeta().GetAnnotations()[ControllerKey]
Comment thread
ivankatliarchuk marked this conversation as resolved.
if ok && value != ControllerValue {
log.Debugf(skipCtrlMsg, rType, entity.GetObjectMeta().GetNamespace(), entity.GetObjectMeta().GetName(), ControllerKey, value, ControllerValue)
return true
}
return false
}

// 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.
Expand Down
93 changes: 93 additions & 0 deletions source/annotations/processors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,21 @@ import (
"fmt"
"testing"

log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/internal/testutils"
)

// helper implementing metav1.ObjectMetaAccessor for tests
type objectUnderTest struct {
meta metav1.ObjectMeta
}

func (t *objectUnderTest) GetObjectMeta() metav1.Object { return &t.meta }

func TestParseAnnotationFilter(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -354,3 +364,86 @@ func TestInternalHostnamesFromAnnotations(t *testing.T) {
})
}
}

func TestShouldProcessResource(t *testing.T) {
Comment thread
ivankatliarchuk marked this conversation as resolved.
Outdated
SetAnnotationPrefix(DefaultAnnotationPrefix)

tests := []struct {
name string
annotations map[string]string
entity objectUnderTest
resourceType string
debugMsg string
expected bool
}{
{
name: "no controller annotation",
entity: objectUnderTest{
meta: metav1.ObjectMeta{
Name: "my-service",
Namespace: "default",
Annotations: map[string]string{},
},
},
resourceType: "service",
expected: false,
},
{
name: "non-matching controller annotation",
entity: objectUnderTest{
meta: metav1.ObjectMeta{
Name: "my-service",
Namespace: "default",
Annotations: map[string]string{
ControllerKey: "other-controller",
},
},
},
debugMsg: fmt.Sprintf("Skipping 'service/default/my-service' because controller '%s' value does not match, found: 'other-controller', required: '%s'", ControllerKey, ControllerValue),
resourceType: "service",
expected: true,
},
{
name: "empty controller value with annotation",
entity: objectUnderTest{
meta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "kube-system",
Annotations: map[string]string{
ControllerKey: "",
},
},
},
debugMsg: fmt.Sprintf("Skipping 'ingress/kube-system/test-ingress' because controller '%s' value does not match, found: '', required: '%s'", ControllerKey, ControllerValue),
resourceType: "ingress",
expected: true,
},
{
name: "nil annotations",
entity: objectUnderTest{
meta: metav1.ObjectMeta{
Name: "service",
Namespace: "default",
Annotations: nil,
},
},
resourceType: "service",
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hook := testutils.LogsUnderTestWithLogLevel(log.DebugLevel, t)

result := IsControllerMismatch(&tt.entity, tt.resourceType)
assert.Equal(t, tt.expected, result)

if tt.debugMsg != "" {
testutils.TestHelperLogContains(tt.debugMsg, hook, t)
} else {
testutils.TestHelperLogNotContains("Skipping", hook, t)
}
})
}
}
11 changes: 4 additions & 7 deletions source/contour_httpproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import (
kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/tools/cache"

"sigs.k8s.io/external-dns/source/types"

"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/source/annotations"
"sigs.k8s.io/external-dns/source/fqdn"
Expand Down Expand Up @@ -143,11 +145,7 @@ func (sc *httpProxySource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, e
endpoints := []*endpoint.Endpoint{}

for _, hp := range httpProxies {
// Check controller annotation to see if we are responsible.
controller, ok := hp.Annotations[annotations.ControllerKey]
if ok && controller != annotations.ControllerValue {
log.Debugf("Skipping HTTPProxy %s/%s because controller value does not match, found: %s, required: %s",
hp.Namespace, hp.Name, controller, annotations.ControllerValue)
if annotations.IsControllerMismatch(hp, types.ContourHTTPProxy) {
continue
}

Expand All @@ -170,8 +168,7 @@ func (sc *httpProxySource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, e
}
}

if len(hpEndpoints) == 0 {
log.Debugf("No endpoints could be generated from HTTPProxy %s/%s", hp.Namespace, hp.Name)
if endpoint.HasNoEmptyEndpoints(hpEndpoints, types.ContourHTTPProxy, hp) {
continue
}

Expand Down
6 changes: 2 additions & 4 deletions source/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,7 @@ func (src *gatewayRouteSource) Endpoints(_ context.Context) ([]*endpoint.Endpoin
continue
}

// Check controller annotation to see if we are responsible.
if v, ok := annots[annotations.ControllerKey]; ok && v != annotations.ControllerValue {
log.Debugf("Skipping %s %s/%s because controller value does not match, found: %s, required: %s",
src.rtKind, meta.Namespace, meta.Name, v, annotations.ControllerValue)
if annotations.IsControllerMismatch(meta, src.rtKind) {
continue
}

Expand All @@ -275,6 +272,7 @@ func (src *gatewayRouteSource) Endpoints(_ context.Context) ([]*endpoint.Endpoin
if err != nil {
return nil, err
}
// TODO: does not follow the pattern of other sources to log empty hostTargets
Comment thread
ivankatliarchuk marked this conversation as resolved.
if len(hostTargets) == 0 {
log.Debugf("No endpoints could be generated from %s %s/%s", src.rtKind, meta.Namespace, meta.Name)
continue
Expand Down
10 changes: 4 additions & 6 deletions source/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import (
netinformers "k8s.io/client-go/informers/networking/v1"
"k8s.io/client-go/kubernetes"

"sigs.k8s.io/external-dns/source/types"

"sigs.k8s.io/external-dns/source/informers"

"sigs.k8s.io/external-dns/endpoint"
Expand Down Expand Up @@ -152,10 +154,7 @@ func (sc *ingressSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, err
endpoints := []*endpoint.Endpoint{}

for _, ing := range ingresses {
// Check the controller annotation to see if we are responsible.
if controller, ok := ing.Annotations[annotations.ControllerKey]; ok && controller != annotations.ControllerValue {
log.Debugf("Skipping ingress %s/%s because controller value does not match, found: %s, required: %s",
ing.Namespace, ing.Name, controller, annotations.ControllerValue)
if annotations.IsControllerMismatch(ing, types.Ingress) {
continue
}

Expand All @@ -171,8 +170,7 @@ func (sc *ingressSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, err
ingEndpoints = append(ingEndpoints, iEndpoints...)
}

if len(ingEndpoints) == 0 {
log.Debugf("No endpoints could be generated from ingress %s/%s", ing.Namespace, ing.Name)
if endpoint.HasNoEmptyEndpoints(ingEndpoints, types.Ingress, ing) {
continue
}

Expand Down
Loading
Loading