Skip to content

Commit 9492f95

Browse files
committed
feat: resolve load balancer hostname to A/AAAA records
1 parent 9475240 commit 9492f95

18 files changed

Lines changed: 846 additions & 18 deletions

docs/annotations/annotations.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,52 @@ spec:
299299

300300
> ExternalDNS will create an internal DNS record for `my-pod.internal.example.com` targeting the Pod `Status.PodIP`.
301301

302+
## external-dns.alpha.kubernetes.io/resolve-target
303+
304+
Controls whether load balancer hostname targets when they are CNAME records. When the annotation is set to `"true"` the CNAME records are resolved to their underlying
305+
A/AAAA records at sync time, ExternalDNS performs a DNS lookup for each hostname target and emits the
306+
resulting IP addresses as A and/or AAAA endpoints. If resolution fails (e.g. the hostname is
307+
temporarily unresolvable), that target is silently skipped.
308+
309+
This is useful when a DNS provider does not support CNAME flattening
310+
or ALIAS records and a flat IP address record is required.
311+
312+
### Use Cases for `external-dns.alpha.kubernetes.io/resolve-target` annotation
313+
314+
#### Opt in to resolution for a single Source
315+
316+
Resolve load balancer hostnames to IPs for one Ingress:
317+
318+
```yaml
319+
apiVersion: networking.k8s.io/v1
320+
kind: Ingress
321+
metadata:
322+
name: test-ingress
323+
namespace: default
324+
annotations:
325+
external-dns.alpha.kubernetes.io/resolve-target: "true"
326+
spec:
327+
rules:
328+
- host: svc.example.com
329+
http:
330+
paths:
331+
- path: /
332+
pathType: Prefix
333+
backend:
334+
service:
335+
name: my-service
336+
port:
337+
number: 80
338+
status:
339+
loadBalancer:
340+
ingress:
341+
- hostname: example.com
342+
```
343+
344+
> ExternalDNS will resolve the Ingress load balancer hostname and publish A/AAAA records instead of a CNAME.
345+
Let's take an example, "example.com" CNAME resolves to A record "104.20.23.154" and AAAA record "2606:4700:10::ac42:93f3".
346+
ExternalDNS will create an A record for "svc.example.com" with target "104.20.23.154" and an AAAA record for "svc.example.com" with target "2606:4700:10::ac42:93f3".
347+
302348
## external-dns.alpha.kubernetes.io/target
303349

304350
Specifies a comma-separated list of values to override the resource's DNS record targets (RDATA).
@@ -420,9 +466,9 @@ spec:
420466
## Gateway API Annotation Placement
421467

422468
When using Gateway API sources (`gateway-httproute`, `gateway-grpcroute`, `gateway-tlsroute`, etc.), annotations
423-
are read from different resources: **Gateway resource** reads only `target` annotation, while **Route resources**
424-
(HTTPRoute, GRPCRoute, TLSRoute, etc.) read all other annotations (`hostname`, `ttl`, `controller`, and
425-
provider-specific annotations like `cloudflare-*`, `aws-*`, `scw-*`).
469+
are read from different resources: **Gateway resource** reads only the `target` annotation, while **Route resources**
470+
(HTTPRoute, GRPCRoute, TLSRoute, etc.) read all other annotations (`hostname`, `ttl`, `controller`,
471+
`resolve-target`, and provider-specific annotations like `cloudflare-*`, `aws-*`, `scw-*`).
426472

427473
**ListenerSet resources** also support the `target` annotation. When a Route references a ListenerSet
428474
as its parent, the ListenerSet's target annotation takes precedence over the parent Gateway's target annotation.

