Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 50 additions & 3 deletions docs/annotations/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,53 @@ spec:

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

## external-dns.alpha.kubernetes.io/resolve-target

Controls load balancer hostname targets with CNAME records.
When the annotation is set to `"true"` the CNAME records are resolved to their underlying
A/AAAA records at sync time, ExternalDNS performs a DNS lookup for each hostname target and emits the
resulting IP addresses as A and/or AAAA endpoints. If resolution fails (e.g. the hostname is
temporarily unresolvable), that target is silently skipped.

This is useful when a DNS provider does not support CNAME flattening
or ALIAS records and a flat IP address record is required.

### Use Cases for `external-dns.alpha.kubernetes.io/resolve-target` annotation

#### Opt in to resolution for a single Source

Resolve load balancer hostnames to IPs for one Ingress:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: test-ingress
namespace: default
annotations:
external-dns.alpha.kubernetes.io/resolve-target: "true"
Comment thread
Apoorva64 marked this conversation as resolved.
spec:
rules:
- host: svc.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80
status:
loadBalancer:
ingress:
- hostname: example.com
```

> ExternalDNS will resolve the Ingress load balancer hostname and publish A/AAAA records instead of a CNAME.
Let's take an example, "example.com" CNAME resolves to A record "104.20.23.154" and AAAA record "2606:4700:10::ac42:93f3".
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".

## external-dns.kubernetes.io/target

Specifies a comma-separated list of values to override the resource's DNS record targets (RDATA).
Expand Down Expand Up @@ -451,9 +498,9 @@ spec:
## Gateway API Annotation Placement

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

**ListenerSet resources** also support the `target` annotation. When a Route references a ListenerSet
as its parent, the ListenerSet's target annotation takes precedence over the parent Gateway's target annotation.
Expand Down
8 changes: 8 additions & 0 deletions docs/contributing/source-wrappers.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Wrappers solve these key challenges:
|:--------------------:|:----------------------------------------|:----------------------------------------------------|
| `MultiSource` | Combine multiple sources. | Aggregate `Ingress`, `Service`, etc. |
| `DedupSource` | Remove duplicate DNS records. | Avoid duplicate records from sources. |
| `ResolveTarget` | Resolve CNAME targets to A/AAAA values. | Source hostnames into IP records. |
| `TargetFilterSource` | Include/exclude targets based on CIDRs. | Exclude internal IPs. |
| `NAT64Source` | Add NAT64-prefixed AAAA records. | Support IPv6 with NAT64. |
| `PostProcessor` | Add records post-processing. | Configure TTL, filter provider-specific properties. |
Expand Down Expand Up @@ -57,6 +58,12 @@ Converts IPv4 targets to IPv6 using NAT64 prefixes.
--nat64-prefix=64:ff9b::/96
```

### 2.2 `ResolveTarget`

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.

📌 **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`.

### 3.1 `PostProcessor`

Applies post-processing to all endpoints after they are collected from sources.
Expand Down Expand Up @@ -116,6 +123,7 @@ Wrappers are often composed like this:
```go
source := NewMultiSource(actualSources, defaultTargets)
source = NewDedupSource(source)
source = NewResolveTarget(source)
source = NewNAT64Source(source, cfg.NAT64Networks)
source = NewTargetFilterSource(source, targetFilter)
source = NewPostProcessor(source, WithTTL(minTTL), WithPostProcessorPreferAlias(preferAlias))
Expand Down
20 changes: 20 additions & 0 deletions endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,26 @@ func (e *Endpoint) WithSetIdentifier(setIdentifier string) *Endpoint {
return e
}

// WithTargets returns a shallow copy of the endpoint with only Targets and RecordType replaced.
// The RecordType is derived from the first target using SuitableType.
// All other fields remain intact (shared with the original endpoint).
func (e *Endpoint) WithTargets(targets Targets) *Endpoint {
recordType := e.RecordType
if len(targets) > 0 {
recordType = SuitableType(targets[0])
}
return &Endpoint{
DNSName: e.DNSName,
Targets: targets,
RecordType: recordType,
SetIdentifier: e.SetIdentifier,
RecordTTL: e.RecordTTL,
Labels: e.Labels,
ProviderSpecific: e.ProviderSpecific,
refObject: e.refObject,
}
}

