Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
d94d9ed
refactore(kubeclient): consolidate duplicate code to ensure consisten…
ivankatliarchuk Jan 2, 2026
aa272f7
refactore(kubeclient): consolidate duplicate code to ensure consisten…
ivankatliarchuk Jan 2, 2026
c75503b
refactore(kubeclient): consolidate duplicate code to ensure consisten…
ivankatliarchuk Jan 2, 2026
2aada8d
refactore(kubeclient): consolidate duplicate code to ensure consisten…
ivankatliarchuk Jan 2, 2026
416354a
Merge branch 'master' into chore-kubeconfig
ivankatliarchuk Jan 11, 2026
ae86269
refactore(kubeclient): consolidate duplicate code to ensure consisten…
ivankatliarchuk Jan 11, 2026
1dcb530
refactore(kubeclient): consolidate duplicate code to ensure consisten…
ivankatliarchuk Jan 11, 2026
2305170
refactore(kubeclient): consolidate duplicate code to ensure consisten…
ivankatliarchuk Jan 11, 2026
b8b1d54
refactore(kubeclient): consolidate duplicate code to ensure consisten…
ivankatliarchuk Jan 11, 2026
c3ead72
feat(event): add support for svc,ingress,pod,node
ivankatliarchuk Jan 11, 2026
6b5803e
feat(event): add support for svc,ingress,pod,node
ivankatliarchuk Jan 11, 2026
cd6a8f7
feat(event): add support for svc,ingress,pod,node
ivankatliarchuk Jan 11, 2026
48ea538
feat(event): add support for svc,ingress,pod,node
ivankatliarchuk Jan 11, 2026
446c968
feat(event): add support for svc,ingress,pod,node
ivankatliarchuk Jan 11, 2026
a24e3bb
Merge branch 'master' into feat-event-101
ivankatliarchuk Jan 16, 2026
fa41412
refactore(kubeclient): consolidate duplicate code to ensure consisten…
ivankatliarchuk Jan 16, 2026
d71b62e
feat(event): add support for svc,ingress,pod,node
ivankatliarchuk Jan 28, 2026
8b49efb
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Jan 28, 2026
3e1d014
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Jan 28, 2026
b47f1b0
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Jan 28, 2026
a90636d
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Jan 29, 2026
7bfba33
Merge branch 'master' into feat-event-101
ivankatliarchuk Feb 1, 2026
fb1162f
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Feb 1, 2026
cb761c8
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Feb 2, 2026
eb539f6
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Feb 7, 2026
ffe65b4
Merge branch 'master' into feat-event-101
ivankatliarchuk Feb 22, 2026
e5e72c0
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Feb 22, 2026
fd5ee56
Merge branch 'master' into feat-event-101
ivankatliarchuk Feb 26, 2026
b331409
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Feb 26, 2026
b5d494f
Merge branch 'master' into feat-event-101
ivankatliarchuk Mar 1, 2026
ec66ec3
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Mar 1, 2026
3f97957
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Mar 1, 2026
17f9754
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Mar 1, 2026
bc96c04
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Mar 1, 2026
ebcb2ad
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Mar 1, 2026
4d5beeb
refactore(source): move SuitableType to endpiont package
ivankatliarchuk Mar 12, 2026
19cf808
refactore(source): move SuitableType to endpiont package
ivankatliarchuk Mar 12, 2026
71740ed
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Mar 14, 2026
e326cde
Merge branch 'master' into feat-event-101
ivankatliarchuk Mar 15, 2026
ec136a6
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Mar 15, 2026
9133e7b
Merge branch 'kubernetes-sigs:master' into feat-event-101
ivankatliarchuk Mar 15, 2026
d5bcfac
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Mar 15, 2026
93514f5
feat(event): add support for svc,ingress,pod,node,crd
ivankatliarchuk Mar 15, 2026
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
4 changes: 2 additions & 2 deletions controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,10 @@ func (c *Controller) RunOnce(ctx context.Context) error {
if err != nil {
registryErrorsTotal.Counter.Inc()
deprecatedRegistryErrors.Counter.Inc()
emitChangeEvent(c.EventEmitter, plan.Changes, events.RecordError)
return err
} else {
emitChangeEvent(c.EventEmitter, *plan.Changes, events.RecordReady)
}
emitChangeEvent(c.EventEmitter, plan.Changes, events.RecordReady)
} else {
controllerNoChangesTotal.Counter.Inc()
log.Info("All records are already up to date")
Expand Down
52 changes: 52 additions & 0 deletions controller/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@ import (
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/internal/testutils"
"sigs.k8s.io/external-dns/pkg/apis/externaldns"
"sigs.k8s.io/external-dns/pkg/events"
"sigs.k8s.io/external-dns/pkg/events/fake"
"sigs.k8s.io/external-dns/plan"
"sigs.k8s.io/external-dns/provider"
"sigs.k8s.io/external-dns/provider/fakes"
"sigs.k8s.io/external-dns/registry"
"sigs.k8s.io/external-dns/registry/noop"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -553,3 +556,52 @@ func TestToggleRegistry(t *testing.T) {
r.failCountMu.Unlock()
assert.Equal(t, toggleRegistryFailureCount, finalCount, "failCount should be at least %d", toggleRegistryFailureCount)
}

func TestRunOnce_EmitChangeEvent(t *testing.T) {
tests := []struct {
name string
applyErr error
expectedReason events.Reason
expectErr bool
}{
{
name: "emits RecordReady on success",
expectedReason: events.RecordReady,
},
{
name: "emits RecordError on failure",
applyErr: errors.New("apply failed"),
expectedReason: events.RecordError,
expectErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
source := new(testutils.MockSource)
source.On("Endpoints").Return([]*endpoint.Endpoint{
endpoint.NewEndpoint("dot.com", endpoint.RecordTypeA, "1.2.3.4").
WithRefObject(&events.ObjectReference{}),
}, nil)

r, err := registry.SelectRegistry(getTestConfig(), &fakes.MockProvider{ApplyChangesErr: tt.applyErr})
require.NoError(t, err)

emitter := fake.NewFakeEventEmitter()
ctrl := &Controller{
Source: source,
Registry: r,
Policy: &plan.SyncPolicy{},
ManagedRecordTypes: []string{endpoint.RecordTypeA},
EventEmitter: emitter,
}

err = ctrl.RunOnce(t.Context())
assert.Equal(t, tt.expectErr, err != nil)

emitter.AssertCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool {
return e.Reason() == tt.expectedReason
}))
})
}
}
13 changes: 8 additions & 5 deletions controller/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@ import (
"sigs.k8s.io/external-dns/plan"
)