docs/contributing/source-wrappers.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Wrappers solve these key challenges:
2929
|:--------------------:|:----------------------------------------|:----------------------------------------------------|
3030
| `MultiSource` | Combine multiple sources. | Aggregate `Ingress`, `Service`, etc. |
3131
| `DedupSource` | Remove duplicate DNS records. | Avoid duplicate records from sources. |
32+
| `ResolveTarget` | Resolve CNAME targets to A/AAAA values. | Source hostnames into IP records. |
3233
| `TargetFilterSource` | Include/exclude targets based on CIDRs. | Exclude internal IPs. |
3334
| `NAT64Source` | Add NAT64-prefixed AAAA records. | Support IPv6 with NAT64. |
3435
| `PostProcessor` | Add records post-processing. | Configure TTL, filter provider-specific properties. |
@@ -57,6 +58,12 @@ Converts IPv4 targets to IPv6 using NAT64 prefixes.
5758
--nat64-prefix=64:ff9b::/96
5859
```
5960

61+
### 2.2 `ResolveTarget`
62+
63+
Resolves `CNAME` targets to concrete IP targets when the endpoint has the `resolve-target` provider-specific property set to `true`. If all target resolutions fail, the endpoint is skipped.
64+
65+
📌 **Use case**: A Service or Gateway exposes a load balancer hostname (for example, `xxx.elb.amazonaws.com`), but the desired DNS output should be `A`/`AAAA` records rather than a `CNAME`.
66+
6067
### 3.1 `PostProcessor`
6168

6269
Applies post-processing to all endpoints after they are collected from sources.
@@ -116,6 +123,7 @@ Wrappers are often composed like this:
116123
```go
117124
source := NewMultiSource(actualSources, defaultTargets)
118125
source = NewDedupSource(source)
126+
source = NewResolveTarget(source)
119127
source = NewNAT64Source(source, cfg.NAT64Networks)
120128
source = NewTargetFilterSource(source, targetFilter)
121129
source = NewPostProcessor(source, WithTTL(minTTL), WithPostProcessorPreferAlias(preferAlias))

endpoint/endpoint.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,26 @@ func (e *Endpoint) WithSetIdentifier(setIdentifier string) *Endpoint {
313313
return e
314314
}
315315

316+
// WithTargets returns a shallow copy of the endpoint with only Targets and RecordType replaced.
317+
// The RecordType is derived from the first target using SuitableType.
318+
// All other fields remain intact (shared with the original endpoint).
319+
func (e *Endpoint) WithTargets(targets Targets) *Endpoint {
320+
recordType := e.RecordType
321+
if len(targets) > 0 {
322+
recordType = SuitableType(targets[0])
323+
}
324+
return &Endpoint{
325+
DNSName: e.DNSName,
326+
Targets: targets,
327+
RecordType: recordType,
328+
SetIdentifier: e.SetIdentifier,
329+
RecordTTL: e.RecordTTL,
330+
Labels: e.Labels,
331+
ProviderSpecific: e.ProviderSpecific,
332+
refObject: e.refObject,
333+
}
334+
}
335+
316336
// WithProviderSpecific attaches a key/value pair to the Endpoint and returns the Endpoint.
317337
// This can be used to pass additional data through the stages of ExternalDNS's Endpoint processing.
318338
// The assumption is that most of the time this will be provider specific metadata that doesn't

endpoint/endpoint_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,43 @@ func TestEndpoint_WithLabel(t *testing.T) {
201201
})
202202
}
203203

204+
func TestEndpoint_WithTargets(t *testing.T) {
205+
t.Run("non-empty targets derives record type from first target and preserves other fields", func(t *testing.T) {
206+
labels := Labels{"owner": "team-a"}
207+
providerSpecific := ProviderSpecific{{Name: "foo", Value: "bar"}}
208+
ref := &events.ObjectReference{Kind: "Service", Name: "svc-1", Namespace: "default"}
209+
210+
ep := &Endpoint{
211+
DNSName: "example.com",
212+
Targets: Targets{"old.example.net"},
213+
RecordType: RecordTypeCNAME,
214+
SetIdentifier: "set-1",
215+
RecordTTL: TTL(300),
216+
Labels: labels,
217+
ProviderSpecific: providerSpecific,
218+
refObject: ref,
219+
}
220+
221+
newTargets := Targets{"1.2.3.4", "example.net"}
222+
got := ep.WithTargets(newTargets)
223+
224+
require.NotNil(t, got)
225+
assert.NotSame(t, ep, got)
226+
assert.Equal(t, "example.com", got.DNSName)
227+
assert.Equal(t, newTargets, got.Targets)
228+
assert.Equal(t, RecordTypeA, got.RecordType)
229+
assert.Equal(t, "set-1", got.SetIdentifier)
230+
assert.Equal(t, TTL(300), got.RecordTTL)
231+
assert.Equal(t, labels, got.Labels)
232+
assert.Equal(t, providerSpecific, got.ProviderSpecific)
233+
assert.Equal(t, ref, got.refObject)
234+
235+
// Original endpoint remains unchanged.
236+
assert.Equal(t, Targets{"old.example.net"}, ep.Targets)
237+
assert.Equal(t, RecordTypeCNAME, ep.RecordType)
238+
})
239+
}
240+
204241
func TestSame_ParseErrorLogged(t *testing.T) {
205242
hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)
206243

