Skip to content

Commit f57baa4

Browse files
feat(events): publish k8s events
Signed-off-by: ivan katliarchuk <[email protected]>
1 parent cf28033 commit f57baa4

File tree

18 files changed

+749
-286
lines changed

18 files changed

+749
-286
lines changed

controller/controller.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,6 @@ func (c *Controller) RunOnce(ctx context.Context) error {
246246
if err != nil {
247247
registryErrorsTotal.Counter.Inc()
248248
deprecatedRegistryErrors.Counter.Inc()
249-
emitChangeEvent(c.EventController, *plan.Changes, events.RecordError)
250249
return err
251250
} else {
252251
emitChangeEvent(c.EventController, *plan.Changes, events.RecordReady)

controller/controller_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"sigs.k8s.io/external-dns/endpoint"
2929
"sigs.k8s.io/external-dns/internal/testutils"
3030
"sigs.k8s.io/external-dns/pkg/apis/externaldns"
31+
"sigs.k8s.io/external-dns/pkg/events/fake"
3132
"sigs.k8s.io/external-dns/plan"
3233
"sigs.k8s.io/external-dns/provider"
3334
"sigs.k8s.io/external-dns/registry"
@@ -216,6 +217,8 @@ func TestRunOnce(t *testing.T) {
216217
cfg := getTestConfig()
217218
provider := getTestProvider()
218219

220+
emitter := fake.NewFakeEventEmitter()
221+
219222
r, err := registry.NewNoopRegistry(provider)
220223
require.NoError(t, err)
221224

@@ -225,6 +228,7 @@ func TestRunOnce(t *testing.T) {
225228
Registry: r,
226229
Policy: &plan.SyncPolicy{},
227230
ManagedRecordTypes: cfg.ManagedDNSRecordTypes,
231+
EventController: emitter,
228232
}
229233

230234
assert.NoError(t, ctrl.RunOnce(context.Background()))
@@ -235,6 +239,8 @@ func TestRunOnce(t *testing.T) {
235239

236240
testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "a"})
237241
testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"})
242+
243+
emitter.AssertNumberOfCalls(t, "Add", 6)
238244
}
239245

240246
// TestRun tests that Run correctly starts and stops

controller/events.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,6 @@ func emitChangeEvent(e events.EventEmitter, ch plan.Changes, reason events.Reaso
3232
e.Add(events.NewEvent(change.RefObject(), change.Describe(), events.ActionUpdate, reason))
3333
}
3434
for _, change := range ch.Delete {
35-
e.Add(events.NewEvent(change.RefObject(), change.Describe(), events.ActionDelete, reason))
35+
e.Add(events.NewEvent(change.RefObject(), change.Describe(), events.ActionDelete, events.RecordDeleted))
3636
}
3737
}

controller/events_test.go

Lines changed: 73 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,43 +19,88 @@ package controller
1919
import (
2020
"testing"
2121

22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/mock"
24+
"sigs.k8s.io/external-dns/endpoint"
2225
"sigs.k8s.io/external-dns/pkg/events"
26+
"sigs.k8s.io/external-dns/pkg/events/fake"
2327
"sigs.k8s.io/external-dns/plan"
2428
)
2529

