From 5d772c590f1348dff7fb86c91b095fd284a389a0 Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Sun, 11 Jan 2026 15:54:52 +0000 Subject: [PATCH 1/5] feat(event): standardize event messages and add Kind lookup Signed-off-by: ivan katliarchuk --- controller/events.go | 12 +- controller/events_test.go | 6 +- endpoint/endpoint.go | 29 ++++ pkg/events/types.go | 50 ++++++- pkg/events/types_test.go | 299 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 384 insertions(+), 12 deletions(-) diff --git a/controller/events.go b/controller/events.go index 79b43c762f..efb304652a 100644 --- a/controller/events.go +++ b/controller/events.go @@ -28,13 +28,13 @@ func emitChangeEvent(e events.EventEmitter, ch plan.Changes, reason events.Reaso if e == nil { return } - for _, change := range ch.Create { - e.Add(events.NewEvent(change.RefObject(), change.Describe(), events.ActionCreate, reason)) + for _, ep := range ch.Create { + e.Add(events.NewEventFromEndpoint(ep, events.ActionCreate, reason)) } - for _, change := range ch.UpdateNew { - e.Add(events.NewEvent(change.RefObject(), change.Describe(), events.ActionUpdate, reason)) + for _, ep := range ch.UpdateNew { + e.Add(events.NewEventFromEndpoint(ep, events.ActionUpdate, reason)) } - for _, change := range ch.Delete { - e.Add(events.NewEvent(change.RefObject(), change.Describe(), events.ActionDelete, events.RecordDeleted)) + for _, ep := range ch.Delete { + e.Add(events.NewEventFromEndpoint(ep, events.ActionDelete, events.RecordDeleted)) } } diff --git a/controller/events_test.go b/controller/events_test.go index 3f31842ab1..d6b5e78e8b 100644 --- a/controller/events_test.go +++ b/controller/events_test.go @@ -53,10 +53,10 @@ func TestEmit_RecordReady(t *testing.T) { }, asserts: func(em *fake.EventEmitter, ch plan.Changes) { for _, ep := range ch.Create { - em.AssertCalled(t, "Add", events.NewEvent(ep.RefObject(), ep.Describe(), events.ActionCreate, events.RecordReady)) + em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ep, events.ActionCreate, events.RecordReady)) } for _, ep := range ch.Delete { - em.AssertCalled(t, "Add", events.NewEvent(ep.RefObject(), ep.Describe(), events.ActionDelete, events.RecordDeleted)) + em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ep, events.ActionDelete, events.RecordDeleted)) } em.AssertNotCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool { return e.EventType() == events.EventTypeWarning @@ -75,7 +75,7 @@ func TestEmit_RecordReady(t *testing.T) { }, asserts: func(em *fake.EventEmitter, ch plan.Changes) { for _, ep := range ch.Delete { - em.AssertCalled(t, "Add", events.NewEvent(ep.RefObject(), ep.Describe(), events.ActionDelete, events.RecordDeleted)) + em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ep, events.ActionDelete, events.RecordDeleted)) } em.AssertCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool { return e.EventType() == events.EventTypeNormal && diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index 11870b3fc4..db88197a3f 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -245,6 +245,7 @@ type Endpoint struct { // +optional ProviderSpecific ProviderSpecific `json:"providerSpecific,omitempty"` // refObject stores reference object + // TODO: should be an array, as endpoints merged from multiple sources may have multiple owners // +optional refObject *ObjectRef `json:"-"` } @@ -524,3 +525,31 @@ func (t Targets) ValidateSRVRecord() bool { } return true } + +// GetDNSName returns the DNS name of the endpoint. +func (e *Endpoint) GetDNSName() string { + return e.DNSName +} + +// GetRecordType returns the record type of the endpoint. +func (e *Endpoint) GetRecordType() string { + return e.RecordType +} + +// GetRecordTTL returns the TTL of the endpoint as int64. +func (e *Endpoint) GetRecordTTL() int64 { + return int64(e.RecordTTL) +} + +// GetTargets returns the targets of the endpoint. +func (e *Endpoint) GetTargets() []string { + return e.Targets +} + +// GetOwner returns the owner of the endpoint from labels or set identifier. +func (e *Endpoint) GetOwner() string { + if val, ok := e.Labels[OwnerLabelKey]; ok { + return val + } + return e.SetIdentifier +} diff --git a/pkg/events/types.go b/pkg/events/types.go index 9f045b1622..fe5636dbbf 100644 --- a/pkg/events/types.go +++ b/pkg/events/types.go @@ -18,6 +18,7 @@ package events import ( "fmt" + "reflect" "regexp" "slices" "strings" @@ -27,8 +28,10 @@ import ( apiv1 "k8s.io/api/core/v1" eventsv1 "k8s.io/api/events/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/kubernetes/scheme" runtime "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -85,12 +88,35 @@ type ( emitEvents sets.Set[Reason] dryRun bool } + + // EndpointInfo defines the interface for endpoint data needed to create events. + // This avoids circular imports between endpoint and events packages. + EndpointInfo interface { + GetDNSName() string + GetRecordType() string + GetRecordTTL() int64 + GetTargets() []string + GetOwner() string + RefObject() *ObjectReference + } ) func NewObjectReference(obj runtime.Object, source string) *ObjectReference { + // Kubernetes API doesn't populate TypeMeta (Kind/APIVersion) when retrieving + // objects via informers. Look up the Kind from the scheme without mutating the object. + gvk := obj.GetObjectKind().GroupVersionKind() + if gvk.Kind == "" { + gvks, _, err := scheme.Scheme.ObjectKinds(obj) + if err == nil && len(gvks) > 0 { + gvk = gvks[0] + } else { + // Fallback to reflection for types not in scheme + gvk = schema.GroupVersionKind{Kind: reflect.TypeOf(obj).Elem().Name()} + } + } return &ObjectReference{ - Kind: obj.GetObjectKind().GroupVersionKind().Kind, - ApiVersion: obj.GetObjectKind().GroupVersionKind().GroupVersion().String(), + Kind: gvk.Kind, + ApiVersion: gvk.GroupVersion().String(), Namespace: obj.GetNamespace(), Name: obj.GetName(), UID: obj.GetUID(), @@ -112,6 +138,17 @@ func NewEvent(obj *ObjectReference, msg string, a Action, r Reason) Event { } } +// NewEventFromEndpoint creates an Event from an EndpointInfo with formatted message. +func NewEventFromEndpoint(ep EndpointInfo, a Action, r Reason) Event { + if ep == nil || ep.RefObject() == nil { + return Event{} + } + msg := fmt.Sprintf("(external-dns) record:%s,owner:%s,type:%s,ttl:%d,targets:%s", + ep.GetDNSName(), ep.GetOwner(), ep.GetRecordType(), ep.GetRecordTTL(), + strings.Join(ep.GetTargets(), ",")) + return NewEvent(ep.RefObject(), msg, a, r) +} + func (e *Event) description() string { return fmt.Sprintf("%s/%s/%s", e.ref.Kind, e.ref.Namespace, e.ref.Name) } @@ -141,10 +178,17 @@ func (e *Event) event() *eventsv1.Event { timestamp := metav1.MicroTime{Time: time.Now()} + // Events are namespaced resources. For cluster-scoped objects like Nodes, + // the namespace is empty, so we default to "default" namespace. + namespace := e.ref.Namespace + if namespace == "" { + namespace = "default" + } + event := &eventsv1.Event{ ObjectMeta: metav1.ObjectMeta{ Name: sanitize(e.ref.Name), - Namespace: e.ref.Namespace, + Namespace: namespace, }, EventTime: timestamp, ReportingInstance: controllerName + "/source/" + e.ref.Source, diff --git a/pkg/events/types_test.go b/pkg/events/types_test.go index 66c7b44de7..0dcf20a70b 100644 --- a/pkg/events/types_test.go +++ b/pkg/events/types_test.go @@ -25,7 +25,9 @@ import ( apiv1 "k8s.io/api/core/v1" eventsv1 "k8s.io/api/events/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" + ctrlruntime "sigs.k8s.io/controller-runtime/pkg/client" ) func TestSanitize(t *testing.T) { @@ -233,3 +235,300 @@ func TestWithKubeConfig(t *testing.T) { require.Equal(t, apiServerURL, cfg.apiServerURL) require.Equal(t, timeout, cfg.timeout) } + +// mockEndpointInfo implements EndpointInfo for testing +type mockEndpointInfo struct { + dnsName string + recordType string + recordTTL int64 + targets []string + owner string + refObject *ObjectReference +} + +func (m *mockEndpointInfo) GetDNSName() string { return m.dnsName } +func (m *mockEndpointInfo) GetRecordType() string { return m.recordType } +func (m *mockEndpointInfo) GetRecordTTL() int64 { return m.recordTTL } +func (m *mockEndpointInfo) GetTargets() []string { return m.targets } +func (m *mockEndpointInfo) GetOwner() string { return m.owner } +func (m *mockEndpointInfo) RefObject() *ObjectReference { return m.refObject } + +func TestNewEventFromEndpoint(t *testing.T) { + tests := []struct { + name string + ep EndpointInfo + action Action + reason Reason + asserts func(t *testing.T, ev Event) + }{ + { + name: "nil endpoint returns empty event", + ep: nil, + action: ActionCreate, + reason: RecordReady, + asserts: func(t *testing.T, ev Event) { + require.Equal(t, Event{}, ev) + }, + }, + { + name: "endpoint with nil RefObject returns empty event", + ep: &mockEndpointInfo{ + dnsName: "example.com", + recordType: "A", + recordTTL: 300, + targets: []string{"10.0.0.1"}, + owner: "default", + refObject: nil, + }, + action: ActionCreate, + reason: RecordReady, + asserts: func(t *testing.T, ev Event) { + require.Equal(t, Event{}, ev) + }, + }, + { + name: "valid endpoint with create action", + ep: &mockEndpointInfo{ + dnsName: "test.example.com", + recordType: "A", + recordTTL: 300, + targets: []string{"10.0.0.1", "10.0.0.2"}, + owner: "my-owner", + refObject: &ObjectReference{ + Kind: "Service", + Namespace: "default", + Name: "my-service", + Source: "service", + }, + }, + action: ActionCreate, + reason: RecordReady, + asserts: func(t *testing.T, ev Event) { + require.Equal(t, ActionCreate, ev.action) + require.Equal(t, RecordReady, ev.reason) + require.Equal(t, EventTypeNormal, ev.eType) + require.Equal(t, "Service", ev.ref.Kind) + require.Equal(t, "default", ev.ref.Namespace) + require.Equal(t, "my-service", ev.ref.Name) + require.Contains(t, ev.message, "record:test.example.com") + require.Contains(t, ev.message, "owner:my-owner") + require.Contains(t, ev.message, "type:A") + require.Contains(t, ev.message, "ttl:300") + require.Contains(t, ev.message, "targets:10.0.0.1,10.0.0.2") + require.Contains(t, ev.message, "(external-dns)") + }, + }, + { + name: "valid endpoint with delete action", + ep: &mockEndpointInfo{ + dnsName: "deleted.example.com", + recordType: "CNAME", + recordTTL: 0, + targets: []string{"target.example.com"}, + owner: "", + refObject: &ObjectReference{ + Kind: "Ingress", + Namespace: "prod", + Name: "my-ingress", + Source: "ingress", + }, + }, + action: ActionDelete, + reason: RecordDeleted, + asserts: func(t *testing.T, ev Event) { + require.Equal(t, ActionDelete, ev.action) + require.Equal(t, RecordDeleted, ev.reason) + require.Contains(t, ev.message, "record:deleted.example.com") + require.Contains(t, ev.message, "type:CNAME") + require.Contains(t, ev.message, "ttl:0") + }, + }, + { + name: "endpoint for cluster-scoped resource (Node) should handle empty namespace", + ep: &mockEndpointInfo{ + dnsName: "node1.example.com", + recordType: "A", + recordTTL: 60, + targets: []string{"192.168.1.1"}, + owner: "default", + refObject: &ObjectReference{ + Kind: "Node", + Namespace: "", // cluster-scoped + Name: "node1", + Source: "node", + }, + }, + action: ActionCreate, + reason: RecordReady, + 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) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := NewEventFromEndpoint(tt.ep, tt.action, tt.reason) + tt.asserts(t, ev) + }) + } +} + +func TestNewObjectReference(t *testing.T) { + tests := []struct { + name string + obj ctrlruntime.Object + source string + expected *ObjectReference + }{ + { + name: "Pod with TypeMeta already set", + obj: &apiv1.Pod{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pod", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-pod", + Namespace: "default", + UID: "pod-uid-123", + }, + }, + source: "pod", + expected: &ObjectReference{ + Kind: "Pod", + ApiVersion: "v1", + Namespace: "default", + Name: "my-pod", + UID: "pod-uid-123", + Source: "pod", + }, + }, + { + name: "Pod without TypeMeta (simulating informer behavior)", + obj: &apiv1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "informer-pod", + Namespace: "kube-system", + UID: "informer-uid-456", + }, + }, + source: "pod", + expected: &ObjectReference{ + Kind: "Pod", + ApiVersion: "v1", + Namespace: "kube-system", + Name: "informer-pod", + UID: "informer-uid-456", + Source: "pod", + }, + }, + { + name: "Service without TypeMeta", + obj: &apiv1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + Namespace: "prod", + UID: "svc-uid-789", + }, + }, + source: "service", + expected: &ObjectReference{ + Kind: "Service", + ApiVersion: "v1", + Namespace: "prod", + Name: "my-service", + UID: "svc-uid-789", + Source: "service", + }, + }, + { + name: "Node (cluster-scoped, no namespace)", + obj: &apiv1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-node-1", + UID: "node-uid-abc", + }, + }, + source: "node", + expected: &ObjectReference{ + Kind: "Node", + ApiVersion: "v1", + Namespace: "", + Name: "worker-node-1", + UID: "node-uid-abc", + Source: "node", + }, + }, + { + name: "Endpoints without TypeMeta", + obj: &apiv1.Endpoints{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-endpoints", + Namespace: "default", + UID: "ep-uid-def", + }, + }, + source: "endpoints", + expected: &ObjectReference{ + Kind: "Endpoints", + ApiVersion: "v1", + Namespace: "default", + Name: "my-endpoints", + UID: "ep-uid-def", + Source: "endpoints", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewObjectReference(tt.obj, tt.source) + require.Equal(t, tt.expected.Kind, result.Kind) + require.Equal(t, tt.expected.ApiVersion, result.ApiVersion) + require.Equal(t, tt.expected.Namespace, result.Namespace) + require.Equal(t, tt.expected.Name, result.Name) + require.Equal(t, tt.expected.UID, result.UID) + require.Equal(t, tt.expected.Source, result.Source) + }) + } +} + +// customObject is a type not registered in the scheme, used to test reflection fallback +type customObject struct { + metav1.TypeMeta + metav1.ObjectMeta +} + +func (c *customObject) DeepCopyObject() runtime.Object { + return &customObject{ + TypeMeta: c.TypeMeta, + ObjectMeta: *c.ObjectMeta.DeepCopy(), + } +} + +func TestNewObjectReference_ReflectionFallback(t *testing.T) { + // Test that when object type is not in scheme, reflection is used to get Kind + obj := &customObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "custom-resource", + Namespace: "custom-ns", + UID: "custom-uid-123", + }, + } + + ref := NewObjectReference(obj, "custom") + + // Kind should be derived from reflection (struct name) + require.Equal(t, "customObject", ref.Kind) + // APIVersion will be empty since it's not in scheme + require.Empty(t, ref.ApiVersion) + require.Equal(t, "custom-ns", ref.Namespace) + require.Equal(t, "custom-resource", ref.Name) + require.Equal(t, "custom-uid-123", string(ref.UID)) + require.Equal(t, "custom", ref.Source) +} From fa21b582fdf19639bf581e74474c34602cc3159a Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Sun, 11 Jan 2026 16:00:28 +0000 Subject: [PATCH 2/5] feat(event): standardize event messages and add Kind lookup Signed-off-by: ivan katliarchuk --- pkg/events/controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/events/controller.go b/pkg/events/controller.go index e8a4b91568..add2e696f3 100644 --- a/pkg/events/controller.go +++ b/pkg/events/controller.go @@ -152,7 +152,7 @@ func (ec *Controller) emit(event *eventsv1.Event) { log.Debugf("skipping event %s/%s/%s with reason %s as not configured to emit", event.Kind, event.Namespace, event.Name, event.Reason) return } - event.ReportingController = controllerName + "-" + ec.hostname + event.ReportingController = controllerName ec.queue.Add(event) } From dc35e9fc9ec543370a5c9d6419c9339ec4d602a2 Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Sun, 11 Jan 2026 16:02:00 +0000 Subject: [PATCH 3/5] feat(event): standardize event messages and add Kind lookup Signed-off-by: ivan katliarchuk --- docs/advanced/events.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/advanced/events.md b/docs/advanced/events.md index 10d9df040f..ac4181a506 100644 --- a/docs/advanced/events.md +++ b/docs/advanced/events.md @@ -1,3 +1,6 @@ +--- +tags: ["advanced", "area/events", "events"] +--- # Kubernetes Events in External-DNS External-DNS manages DNS records dynamically based on Kubernetes resources like Services and Ingresses. @@ -31,6 +34,7 @@ kubectl describe service kubectl get events --field-selector involvedObject.kind=Service kubectl get events --field-selector type=Normal|Warning kubectl get events --field-selector reason=RecordReady|RecordDeleted|RecordError +kubectl get events --field-selector reportingComponent=external-dns ``` Or integrate with tools like: From fae1901d887192efa639243d24755b1bdbb32152 Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Sun, 11 Jan 2026 16:08:14 +0000 Subject: [PATCH 4/5] feat(event): standardize event messages and add Kind lookup Signed-off-by: ivan katliarchuk --- endpoint/endpoint.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index db88197a3f..395631adaa 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -245,7 +245,7 @@ type Endpoint struct { // +optional ProviderSpecific ProviderSpecific `json:"providerSpecific,omitempty"` // refObject stores reference object - // TODO: should be an array, as endpoints merged from multiple sources may have multiple owners + // TODO: should be an array, as endpoints merged from multiple sources may have multiple ref objects // +optional refObject *ObjectRef `json:"-"` } From 1b3d8130e237117db1f629a98f98736195b96236 Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Sun, 11 Jan 2026 16:09:09 +0000 Subject: [PATCH 5/5] feat(event): standardize event messages and add Kind lookup Signed-off-by: ivan katliarchuk --- pkg/events/types.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/events/types.go b/pkg/events/types.go index fe5636dbbf..fed5832db6 100644 --- a/pkg/events/types.go +++ b/pkg/events/types.go @@ -90,7 +90,6 @@ type ( } // EndpointInfo defines the interface for endpoint data needed to create events. - // This avoids circular imports between endpoint and events packages. EndpointInfo interface { GetDNSName() string GetRecordType() string