// WithProviderSpecific attaches a key/value pair to the Endpoint and returns the Endpoint.
// This can be used to pass additional data through the stages of ExternalDNS's Endpoint processing.
// The assumption is that most of the time this will be provider specific metadata that doesn't
Expand Down
37 changes: 37 additions & 0 deletions endpoint/endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,43 @@ func TestEndpoint_WithLabel(t *testing.T) {
})
}

func TestEndpoint_WithTargets(t *testing.T) {
t.Run("non-empty targets derives record type from first target and preserves other fields", func(t *testing.T) {
labels := Labels{"owner": "team-a"}
providerSpecific := ProviderSpecific{{Name: "foo", Value: "bar"}}
ref := events.NewObjectReferenceFromParts("Service", "", "default", "svc-1", "", "")

ep := &Endpoint{
DNSName: "example.com",
Targets: Targets{"old.example.net"},
RecordType: RecordTypeCNAME,
SetIdentifier: "set-1",
RecordTTL: TTL(300),
Labels: labels,
ProviderSpecific: providerSpecific,
refObject: ref,
}

newTargets := Targets{"1.2.3.4", "example.net"}
got := ep.WithTargets(newTargets)

require.NotNil(t, got)
assert.NotSame(t, ep, got)
assert.Equal(t, "example.com", got.DNSName)
assert.Equal(t, newTargets, got.Targets)
assert.Equal(t, RecordTypeA, got.RecordType)
assert.Equal(t, "set-1", got.SetIdentifier)
assert.Equal(t, TTL(300), got.RecordTTL)
assert.Equal(t, labels, got.Labels)
assert.Equal(t, providerSpecific, got.ProviderSpecific)
assert.Equal(t, ref, got.refObject)

// Original endpoint remains unchanged.
assert.Equal(t, Targets{"old.example.net"}, ep.Targets)
assert.Equal(t, RecordTypeCNAME, ep.RecordType)
})
}

func TestSame_ParseErrorLogged(t *testing.T) {
hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)

Expand Down
4 changes: 4 additions & 0 deletions source/annotations/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ var (
InternalHostnameKey = AnnotationKeyPrefix + "internal-hostname"
// The annotation used for defining the desired hostname source for gateways
GatewayHostnameSourceKey = AnnotationKeyPrefix + "gateway-hostname-source"
// ResolveTargetKey is the per-resource annotation that controls whether
// the resolveTarget Wrapper should resolve the LoadBalancer hostname to IP addresses
ResolveTargetKey = AnnotationKeyPrefix + "resolve-target"
)

// SetAnnotationPrefix sets a custom annotation prefix and rebuilds all annotation keys.
Expand Down Expand Up @@ -109,4 +112,5 @@ func SetAnnotationPrefix(prefix string) {
IngressHostnameSourceKey = AnnotationKeyPrefix + "ingress-hostname-source"
InternalHostnameKey = AnnotationKeyPrefix + "internal-hostname"
GatewayHostnameSourceKey = AnnotationKeyPrefix + "gateway-hostname-source"
ResolveTargetKey = AnnotationKeyPrefix + "resolve-target"
}
1 change: 1 addition & 0 deletions source/annotations/annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func TestSetAnnotationPrefix(t *testing.T) {
assert.Equal(t, "custom.io/endpoints-type", EndpointsTypeKey)
assert.Equal(t, "custom.io/ingress", Ingress)
assert.Equal(t, "custom.io/ingress-hostname-source", IngressHostnameSourceKey)
assert.Equal(t, "custom.io/resolve-target", ResolveTargetKey)

// ControllerValue should remain constant
assert.Equal(t, "dns-controller", ControllerValue)
Expand Down
6 changes: 6 additions & 0 deletions source/annotations/provider_specific.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,11 @@ func ProviderSpecificAnnotations(annotations map[string]string) (endpoint.Provid
}
}
}
if v, ok := annotations[ResolveTargetKey]; ok {
Comment thread
Apoorva64 marked this conversation as resolved.
providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{
Name: "resolve-target",
Value: v,
})
}
return providerSpecificAnnotations, setIdentifier
}
16 changes: 16 additions & 0 deletions source/annotations/provider_specific_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ func TestProviderSpecificAnnotations(t *testing.T) {
},
setIdentifier: "",
},
{
name: "resolve-target annotation",
annotations: map[string]string{
ResolveTargetKey: "true",
},
expected: endpoint.ProviderSpecific{
{Name: "resolve-target", Value: "true"},
},
setIdentifier: "",
},
{
name: "resolve-target annotation absent",
annotations: map[string]string{},
expected: endpoint.ProviderSpecific{},
setIdentifier: "",
},
}