26-
type mockEventEmitter struct {
27-
events []events.Event
28-
}
30+
func TestEmit_RecordReady(t *testing.T) {
31+
refObj := &events.ObjectReference{}
32+
33+
tests := []struct {
34+
name string
35+
changes plan.Changes
36+
asserts func(em *fake.EventEmitter, ch plan.Changes)
37+
}{
38+
{
39+
name: "create, update and delete endpoints",
40+
changes: plan.Changes{
41+
Create: []*endpoint.Endpoint{
42+
endpoint.NewEndpoint("one.example.com", endpoint.RecordTypeA, "10.10.10.0").WithRefObject(refObj),
43+
endpoint.NewEndpoint("two.example.com", endpoint.RecordTypeA, "10.10.10.1").WithRefObject(refObj),
44+
},
45+
UpdateNew: []*endpoint.Endpoint{
46+
endpoint.NewEndpoint("three.example.com", endpoint.RecordTypeA, "10.10.10.2").WithRefObject(refObj),
47+
endpoint.NewEndpoint("four.example.com", endpoint.RecordTypeA, "10.10.10.3").WithRefObject(refObj),
48+
},
49+
Delete: []*endpoint.Endpoint{
50+
endpoint.NewEndpoint("five.example.com", endpoint.RecordTypeA, "192.10.10.0").WithRefObject(refObj),
51+
},
52+
},
53+
asserts: func(em *fake.EventEmitter, ch plan.Changes) {
54+
for _, ep := range ch.Create {
55+
em.AssertCalled(t, "Add", events.NewEvent(ep.RefObject(), ep.Describe(), events.ActionCreate, events.RecordReady))
56+
}
57+
for _, ep := range ch.Delete {
58+
em.AssertCalled(t, "Add", events.NewEvent(ep.RefObject(), ep.Describe(), events.ActionDelete, events.RecordDeleted))
59+
}
60+
em.AssertNotCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool {
61+
return e.EventType() == events.EventTypeWarning
62+
}))
63+
em.AssertNumberOfCalls(t, "Add", 5)
64+
},
65+
},
66+
{
67+
name: "delete endpoints",
68+
changes: plan.Changes{
69+
Create: []*endpoint.Endpoint{},
70+
UpdateNew: []*endpoint.Endpoint{},
71+
Delete: []*endpoint.Endpoint{
72+
endpoint.NewEndpoint("five.example.com", endpoint.RecordTypeA, "192.10.10.0").WithRefObject(refObj),
73+
},
74+
},
75+
asserts: func(em *fake.EventEmitter, ch plan.Changes) {
76+
for _, ep := range ch.Delete {
77+
em.AssertCalled(t, "Add", events.NewEvent(ep.RefObject(), ep.Describe(), events.ActionDelete, events.RecordDeleted))
78+
}
79+
em.AssertCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool {
80+
return e.EventType() == events.EventTypeNormal &&
81+
e.Action() == events.ActionDelete &&
82+
e.Reason() == events.RecordDeleted
83+
}))
2984

30-
func (m *mockEventEmitter) Add(events ...events.Event) {
31-
for _, event := range events {
32-
m.events = append(m.events, event)
85+
em.AssertNumberOfCalls(t, "Add", 1)
86+
},
87+
},
3388
}
3489

35-
}
90+
for _, tt := range tests {
91+
t.Run(tt.name, func(t *testing.T) {
92+
emitter := fake.NewFakeEventEmitter()
3693

37-
func TestEmit(t *testing.T) {
38-
// emitter := &mockEventEmitter{}
39-
// obj := &struct{}{} // dummy object
40-
41-
// change := plan.Change{
42-
// Ref: obj,
43-
// }
44-
// changes := plan.Changes{
45-
// Create: []plan.Change{change},
46-
// UpdateNew: []plan.Change{change},
47-
// Delete: []plan.Change{change},
48-
// }
49-
//
50-
// emitChangeEvent(emitter, changes, events.Reason("TestReason"))
51-
//
52-
// require.Len(t, emitter.events, 3)
53-
// require.Equal(t, events.ActionCreate, emitter.events[0].Action)
54-
// require.Equal(t, events.ActionUpdate, emitter.events[1].Action)
55-
// require.Equal(t, events.ActionDelete, emitter.events[2].Action)
94+
emitChangeEvent(emitter, tt.changes, events.RecordReady)
95+
96+
tt.asserts(emitter, tt.changes)
97+
mock.AssertExpectationsForObjects(t, emitter)
98+
})
99+
}
56100
}
57101

58102
func TestEmit_NilEmitter(t *testing.T) {
59-
emitChangeEvent(nil, plan.Changes{}, events.Reason("TestReason"))
60-
// Should not panic or do anything
103+
assert.NotPanics(t, func() {
104+
emitChangeEvent(nil, plan.Changes{}, events.RecordError)
105+
})
61106
}

controller/execute.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,7 @@ func Execute() {
122122
}
123123

124124
eventsController, err := events.NewEventController(events.NewConfig(
125-
events.WithKubeConfig(cfg.KubeConfig),
126-
events.WithAPIServerURL(cfg.APIServerURL),
125+
events.WithKubeConfig(cfg.KubeConfig, cfg.APIServerURL, cfg.RequestTimeout),
127126
events.WithEmitEvents(cfg.EmitEvents),
128127
events.WithDryRun(cfg.DryRun),
129128
))

controller/execute_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ func (m *MockProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error
484484
return nil, nil
485485
}
486486