// This function emits events for each change in the provided plan.Changes object using the given EventEmitter.
// It handles create, update, and delete changes, assigning appropriate actions and reasons to each event.
// If the emitter is nil, it does nothing.
func emitChangeEvent(e events.EventEmitter, ch plan.Changes, reason events.Reason) {
// emitChangeEvent emits a Kubernetes event for each DNS record change.
// Deletes use RecordDeleted on success and RecordError on failure.
func emitChangeEvent(e events.EventEmitter, ch *plan.Changes, reason events.Reason) {
if e == nil {
return
}
Expand All @@ -34,7 +33,11 @@ func emitChangeEvent(e events.EventEmitter, ch plan.Changes, reason events.Reaso
for _, ep := range ch.UpdateNew {
e.Add(events.NewEventFromEndpoint(ep, events.ActionUpdate, reason))
}
deleteReason := events.RecordDeleted
if reason == events.RecordError {
deleteReason = events.RecordError
}
for _, ep := range ch.Delete {
e.Add(events.NewEventFromEndpoint(ep, events.ActionDelete, events.RecordDeleted))
e.Add(events.NewEventFromEndpoint(ep, events.ActionDelete, deleteReason))
}
}
63 changes: 61 additions & 2 deletions controller/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func TestEmit_RecordReady(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
emitter := fake.NewFakeEventEmitter()

emitChangeEvent(emitter, tt.changes, events.RecordReady)
emitChangeEvent(emitter, &tt.changes, events.RecordReady)

tt.asserts(emitter, tt.changes)
mock.AssertExpectationsForObjects(t, emitter)
Expand All @@ -102,6 +102,65 @@ func TestEmit_RecordReady(t *testing.T) {

func TestEmit_NilEmitter(t *testing.T) {
assert.NotPanics(t, func() {
emitChangeEvent(nil, plan.Changes{}, events.RecordError)
emitChangeEvent(nil, &plan.Changes{}, events.RecordError)
})
}

func TestEmit_RecordError(t *testing.T) {
refObj := &events.ObjectReference{}

tests := []struct {
name string
changes plan.Changes
asserts func(em *fake.EventEmitter, ch plan.Changes)
}{
{
name: "create, update and delete endpoints",
changes: plan.Changes{
Create: []*endpoint.Endpoint{
endpoint.NewEndpoint("one.example.com", endpoint.RecordTypeA, "10.10.10.0").WithRefObject(refObj),
},
UpdateNew: []*endpoint.Endpoint{
endpoint.NewEndpoint("two.example.com", endpoint.RecordTypeA, "10.10.10.1").WithRefObject(refObj),
},
Delete: []*endpoint.Endpoint{
endpoint.NewEndpoint("three.example.com", endpoint.RecordTypeA, "10.10.10.2").WithRefObject(refObj),
},
},
asserts: func(em *fake.EventEmitter, ch plan.Changes) {
em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ch.Create[0], events.ActionCreate, events.RecordError))
em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ch.UpdateNew[0], events.ActionUpdate, events.RecordError))
em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ch.Delete[0], events.ActionDelete, events.RecordError))
em.AssertNumberOfCalls(t, "Add", 3)
},
},
{
name: "delete endpoints emit RecordError not RecordDeleted",
changes: plan.Changes{
Create: []*endpoint.Endpoint{},
UpdateNew: []*endpoint.Endpoint{},
Delete: []*endpoint.Endpoint{
endpoint.NewEndpoint("five.example.com", endpoint.RecordTypeA, "192.10.10.0").WithRefObject(refObj),
},
},
asserts: func(em *fake.EventEmitter, ch plan.Changes) {
em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ch.Delete[0], events.ActionDelete, events.RecordError))
em.AssertNotCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool {
return e.Reason() == events.RecordDeleted
}))
em.AssertNumberOfCalls(t, "Add", 1)
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
emitter := fake.NewFakeEventEmitter()