source/annotations/annotations.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ var (
7070
InternalHostnameKey = AnnotationKeyPrefix + "internal-hostname"
7171
// The annotation used for defining the desired hostname source for gateways
7272
GatewayHostnameSourceKey = AnnotationKeyPrefix + "gateway-hostname-source"
73+
// ResolveTargetKey is the per-resource annotation that controls whether
74+
// the resolveTarget Wrapper should resolve the LoadBalancer hostname to IP addresses
75+
ResolveTargetKey = AnnotationKeyPrefix + "resolve-target"
7376
)
7477

7578
// SetAnnotationPrefix sets a custom annotation prefix and rebuilds all annotation keys.
@@ -109,4 +112,5 @@ func SetAnnotationPrefix(prefix string) {
109112
IngressHostnameSourceKey = AnnotationKeyPrefix + "ingress-hostname-source"
110113
InternalHostnameKey = AnnotationKeyPrefix + "internal-hostname"
111114
GatewayHostnameSourceKey = AnnotationKeyPrefix + "gateway-hostname-source"
115+
ResolveTargetKey = AnnotationKeyPrefix + "resolve-target"
112116
}

source/annotations/annotations_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ func TestSetAnnotationPrefix(t *testing.T) {
5151
assert.Equal(t, "custom.io/endpoints-type", EndpointsTypeKey)
5252
assert.Equal(t, "custom.io/ingress", Ingress)
5353
assert.Equal(t, "custom.io/ingress-hostname-source", IngressHostnameSourceKey)
54+
assert.Equal(t, "custom.io/resolve-target", ResolveTargetKey)
5455

5556
// ControllerValue should remain constant
5657
assert.Equal(t, "dns-controller", ControllerValue)

source/annotations/provider_specific.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,11 @@ func ProviderSpecificAnnotations(annotations map[string]string) (endpoint.Provid
100100
}
101101
}
102102
}
103+
if v, ok := annotations[ResolveTargetKey]; ok {
104+
providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{
105+
Name: "resolve-target",
106+
Value: v,
107+
})
108+
}
103109
return providerSpecificAnnotations, setIdentifier
104110
}

source/annotations/provider_specific_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,22 @@ func TestProviderSpecificAnnotations(t *testing.T) {
121121
},
122122
setIdentifier: "",
123123
},
124+
{
125+
name: "resolve-target annotation",
126+
annotations: map[string]string{
127+
ResolveTargetKey: "true",
128+
},
129+
expected: endpoint.ProviderSpecific{
130+
{Name: "resolve-target", Value: "true"},
131+
},
132+
setIdentifier: "",
133+
},
134+
{
135+
name: "resolve-target annotation absent",
136+
annotations: map[string]string{},
137+
expected: endpoint.ProviderSpecific{},
138+
setIdentifier: "",
139+
},
124140
}
125141

126142
for _, tt := range tests {

source/wrappers/build.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import (
2525
// Build creates all named sources using cfg's ClientGenerator and wraps them
2626
// with the standard pipeline (dedup, optional NAT64, optional target filter,
2727
// post-processor). Inject a custom ClientGenerator via source.WithClientGenerator.
28-
func Build(ctx context.Context, cfg *source.Config) (source.Source, error) {
28+
// Additional Options can be passed to customize the wrapper behavior (e.g., custom lookupIP).
29+
func Build(ctx context.Context, cfg *source.Config, extraOpts ...Option) (source.Source, error) {
2930
sources, err := source.ByNames(ctx, cfg, cfg.ClientGenerator())
3031
if err != nil {
3132
return nil, err
@@ -42,5 +43,9 @@ func Build(ctx context.Context, cfg *source.Config) (source.Source, error) {
4243
WithPTRSupported(cfg.PTRSupported),
4344
WithCreatePTR(cfg.CreatePTR),
4445
)
46+
// Apply any extra options passed by the caller.
47+
for _, opt := range extraOpts {
48+
opt(opts)
49+
}
4550
return wrapSources(sources, opts)
4651
}

source/wrappers/build_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,11 @@ func TestBuildWrappedSource(t *testing.T) {
104104
})
105105
}
106106
}
107+
108+
func TestBuildAppliesExtraOptions(t *testing.T) {
109+
cfg := stubConfig(t, &externaldns.Config{Sources: []string{types.Fake}})
110+
111+
_, err := Build(t.Context(), cfg, WithNAT64Networks([]string{"not-a-cidr"}))
112+
require.Error(t, err)
113+
assert.Contains(t, err.Error(), "failed to create NAT64 source wrapper")
114+
}

0 commit comments

Comments
 (0)