487-
func (p *MockProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
487+
func (m *MockProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
488488
return nil
489489
}
490490

docs/advanced/events.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Kubernetes Events in External-DNS
2+
3+
External-DNS manages DNS records dynamically based on Kubernetes resources like Services and Ingresses. Emitting Kubernetes Events provides a lightweight, observable way for users and systems to understand what External-DNS is doing, especially in production environments where DNS correctness is essential.
4+
5+
> Note; events is currently alpha feature. Functionality is limited and subject to change
6+
7+
## ✨ Why Events Matter
8+
9+
Kubernetes Events enable External-DNS to provide real-time feedback to users and controllers, complementing logs with a simpler way to track DNS changes. This enhances debugging, monitoring, and automation around DNS operations.
10+
11+
### Use Cases of Emitting Events
12+
13+
| Use Case | Description |
14+
|---------------------------------------------------|--------------------------------------------------------------------------------------------------------------|
15+
| **DNS Record Visibility** | Events show what DNS records were created, updated, or deleted (e.g., `Created A record "api.example.com"`). |
16+
| **Developer Feedback** | Users deploying Ingresses or Services can see if External-DNS processed their resource. |
17+
| **Surface Errors, Debugging and Troubleshooting** | Easily identify resource misannotations, sync failures, or IAM permission issues. |
18+
| **Error Reporting** | Emit warning events when record sync fails due to provider issues, duplicate records, or misconfigurations. |
19+
| **Integration with Alerting/Automation/Auditing** | This enables automated remediation or notifications when DNS sync fails or changes unexpectedly. |
20+
| **Observability** | Trace why a DNS record wasn’t created. |
21+
| **Alerting/automation** | Trigger actions based on failed events. |
22+
| **Operator and Developer feedback** | It removes the black-box feeling of "I deployed an Ingress, but why doesn’t the DNS work?" |
23+
24+
## Consuming Events
25+
26+
You can observe External-DNS events using:
27+
28+
```sh
29+
kubectl describe service <name>
30+
kubectl get events --field-selector involvedObject.kind=Service
31+
kubectl get events --field-selector type=Normal|Warning
32+
kubectl get events --field-selector reason=RecordReady|RecordDeleted|RecordError
33+
kubectl get events --field-selector action=Created|Updated|Deleted|FailedSync
34+
kubectl get events --field-selector reportingComponent=external-dns
35+
```
36+
37+
Or integrate with tools like:
38+
39+
- Prometheus (via event exporters)
40+
- Loki/Fluentd for log/event aggregation
41+
- Argo CD / Flux for GitOps monitoring
42+
43+
### Practices for Understanding Events
44+
45+
- **Action field**: Events include a short label describing the `Action`, such as `Created`, `Updated`, `Deleted`, or `FailedSync`
46+
- **Reason field**: Events include a short label `Reason` is why the action was taken, such as `RecordReady`, `RecordDeleted`, or `RecordError`.
47+
- **Type field**:
48+
- `Normal` means the operation succeeded (e.g., a DNS record was created).
49+
- `Warning` indicates a problem (e.g., DNS sync failed due to configuration or provider issues).
50+
- **Linked** resource: Events are attached to the relevant Kubernetes resource (like an `Ingress` or `Service`), so you can view them with tools like `kubectl describe`.
51+
- **Event noise**: If you see repeated identical events, it may indicate a misconfiguration or an issue worth investigating.
52+
53+
### Caveats
54+
55+
- Events are ephemeral (default retention is ~1 hour).
56+
- They are best-effort and not guaranteed to be delivered or stored long-term.
57+
- Not a substitute for logging or metrics, but complementary.
58+
59+
## Supported Sources
60+
61+
Events are emitted for all sources that External-DNS supports. The following table lists the sources and whether they currently emit events.
62+
If a source does not emit events, it may in the future.
63+
64+
| Source | Supported |
65+
|:-----------------------|:---------:|
66+
| `ambassador-host` | |
67+
| `cloudfoundry` | |
68+
| `connector` | |
69+
| `contour-httpproxy` | |
70+
| `crd` | |
71+
| `empty` | |
72+
| `f5-transportserver` | |
73+
| `f5-virtualserver` | |
74+
| `fake` ||
75+
| `gateway-grpcroute` | |
76+
| `gateway-httproute` | |
77+
| `gateway-tcproute` | |
78+
| `gateway-tlsroute` | |
79+
| `gateway-udproute` | |
80+
| `gloo-proxy` | |
81+
| `ingress` | |
82+
| `istio-gateway` | |
83+
| `istio-virtualservice` | |
84+
| `kong-tcpingress` | |
85+
| `node` | |
86+
| `openshift-route` | |
87+
| `pod` | |
88+
| `service` | |
89+
| `skipper-routegroup` | |
90+
| `traefik-proxy` | |

docs/flags.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
| `--target-net-filter=TARGET-NET-FILTER` | Limit possible targets by a net filter; specify multiple times for multiple possible nets (optional) |
5353
| `--[no-]traefik-enable-legacy` | Enable legacy listeners on Resources under the traefik.containo.us API Group |
5454
| `--[no-]traefik-disable-new` | Disable listeners on Resources under the traefik.io API Group |
55+
| `--events-emit=EVENTS-EMIT` | Events that should be emitted. (optional, default: none, expected: RecordReady, RecordDeleted, RecordError) |
5556
| `--provider=provider` | The DNS provider where the DNS records will be created (required, options: akamai, alibabacloud, aws, aws-sd, azure, azure-dns, azure-private-dns, civo, cloudflare, coredns, digitalocean, dnsimple, exoscale, gandi, godaddy, google, inmemory, linode, ns1, oci, ovh, pdns, pihole, plural, rfc2136, scaleway, skydns, transip, webhook) |
5657
| `--provider-cache-time=0s` | The time to cache the DNS provider record list requests. |
5758
| `--domain-filter=` | Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional) |