emitChangeEvent(emitter, &tt.changes, events.RecordError)

tt.asserts(emitter, tt.changes)
mock.AssertExpectationsForObjects(t, emitter)
})
}
}
Comment on lines +109 to +166
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why no test for ``Delete` changes too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, looks like missed that gap. Deletes have unique behavior: the reason is overridden. On success they emit RecordDeleted (not RecordReady); on failure they emit RecordError. This is a deliberate branch that differs from Create/Update.

Restructured test, and added new sub-case to explicitly documents the contract

10 changes: 5 additions & 5 deletions docs/sources/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Sources are responsible for:
| **ambassador-host** | Host.getambassador.io | annotation,label | all,single | false | false | ingress controllers |
| **connector** | Remote TCP Server | | | false | false | special |
| **contour-httpproxy** | HTTPProxy.projectcontour.io | annotation | all,single | true | false | ingress controllers |
| **crd** | DNSEndpoint.externaldns.k8s.io | annotation,label | all,single | false | false | externaldns |
| **crd** | DNSEndpoint.externaldns.k8s.io | annotation,label | all,single | false | true | externaldns |
| **empty** | None | | | false | false | testing |
| **f5-transportserver** | TransportServer.cis.f5.com | annotation | all,single | false | false | load balancers |
| **f5-virtualserver** | VirtualServer.cis.f5.com | annotation | all,single | false | false | load balancers |
Expand All @@ -38,14 +38,14 @@ Sources are responsible for:
| **gateway-tlsroute** | TLSRoute.gateway.networking.k8s.io | annotation,label | all,single | false | false | gateway api |
| **gateway-udproute** | UDPRoute.gateway.networking.k8s.io | annotation,label | all,single | true | false | gateway api |
| **gloo-proxy** | Proxy.gloo.solo.io | | all,single | false | false | service mesh |
| **ingress** | Ingress | annotation,label | all,single | true | false | kubernetes core |
| **ingress** | Ingress | annotation,label | all,single | true | true | kubernetes core |
| **istio-gateway** | Gateway.networking.istio.io | annotation | all,single | true | false | service mesh |
| **istio-virtualservice** | VirtualService.networking.istio.io | annotation | all,single | true | false | service mesh |
| **kong-tcpingress** | TCPIngress.configuration.konghq.com | annotation | all,single | false | false | ingress controllers |
| **node** | Node | annotation,label | all | true | false | kubernetes core |
| **node** | Node | annotation,label | all | true | true | kubernetes core |
| **openshift-route** | Route.route.openshift.io | annotation,label | all,single | true | false | openshift |
| **pod** | Pod | annotation,label | all,single | true | false | kubernetes core |
| **service** | Service | annotation,label | all,single | true | false | kubernetes core |
| **pod** | Pod | annotation,label | all,single | true | true | kubernetes core |
| **service** | Service | annotation,label | all,single | true | true | kubernetes core |
| **skipper-routegroup** | RouteGroup.zalando.org | annotation | all,single | true | false | ingress controllers |
| **traefik-proxy** | IngressRoute.traefik.io<br/>IngressRouteTCP.traefik.io<br/>IngressRouteUDP.traefik.io | annotation | all,single | false | false | ingress controllers |
| **unstructured** | Unstructured | annotation,label | all,single | true | false | custom resources |
Expand Down
11 changes: 11 additions & 0 deletions endpoint/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import (

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

"sigs.k8s.io/external-dns/pkg/events"
)

const (
Expand Down Expand Up @@ -84,3 +86,12 @@ func EndpointsForHostname(hostname string, targets Targets, ttl TTL, providerSpe
}
return endpoints
}

// AttachRefObject sets the same ObjectReference on every endpoint in eps.
// The reference is shared across all endpoints, so callers should create it once
// per source object rather than once per endpoint.
func AttachRefObject(eps []*Endpoint, ref *events.ObjectReference) {
for _, ep := range eps {
ep.WithRefObject(ref)
}
}
18 changes: 16 additions & 2 deletions internal/testutils/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import (
"testing"

"github.com/stretchr/testify/assert"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"

"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/pkg/events"
)

/** test utility functions for endpoints verifications */
Expand Down Expand Up @@ -146,7 +148,7 @@ func NewTargetsFromAddr(targets []netip.Addr) endpoint.Targets {
// Usage example:
//
// endpoints := GenerateTestEndpointsByType(map[string]int{"A": 2, "CNAME": 1})
// // endpoints will contain 2 A records and 1 CNAME record with unique DNS names and targets.
// endpoints will contain 2 A records and 1 CNAME record with unique DNS names and targets.
func GenerateTestEndpointsByType(typeCounts map[string]int) []*endpoint.Endpoint {
var result []*endpoint.Endpoint
idx := 0
Expand All @@ -167,9 +169,21 @@ func GenerateTestEndpointsByType(typeCounts map[string]int) []*endpoint.Endpoint
return result
}

// NewEndpointWithRef builds an endpoint attached to a Kubernetes object reference.
// The record type is inferred from target: A for IPv4, AAAA for IPv6, CNAME otherwise.
// Kind and APIVersion are resolved from the client-go scheme, so TypeMeta need not be set on obj.
func NewEndpointWithRef(dns, target string, obj ctrlclient.Object, source string) *endpoint.Endpoint {
return endpoint.NewEndpoint(dns, endpoint.SuitableType(target), target).
WithRefObject(events.NewObjectReference(obj, source))
}

// AssertEndpointsHaveRefObject asserts that endpoints have the expected count
// and each endpoint has a non-nil RefObject with the expected source type.
func AssertEndpointsHaveRefObject(t *testing.T, endpoints []*endpoint.Endpoint, expectedSource string, expectedCount int) {
func AssertEndpointsHaveRefObject(
t *testing.T,
endpoints []*endpoint.Endpoint,
expectedSource string,
expectedCount int) {
t.Helper()
assert.Len(t, endpoints, expectedCount)
for _, ep := range endpoints {
Expand Down
2 changes: 2 additions & 0 deletions pkg/events/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ type (
reason Reason
}

// ObjectReference holds metadata about a Kubernetes object for event correlation.
// TODO: consider make fields private. Ensuring data integrity, encapsulation and immutability.
ObjectReference struct {
Kind string
ApiVersion string
Expand Down
19 changes: 18 additions & 1 deletion pkg/events/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiv1 "k8s.io/api/core/v1"
eventsv1 "k8s.io/api/events/v1"
Expand All @@ -29,6 +30,21 @@ import (
ctrlruntime "sigs.k8s.io/controller-runtime/pkg/client"
)

func TestNewObjectReference_DoesNotMutateObject(t *testing.T) {
// Verify that NewObjectReference does NOT mutate the original object
pod := &apiv1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
Namespace: "default",
},
}
podCopy := pod.DeepCopy()

_ = NewObjectReference(pod, "test")

assert.Equal(t, podCopy, pod)
}

func TestSanitize(t *testing.T) {
tests := []struct {
input string
Expand Down Expand Up @@ -329,7 +345,7 @@ func TestNewEventFromEndpoint(t *testing.T) {
},
},
{
name: "endpoint for cluster-scoped resource (Node) should handle empty namespace",
name: "endpoint for cluster-scoped resource (Node)",
ep: &mockEndpointInfo{
dnsName: "node1.example.com",
recordType: "A",
Expand All @@ -348,6 +364,7 @@ func TestNewEventFromEndpoint(t *testing.T) {
asserts: func(t *testing.T, ev Event) {
require.Equal(t, ActionCreate, ev.action)
require.Empty(t, ev.ref.Namespace)

k8sEvent := ev.event()
require.NotNil(t, k8sEvent)
require.Equal(t, "default", k8sEvent.Namespace)
Expand Down
15 changes: 9 additions & 6 deletions provider/fakes/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,23 @@ import (
"sigs.k8s.io/external-dns/plan"
)

type MockProvider struct{}
type MockProvider struct {
RecordsErr error
ApplyChangesErr error
}

func (m *MockProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {
return nil, nil
return nil, m.RecordsErr
}

func (m *MockProvider) ApplyChanges(_ context.Context, _ *plan.Changes) error {
return nil
return m.ApplyChangesErr
}

func (m *MockProvider) AdjustEndpoints(_ []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
return nil, nil
func (m *MockProvider) AdjustEndpoints(eps []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
return eps, nil
}

func (m *MockProvider) GetDomainFilter() endpoint.DomainFilterInterface {
return nil
return &endpoint.DomainFilter{}
}
6 changes: 5 additions & 1 deletion source/crd.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import (
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/tools/cache"

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

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

log "github.com/sirupsen/logrus"
Expand All @@ -52,7 +55,7 @@ import (
// +externaldns:source:filters=annotation,label
// +externaldns:source:namespace=all,single
// +externaldns:source:fqdn-template=false
// +externaldns:source:events=false
// +externaldns:source:events=true
type crdSource struct {
crdClient rest.Interface
namespace string
Expand Down Expand Up @@ -232,6 +235,7 @@ func (cs *crdSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error
crdEndpoints = append(crdEndpoints, ep)
}

endpoint.AttachRefObject(crdEndpoints, events.NewObjectReference(dnsEndpoint, types.CRD))
endpoints = append(endpoints, crdEndpoints...)

if dnsEndpoint.Status.ObservedGeneration == dnsEndpoint.Generation {
Expand Down
Loading
Loading