for _, tt := range tests {
Expand Down
7 changes: 6 additions & 1 deletion source/wrappers/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import (
// Build creates all named sources using cfg's ClientGenerator and wraps them
// with the standard pipeline (dedup, optional NAT64, optional target filter,
// post-processor). Inject a custom ClientGenerator via source.WithClientGenerator.
func Build(ctx context.Context, cfg *source.Config) (source.Source, error) {
// Additional Options can be passed to customize the wrapper behavior (e.g., custom lookupIP).
func Build(ctx context.Context, cfg *source.Config, extraOpts ...Option) (source.Source, error) {
sources, err := source.ByNames(ctx, cfg, cfg.ClientGenerator())
if err != nil {
return nil, err
Expand All @@ -42,5 +43,9 @@ func Build(ctx context.Context, cfg *source.Config) (source.Source, error) {
WithPTRSupported(cfg.PTRSupported),
WithCreatePTR(cfg.CreatePTR),
)
// Apply any extra options passed by the caller.
for _, opt := range extraOpts {
opt(opts)
}
return wrapSources(sources, opts)
}
8 changes: 8 additions & 0 deletions source/wrappers/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,11 @@ func TestBuildWrappedSource(t *testing.T) {
})
}
}

func TestBuildAppliesExtraOptions(t *testing.T) {
cfg := stubConfig(t, &externaldns.Config{Sources: []string{types.Fake}})

_, err := Build(t.Context(), cfg, WithNAT64Networks([]string{"not-a-cidr"}))
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to create NAT64 source wrapper")
}
137 changes: 137 additions & 0 deletions source/wrappers/resolvetarget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
Comment thread
Apoorva64 marked this conversation as resolved.
Copyright 2026 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package wrappers

import (
"context"
"net"
"sort"

log "github.com/sirupsen/logrus"

"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/source"
)

const resolveTargetPropertyName = "resolve-target"

// resolveTarget is a Source wrapper that resolves CNAME targets (load balancer hostnames)
// to their underlying A/AAAA IP addresses via DNS lookup.
//
// Resolution is controlled per-endpoint via the "resolve-target" provider-specific property:
//
// "true" – resolve this endpoint's hostname targets to A/AAAA records
// "false" – keep this endpoint's hostname targets as CNAME records
//
// The property is always consumed (deleted) by this wrapper so it does not leak
// to downstream components.
type resolveTarget struct {
source source.Source
lookupIP func(string) ([]net.IP, error)
}

// resolveTargetOption is a functional option for resolveTarget.
type resolveTargetOption func(*resolveTarget)

// WithResolveTargetLookupIP is a helper to create a resolveTargetOption that sets a custom lookupIP function for testing.
// If fn is nil, the default net.LookupIP is preserved.
func WithResolveTargetLookupIP(fn func(string) ([]net.IP, error)) resolveTargetOption {
return func(rs *resolveTarget) {
if fn != nil {
rs.lookupIP = fn
}
}
}

// NewResolveTarget creates a new resolveTarget wrapping src.
func NewResolveTarget(src source.Source, opts ...resolveTargetOption) source.Source {
rs := &resolveTarget{
source: src,
lookupIP: net.LookupIP,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

net.LookupIP is synchronous, system-resolver default timeout, one call per target serially inside RunOnce. Many resolve-target endpoints + a slow resolver stalls the whole reconcile loop.
Wdyt about a context-aware resolver (net.Resolver.LookupIP(ctx, ...)) with bounded timeout ?

}
for _, opt := range opts {
opt(rs)
}
return rs
}

func (rs *resolveTarget) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {
endpoints, err := rs.source.Endpoints(ctx)
if err != nil {
return nil, err
}

result := make([]*endpoint.Endpoint, 0, len(endpoints))
for _, ep := range endpoints {
if ep == nil {
continue
}

// Always consume the "resolve-target" property so it never leaks downstream,
// regardless of record type or value otherwise it won't converge with a non-empty UpdateNew in the plan.
resolve, _ := ep.GetProviderSpecificProperty(resolveTargetPropertyName)
ep.DeleteProviderSpecificProperty(resolveTargetPropertyName)

// Only CNAME endpoints opted in via "true" are resolved; everything else passes through.
if ep.RecordType != endpoint.RecordTypeCNAME || resolve != "true" {
result = append(result, ep)
continue
}

var ipTargets endpoint.Targets
for _, target := range ep.Targets {
ips, err := rs.lookupIP(target)
if err != nil {
log.Debugf("Unable to resolve %q, skipping target: %v", target, err)
continue
}
for _, ip := range ips {
ipTargets = append(ipTargets, ip.String())
}
Comment thread
mloiseleur marked this conversation as resolved.
}
if len(ipTargets) == 0 {
// All resolutions failed; skip this endpoint entirely.
continue
}
Comment on lines +106 to +109

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If all targets transiently fail to resolve, the endpoint vanishes from desired state → plan computes a delete → DNS record removed → outage, then recreated next sync when DNS recovers.
This is record-flapping driven by resolver health.

Do you think that, on total resolution failure, it can keep the previous/CNAME endpoint (skip the change) rather than emitting nothing ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A test like this would confirm that behavior:

  func TestTotalFailureKeepsCNAME(t *testing.T) {
        ep := endpoint.NewEndpoint("app.example.internal", endpoint.RecordTypeCNAME, "lb.example.com")
        ep.WithProviderSpecific(resolveTargetPropertyName, "true")

        ms := new(testutils.MockSource)
        ms.On("Endpoints").Return([]*endpoint.Endpoint{ep}, nil)
        src := NewResolveTarget(ms, WithResolveTargetLookupIP(
                func(string) ([]net.IP, error) { return nil, errors.New("i/o timeout") }, // transient
        ))

        got, err := src.Endpoints(t.Context())
        require.NoError(t, err)

        // Expected: original CNAME preserved, not dropped.
        require.Len(t, got, 1, "endpoint must be kept when resolution totally fails")
        require.Equal(t, endpoint.RecordTypeCNAME, got[0].RecordType, "should fall back to the original CNAME")
        require.Equal(t, endpoint.Targets{"lb.example.com"}, got[0].Targets)

        // Property still consumed so it does not leak downstream.
        _, ok := got[0].GetProviderSpecificProperty(resolveTargetPropertyName)
        require.False(t, ok, "resolve-target property should be consumed even on the fallback path")
  }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the host returns no IPs, why would you want to preserve the record? That's a signal something is wrong - and we don't know whether the host is being reprovisioned, permanently removed, or just experiencing a transient network issue.

This comes down to reconciliation interval tuning. The current service lookup is straightforward - no special logic, just binary: resolved or not. Currnet service lookup

ips, err := net.LookupIP(lb.Hostname)
no magic, just binary - either resolved or not.

There's no clean answer here. Either you keep a record that points nowhere - which is going to be painful to debug - or you remove it when it stops resolving. Both options have real downsides.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@ivankatliarchuk Yes, that's true. Maybe we can improve UserXP, though.

Wdyt of documenting this limitation and log a warning or a SoftError ?


// Sort targets before grouping for deterministic output.
sort.Strings(ipTargets)

// Group targets by record type (A vs AAAA)
byType := map[string]endpoint.Targets{}
for _, t := range ipTargets {
rt := endpoint.SuitableType(t)
byType[rt] = append(byType[rt], t)
}

// Emit one endpoint per record type with the same DNSName and provider-specific properties.
for _, rt := range []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA} {
if targets := byType[rt]; len(targets) > 0 {
result = append(result, ep.WithTargets(targets))
}
}

log.Debugf("resolveTarget: resolved endpoint %q into %d IP target(s)", ep.DNSName, len(ipTargets))
}

return result, nil
}

func (rs *resolveTarget) AddEventHandler(ctx context.Context, handler func()) {
log.Debug("resolveTarget: adding event handler")
rs.source.AddEventHandler(ctx, handler)
}
Loading