endpoint/endpoint_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"testing"
2323

2424
"github.com/stretchr/testify/assert"
25+
"sigs.k8s.io/external-dns/pkg/events"
2526
)
2627

2728
func TestNewEndpoint(t *testing.T) {
@@ -968,3 +969,16 @@ func TestEndpoint_UniqueOrderedTargets(t *testing.T) {
968969
})
969970
}
970971
}
972+
973+
func TestEndpoint_WithRefObject(t *testing.T) {
974+
ep := &Endpoint{}
975+
ref := &events.ObjectReference{
976+
Kind: "Service",
977+
Namespace: "default",
978+
Name: "my-service",
979+
}
980+
result := ep.WithRefObject(ref)
981+
982+
assert.Equal(t, ref, ep.RefObject(), "refObject should be set")
983+
assert.Equal(t, ep, result, "should return the same Endpoint pointer")
984+
}

pkg/apis/externaldns/types.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,9 @@ type Config struct {
160160
ExoscaleAPISecret string `secure:"yes"`
161161
ExoscaleAPIEnvironment string
162162
ExoscaleAPIZone string
163-
ServiceTypeFilter []string
164163
CRDSourceAPIVersion string
165164
CRDSourceKind string
166-
EmitEvents []string
165+
ServiceTypeFilter []string
167166
CFAPIEndpoint string
168167
CFUsername string
169168
CFPassword string
@@ -213,6 +212,7 @@ type Config struct {
213212
TraefikDisableNew bool
214213
NAT64Networks []string
215214
ExcludeUnschedulable bool
215+
EmitEvents []string
216216
ForceDefaultTargets bool
217217
}
218218

@@ -490,7 +490,7 @@ func App(cfg *Config) *kingpin.Application {
490490
app.Flag("traefik-enable-legacy", "Enable legacy listeners on Resources under the traefik.containo.us API Group").Default(strconv.FormatBool(defaultConfig.TraefikEnableLegacy)).BoolVar(&cfg.TraefikEnableLegacy)
491491
app.Flag("traefik-disable-new", "Disable listeners on Resources under the traefik.io API Group").Default(strconv.FormatBool(defaultConfig.TraefikDisableNew)).BoolVar(&cfg.TraefikDisableNew)
492492

493-
app.Flag("events-emit", "Events that should be emitted. (optional, default: none, expected: RecordReady, RecordError)").Default(defaultConfig.EmitEvents...).StringsVar(&cfg.EmitEvents)
493+
app.Flag("events-emit", "Events that should be emitted. (optional, default: none, expected: RecordReady, RecordDeleted, RecordError)").Default(defaultConfig.EmitEvents...).StringsVar(&cfg.EmitEvents)
494494

495495
// Flags related to providers
496496
providers := []string{"akamai", "alibabacloud", "aws", "aws-sd", "azure", "azure-dns", "azure-private-dns", "civo", "cloudflare", "coredns", "digitalocean", "dnsimple", "exoscale", "gandi", "godaddy", "google", "inmemory", "linode", "ns1", "oci", "ovh", "pdns", "pihole", "plural", "rfc2136", "scaleway", "skydns", "transip", "webhook"}

0 commit comments

Comments
 (0)