Skip to content

Commit 5a55b09

Browse files
lexfreiclaude
andauthored
feat(annotations): add custom annotation prefix support for split horizon DNS (#5889)
* feat(annotations): add custom annotation prefix support for split horizon DNS Add --annotation-prefix flag to allow customizing the annotation prefix used by external-dns. This enables split horizon DNS scenarios where multiple instances process different sets of annotations from the same Kubernetes resources. Changes: - Add AnnotationPrefix field to Config with validation - Convert annotation constants to variables that can be reconfigured - Add SetAnnotationPrefix() function to rebuild annotation keys - Integrate annotation prefix setting in controller startup - Update Helm chart with annotationPrefix value - Add comprehensive split horizon DNS documentation - Update FAQ with annotation prefix examples This maintains full backward compatibility - the default prefix remains "external-dns.alpha.kubernetes.io/". Co-Authored-By: Claude <[email protected]> * docs(advanced): fix markdown formatting in split-horizon guide Add blank lines before code blocks to improve markdown rendering and comply with markdownlint rules. Co-Authored-By: Claude <[email protected]> * docs(advanced): fix markdown formatting in split-horizon guide Co-Authored-By: Claude <[email protected]> * docs(charts): regenerate Helm chart documentation Co-Authored-By: Claude <[email protected]> * test: add AnnotationPrefix field to test configs Add missing AnnotationPrefix field to minimalConfig and overriddenConfig test configurations to match the new default value set in NewConfig(). Co-Authored-By: Claude <[email protected]> * test(charts): update error pattern in json-schema test Update expected error message pattern to match current Helm validation output format. Co-Authored-By: Claude <[email protected]> * refactor(annotations): remove init() for explicit initialization - Remove init() function from annotations package - Add explicit SetAnnotationPrefix() call in controller/execute.go - Remove annotation key aliases from source/source.go - Replace all alias usages with annotations.* references (348 changes in 28 files) - Add TestMain to existing test files (service_test.go, cloudflare_test.go) This change makes annotation initialization explicit and predictable, avoiding hidden global state initialization at import time. Co-Authored-By: Claude <[email protected]> * docs: update changelog and mkdocs to include annotationPrefix and split horizon DNS Signed-off-by: Aleksei Sviridkin <[email protected]> * docs(split-horizon): fix linting Signed-off-by: Aleksei Sviridkin <[email protected]> * refactor(annotations): replace hardcoded annotation prefix with constant Replace all hardcoded "external-dns.alpha.kubernetes.io/" strings with annotations.DefaultAnnotationPrefix constant to establish a single source of truth. Changes: - Add DefaultAnnotationPrefix constant in source/annotations/annotations.go - Replace hardcoded string in controller/execute.go with constant reference - Replace hardcoded strings in pkg/apis/externaldns/types.go (2 occurrences) - Add helm unit tests for annotationPrefix value This eliminates string duplication and makes future changes easier. Co-Authored-By: Claude <[email protected]> --------- Signed-off-by: Aleksei Sviridkin <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 33f8cc6 commit 5a55b09

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+950
-394
lines changed

charts/external-dns/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

1919
## [UNRELEASED]
2020

21+
### Added
22+
23+
- Add option to set `annotationPrefix` ([#5889](https://github.com/kubernetes-sigs/external-dns/pull/5889)) _@lexfrei_
24+
2125
## [v1.19.0] - 2025-09-08
2226

2327
### Added

charts/external-dns/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ If `namespaced` is set to `true`, please ensure that `sources` my only contains
9595
|-----|------|---------|-------------|
9696
| affinity | object | `{}` | Affinity settings for `Pod` [scheduling](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/). If an explicit label selector is not provided for pod affinity or pod anti-affinity one will be created from the pod selector labels. |
9797
| annotationFilter | string | `nil` | Filter resources queried for endpoints by annotation selector. |
98+
| annotationPrefix | string | `nil` | Annotation prefix for external-dns annotations (useful for split horizon DNS with multiple instances). |
9899
| automountServiceAccountToken | bool | `true` | Set this to `false` to [opt out of API credential automounting](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#opt-out-of-api-credential-automounting) for the `Pod`. |
99100
| commonLabels | object | `{}` | Labels to add to all chart resources. |
100101
| deploymentAnnotations | object | `{}` | Annotations to add to the `Deployment`. |

charts/external-dns/templates/deployment.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ spec:
126126
{{- if .Values.annotationFilter }}
127127
- --annotation-filter={{ .Values.annotationFilter }}
128128
{{- end }}
129+
{{- if .Values.annotationPrefix }}
130+
- --annotation-prefix={{ .Values.annotationPrefix }}
131+
{{- end }}
129132
{{- range .Values.managedRecordTypes }}
130133
- --managed-record-types={{ . }}
131134
{{- end }}

charts/external-dns/tests/deployment-flags_test.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,41 @@ tests:
164164
asserts:
165165
- failedTemplate:
166166
errorMessage: "'txtPrefix' and 'txtSuffix' are mutually exclusive"
167+
168+
- it: should configure custom annotation prefix
169+
set:
170+
annotationPrefix: "custom.io/"
171+
asserts:
172+
- contains:
173+
path: spec.template.spec.containers[?(@.name == "external-dns")].args
174+
content: "--annotation-prefix=custom.io/"
175+
176+
- it: should not include annotation-prefix flag when not set
177+
set:
178+
annotationPrefix: null
179+
asserts:
180+
- notContains:
181+
path: spec.template.spec.containers[?(@.name == "external-dns")].args
182+
content: "--annotation-prefix"
183+
184+
- it: should configure annotation prefix with other flags
185+
set:
186+
annotationPrefix: "internal.company.io/"
187+
provider:
188+
name: cloudflare
189+
sources:
190+
- service
191+
- ingress
192+
asserts:
193+
- contains:
194+
path: spec.template.spec.containers[?(@.name == "external-dns")].args
195+
content: "--annotation-prefix=internal.company.io/"
196+
- contains:
197+
path: spec.template.spec.containers[?(@.name == "external-dns")].args
198+
content: "--provider=cloudflare"
199+
- contains:
200+
path: spec.template.spec.containers[?(@.name == "external-dns")].args
201+
content: "--source=service"
202+
- contains:
203+
path: spec.template.spec.containers[?(@.name == "external-dns")].args
204+
content: "--source=ingress"

charts/external-dns/tests/json-schema_test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ tests:
3030
enabled: "abrakadabra"
3131
asserts:
3232
- failedTemplate:
33-
errorPattern: "Invalid type. Expected: [boolean,null], given: string"
33+
errorPattern: "got string, want null or boolean"
3434

3535
- it: should fail if provider is null
3636
set:

charts/external-dns/values.schema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
"null"
1414
]
1515
},
16+
"annotationPrefix": {
17+
"description": "Annotation prefix for external-dns annotations (useful for split horizon DNS with multiple instances).",
18+
"type": [
19+
"string",
20+
"null"
21+
]
22+
},
1623
"automountServiceAccountToken": {
1724
"description": "Set this to `false` to [opt out of API credential automounting](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#opt-out-of-api-credential-automounting) for the `Pod`.",
1825
"type": "boolean"

charts/external-dns/values.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ labelFilter: # @schema type: [string,null]; default: null
240240
# -- Filter resources queried for endpoints by annotation selector.
241241
annotationFilter: # @schema type: [string,null]; default: null
242242

243+
# -- Annotation prefix for external-dns annotations (useful for split horizon DNS with multiple instances).
244+
annotationPrefix: # @schema type: [string,null]; default: null
245+
243246
# -- Record types to manage (default: A, AAAA, CNAME)
244247
managedRecordTypes: [] # @schema type: [array, null]; item: string; uniqueItems: true
245248

controller/execute.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import (
6969
webhookapi "sigs.k8s.io/external-dns/provider/webhook/api"
7070
"sigs.k8s.io/external-dns/registry"
7171
"sigs.k8s.io/external-dns/source"
72+
"sigs.k8s.io/external-dns/source/annotations"
7273
"sigs.k8s.io/external-dns/source/wrappers"
7374
)
7475

@@ -82,6 +83,12 @@ func Execute() {
8283
log.Fatalf("config validation failed: %v", err)
8384
}
8485

86+
// Set annotation prefix (required since init() was removed)
87+
annotations.SetAnnotationPrefix(cfg.AnnotationPrefix)
88+
if cfg.AnnotationPrefix != annotations.DefaultAnnotationPrefix {
89+
log.Infof("Using custom annotation prefix: %s", cfg.AnnotationPrefix)
90+
}
91+
8592
configureLogger(cfg)
8693

8794
if cfg.DryRun {

docs/advanced/split-horizon.md

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# Split Horizon DNS
2+
3+
Split horizon DNS allows you to serve different DNS responses based on the client's location - internal clients receive private IPs while external clients receive public IPs. External-DNS supports split horizon DNS by running multiple instances with different annotation prefixes.
4+
5+
## Overview
6+
7+
By default, all external-dns instances use the same annotation prefix: `external-dns.alpha.kubernetes.io/`. This means all instances process the same annotations. To enable split horizon DNS, you can configure each instance to use a different annotation prefix via the `--annotation-prefix` flag.
8+
9+
## Use Cases
10+
11+
- **Internal/External separation**: Internal DNS points to private IPs (ClusterIP), external DNS points to public Load Balancer IPs
12+
- **Multiple DNS providers**: Route different services to different DNS providers (e.g., internal to CoreDNS, external to Route53)
13+
- **Geographic split**: Different DNS records for different regions
14+
15+
## Configuration
16+
17+
### Basic Split Horizon Setup
18+
19+
**Internal DNS Instance:**
20+
21+
```bash
22+
external-dns \
23+
--annotation-prefix=internal.company.io/ \
24+
--source=service \
25+
--source=ingress \
26+
--provider=aws \
27+
--aws-zone-type=private \
28+
--domain-filter=internal.company.com \
29+
--txt-owner-id=internal-dns
30+
```
31+
32+
**External DNS Instance:**
33+
34+
```bash
35+
external-dns \
36+
--annotation-prefix=external-dns.alpha.kubernetes.io/ \ # default, can be omitted
37+
--source=service \
38+
--source=ingress \
39+
--provider=aws \
40+
--aws-zone-type=public \
41+
--domain-filter=company.com \
42+
--txt-owner-id=external-dns
43+
```
44+
45+
### Service with Both Annotations
46+
47+
```yaml
48+
apiVersion: v1
49+
kind: Service
50+
metadata:
51+
name: myapp
52+
annotations:
53+
# Internal DNS reads this
54+
internal.company.io/hostname: myapp.internal.company.com
55+
internal.company.io/ttl: "300"
56+
internal.company.io/target: 10.0.1.50 # Private IP
57+
58+
# External DNS reads this
59+
external-dns.alpha.kubernetes.io/hostname: myapp.company.com
60+
external-dns.alpha.kubernetes.io/ttl: "60"
61+
# No target = uses LoadBalancer IP automatically
62+
spec:
63+
type: LoadBalancer
64+
clusterIP: 10.0.1.50
65+
ports:
66+
- port: 80
67+
targetPort: 8080
68+
selector:
69+
app: myapp
70+
```
71+
72+
**Result:**
73+
74+
- **Internal DNS** (Route53 Private Zone `internal.company.com`): `myapp.internal.company.com → 10.0.1.50`
75+
- **External DNS** (Route53 Public Zone `company.com`): `myapp.company.com → 203.0.113.10` (LoadBalancer IP)
76+
77+
### Helm Chart Configuration
78+
79+
You can use the Helm chart to deploy multiple instances:
80+
81+
**values-internal.yaml:**
82+
83+
```yaml
84+
annotationPrefix: "internal.company.io/"
85+
86+
provider:
87+
name: aws
88+
89+
aws:
90+
zoneType: private
91+
92+
domainFilters:
93+
- internal.company.com
94+
95+
txtOwnerId: internal-dns
96+
97+
sources:
98+
- service
99+
- ingress
100+
```
101+
102+
**values-external.yaml:**
103+
104+
```yaml
105+
# annotationPrefix defaults to "external-dns.alpha.kubernetes.io/"
106+
# can be omitted or set explicitly:
107+
# annotationPrefix: "external-dns.alpha.kubernetes.io/"
108+
109+
provider:
110+
name: aws
111+
112+
aws:
113+
zoneType: public
114+
115+
domainFilters:
116+
- company.com
117+
118+
txtOwnerId: external-dns
119+
120+
sources:
121+
- service
122+
- ingress
123+
```
124+
125+
**Deploy:**
126+
127+
```bash
128+
# Internal instance
129+
helm install external-dns-internal external-dns/external-dns \
130+
--namespace external-dns-internal \
131+
--create-namespace \
132+
--values values-internal.yaml
133+
134+
# External instance
135+
helm install external-dns-external external-dns/external-dns \
136+
--namespace external-dns-external \
137+
--create-namespace \
138+
--values values-external.yaml
139+
```
140+
141+
## Advanced Examples
142+
143+
### Three-Way Split (Internal / DMZ / External)
144+
145+
```yaml
146+
apiVersion: v1
147+
kind: Service
148+
metadata:
149+
name: api
150+
annotations:
151+
# Internal (private network only)
152+
internal.company.io/hostname: api.internal.company.com
153+
internal.company.io/ttl: "300"
154+
155+
# DMZ (accessible from office network)
156+
dmz.company.io/hostname: api.dmz.company.com
157+
dmz.company.io/ttl: "120"
158+
159+
# External (public internet)
160+
external-dns.alpha.kubernetes.io/hostname: api.company.com
161+
external-dns.alpha.kubernetes.io/ttl: "60"
162+
external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"
163+
spec:
164+
type: LoadBalancer
165+
# ...
166+
```
167+
168+
**Deploy three instances:**
169+
170+
```bash
171+
# Internal
172+
--annotation-prefix=internal.company.io/ --provider=aws --aws-zone-type=private
173+
174+
# DMZ
175+
--annotation-prefix=dmz.company.io/ --provider=aws --aws-zone-type=private
176+
177+
# External
178+
--annotation-prefix=external-dns.alpha.kubernetes.io/ --provider=cloudflare
179+
```
180+
181+
### Different Providers Per Instance
182+
183+
```yaml
184+
apiVersion: v1
185+
kind: Service
186+
metadata:
187+
name: webapp
188+
annotations:
189+
# Route53 for AWS internal
190+
aws.company.io/hostname: webapp.aws.company.com
191+
aws.company.io/aws-alias: "true"
192+
193+
# Cloudflare for public
194+
cf.company.io/hostname: webapp.company.com
195+
cf.company.io/cloudflare-proxied: "true"
196+
spec:
197+
type: LoadBalancer
198+
# ...
199+
```
200+
201+
**Deploy:**
202+
203+
```bash
204+
# AWS instance
205+
--annotation-prefix=aws.company.io/ --provider=aws
206+
207+
# Cloudflare instance
208+
--annotation-prefix=cf.company.io/ --provider=cloudflare
209+
```
210+
211+
## Important Notes
212+
213+
1. **Annotation prefix must end with `/`** - The validation will fail if the prefix doesn't end with a forward slash.
214+
2. **Backward compatibility** - If you don't specify `--annotation-prefix`, the default `external-dns.alpha.kubernetes.io/` is used, maintaining full backward compatibility.
215+
3. **All annotations use the same prefix** - When you set a custom prefix, ALL external-dns annotations (hostname, ttl, target, cloudflare-proxied, etc.) must use that prefix.
216+
4. **TXT ownership records** - Each instance should have a unique `--txt-owner-id` to avoid conflicts in ownership tracking.
217+
5. **Provider-specific annotations** - Provider-specific annotations (like `cloudflare-proxied`, `aws-alias`) also use the custom prefix:
218+
219+
```yaml
220+
custom.io/hostname: example.com
221+
custom.io/cloudflare-proxied: "true" # NOT external-dns.alpha.kubernetes.io/cloudflare-proxied
222+
```
223+
224+
## Troubleshooting
225+
226+
### Both instances processing the same resources
227+
228+
**Problem:** Both internal and external instances are creating records for the same service.
229+
230+
**Solution:** Make sure you're using different annotation prefixes and that your services have the correct annotations:
231+
232+
```yaml
233+
# ✅ Correct - different prefixes
234+
internal.company.io/hostname: internal.example.com
235+
external-dns.alpha.kubernetes.io/hostname: example.com
236+
237+
# ❌ Wrong - same prefix
238+
external-dns.alpha.kubernetes.io/hostname: internal.example.com
239+
external-dns.alpha.kubernetes.io/hostname: example.com # Second one overwrites first
240+
```
241+
242+
### Validation error: "annotation-prefix must end with '/'"
243+
244+
**Problem:** The annotation prefix doesn't end with a forward slash.
245+
246+
**Solution:** Always end your custom prefix with `/`:
247+
248+
```bash
249+
# ✅ Correct
250+
--annotation-prefix=custom.io/
251+
252+
# ❌ Wrong
253+
--annotation-prefix=custom.io
254+
```
255+
256+
### Provider-specific annotations not working
257+
258+
**Problem:** Cloudflare/AWS-specific annotations are not being applied.
259+
260+
**Solution:** Provider-specific annotations must use the same prefix as the hostname:
261+
262+
```yaml
263+
# If using custom prefix
264+
custom.io/hostname: example.com
265+
custom.io/cloudflare-proxied: "true"
266+
custom.io/ttl: "60"
267+
```
268+
269+
## See Also
270+
271+
- [Configuration Precedence](configuration-precedence.md) - Understanding how external-dns processes configuration
272+
- [FAQ](../faq.md) - Frequently asked questions
273+
- [AWS Provider](../tutorials/aws.md) - AWS Route53 configuration
274+
- [Cloudflare Provider](../tutorials/cloudflare.md) - Cloudflare configuration

0 commit comments

Comments
 (0)