Skip to content
Merged
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
1 change: 1 addition & 0 deletions controller/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ func buildSource(ctx context.Context, cfg *source.Config) (source.Source, error)
wrappers.WithTargetNetFilter(cfg.TargetNetFilter),
wrappers.WithExcludeTargetNets(cfg.ExcludeTargetNets),
wrappers.WithMinTTL(cfg.MinTTL),
wrappers.WithProvider(cfg.Provider),
wrappers.WithPreferAlias(cfg.PreferAlias))
return wrappers.WrapSources(sources, opts)
}
Expand Down
30 changes: 23 additions & 7 deletions docs/contributing/source-wrappers.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ Wrappers solve these key challenges:

## Built In Wrappers

| Wrapper | Purpose | Use Case |
|:--------------------:|:----------------------------------------|:--------------------------------------|
| `MultiSource` | Combine multiple sources. | Aggregate `Ingress`, `Service`, etc. |
| `DedupSource` | Remove duplicate DNS records. | Avoid duplicate records from sources. |
| `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 for all endpoints. |
| Wrapper | Purpose | Use Case |
|:--------------------:|:----------------------------------------|:----------------------------------------------------|
| `MultiSource` | Combine multiple sources. | Aggregate `Ingress`, `Service`, etc. |
| `DedupSource` | Remove duplicate DNS records. | Avoid duplicate records from sources. |
| `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. |

### Use Cases

Expand All @@ -56,6 +56,22 @@ Converts IPv4 targets to IPv6 using NAT64 prefixes.
--nat64-prefix=64:ff9b::/96
```

### 3.1 `PostProcessor`

Applies post-processing to all endpoints after they are collected from sources.

📌 **Use case**

- Sets a minimum TTL on endpoints that have no TTL or a TTL below the configured minimum.
- Filters `ProviderSpecific` properties to retain only those belonging to the configured provider (e.g. `aws/evaluate-target-health` when provider is `aws`). Properties with no provider prefix (e.g. `alias`) are considered provider-agnostic and are always retained.
- Sets the `alias=true` provider-specific property on `CNAME` endpoints when `--prefer-alias` is enabled, signalling providers that support ALIAS records (e.g. PowerDNS, AWS) to use them instead of CNAMEs. Per-resource annotations already present are not overwritten.

```yaml
--min-ttl=60s
--provider=aws
--prefer-alias
```

---

## How Wrappers Work
Expand Down
25 changes: 25 additions & 0 deletions endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ limitations under the License.
package endpoint

import (
"cmp"
"fmt"
"net/netip"
"slices"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -368,6 +370,29 @@ func (e *Endpoint) DeleteProviderSpecificProperty(key string) {
}
}

// RetainProviderProperties retains only properties whose name is prefixed with
// "provider/" (e.g. "aws/evaluate-target-health" for provider "aws").
// Properties belonging to other providers are dropped.
// Properties with no provider prefix (e.g. "alias") are provider-agnostic and always retained.
// TODO: cloudflare does not follow the "provider/" prefix convention — its properties use the
// annotation form "external-dns.alpha.kubernetes.io/cloudflare-*", so filtering is skipped for
// cloudflare and all properties are retained (only sorted). This should be removed once cloudflare
// adopts the standard prefix convention.
func (e *Endpoint) RetainProviderProperties(provider string) {
if len(e.ProviderSpecific) == 0 {
return
}
if provider != "" && provider != "cloudflare" {
prefix := provider + "/"
e.ProviderSpecific = slices.DeleteFunc(e.ProviderSpecific, func(prop ProviderSpecificProperty) bool {
return strings.Contains(prop.Name, "/") && !strings.HasPrefix(prop.Name, prefix)
})
}
slices.SortFunc(e.ProviderSpecific, func(a, b ProviderSpecificProperty) int {
return cmp.Compare(a.Name, b.Name)
})
}

// WithLabel adds or updates a label for the Endpoint.
//
// Example usage:
Expand Down
136 changes: 136 additions & 0 deletions endpoint/endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,142 @@ func TestDeleteProviderSpecificProperty(t *testing.T) {
}
}

func TestRetainProviderProperties(t *testing.T) {
cases := []struct {
name string
endpoint Endpoint
provider string
expected []ProviderSpecificProperty
}{
{
name: "empty provider specific",
endpoint: Endpoint{},
provider: "aws",
expected: nil,
},
{
name: "empty provider, properties untouched",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "aws/evaluate-target-health", Value: "true"},
{Name: "coredns/group", Value: "my-group"},
},
},
provider: "",
expected: []ProviderSpecificProperty{
{Name: "aws/evaluate-target-health", Value: "true"},
{Name: "coredns/group", Value: "my-group"},
},
},
{
name: "all properties match provider",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "aws/evaluate-target-health", Value: "true"},
{Name: "aws/weight", Value: "10"},
},
},
provider: "aws",
expected: []ProviderSpecificProperty{
{Name: "aws/evaluate-target-health", Value: "true"},
{Name: "aws/weight", Value: "10"},
},
},
{
name: "no properties match provider",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "coredns/group", Value: "my-group"},
},
},
provider: "aws",
expected: []ProviderSpecificProperty{},
},
{
name: "mixed providers, only configured provider retained",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "aws/evaluate-target-health", Value: "true"},
{Name: "coredns/group", Value: "my-group"},
{Name: "aws/weight", Value: "10"},
},
},
provider: "aws",
expected: []ProviderSpecificProperty{
{Name: "aws/evaluate-target-health", Value: "true"},
{Name: "aws/weight", Value: "10"},
},
},
{
name: "provider agnostic properties without prefix are retained",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "alias", Value: "true"},
{Name: "aws/evaluate-target-health", Value: "true"},
{Name: "coredns/group", Value: "my-group"},
},
},
provider: "aws",
expected: []ProviderSpecificProperty{
{Name: "alias", Value: "true"},
{Name: "aws/evaluate-target-health", Value: "true"},
},
},
{
name: "provider prefix must match exactly, not as substring",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "aws-extended/some-prop", Value: "val"},
{Name: "aws/weight", Value: "10"},
},
},
provider: "aws",
expected: []ProviderSpecificProperty{
{Name: "aws/weight", Value: "10"},
},
},
// cloudflare uses annotation-style names (e.g. "external-dns.alpha.kubernetes.io/cloudflare-*")
// rather than the standard "provider/" prefix, so all properties are retained and only sorted.
{
name: "cloudflare retains all properties",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"},
{Name: "aws/evaluate-target-health", Value: "true"},
{Name: "alias", Value: "false"},
},
},
provider: "cloudflare",
expected: []ProviderSpecificProperty{
{Name: "alias", Value: "false"},
{Name: "aws/evaluate-target-health", Value: "true"},
{Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"},
},
},
{
name: "cloudflare properties are sorted",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true"},
{Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"},
},
},
provider: "cloudflare",
expected: []ProviderSpecificProperty{
{Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true"},
{Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"},
},
},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
c.endpoint.RetainProviderProperties(c.provider)
require.Equal(t, c.expected, []ProviderSpecificProperty(c.endpoint.ProviderSpecific))
})
}
}

func TestFilterEndpointsByOwnerIDWithRecordTypeA(t *testing.T) {
foo1 := &Endpoint{
DNSName: "foo.com",
Expand Down
5 changes: 5 additions & 0 deletions source/annotations/provider_specific.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ func ProviderSpecificAnnotations(annotations map[string]string) (endpoint.Provid
Value: v,
})
} else if strings.HasPrefix(k, CloudflarePrefix) {
// TODO: unlike other providers which normalise to "provider/attr",
// Cloudflare retains the full annotation key as the property name
// (e.g. "external-dns.alpha.kubernetes.io/cloudflare-proxied").
// This is why RetainProviderProperties has a special case for cloudflare.
// Should be aligned with the standard convention in a future change.
switch {
case strings.Contains(k, CloudflareCustomHostnameKey):
providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{
Expand Down
49 changes: 49 additions & 0 deletions source/annotations/provider_specific_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package annotations

import (
"strconv"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -90,6 +91,15 @@ func TestProviderSpecificAnnotations(t *testing.T) {
result, setIdentifier := ProviderSpecificAnnotations(tt.annotations)
assert.Equal(t, tt.expected, result)
assert.Equal(t, tt.setIdentifier, setIdentifier)

for _, prop := range result {
slashIdx := strings.Index(prop.Name, "/")
if slashIdx == -1 || strings.HasPrefix(prop.Name, CloudflarePrefix) {
continue
}
assert.NotContains(t, prop.Name[:slashIdx], ".",
"property %q uses a full annotation name; only cloudflare is allowed to — use the short \"provider/attr\" form instead", prop.Name)
}
})
}
}
Expand Down Expand Up @@ -313,6 +323,45 @@ func TestGetProviderSpecificAliasAnnotations(t *testing.T) {
}
}

// TestProviderSpecificPropertyNameConvention enforces that only Cloudflare may
// emit the full annotation name (e.g. "external-dns.alpha.kubernetes.io/cloudflare-proxied")
// as a property name. All other providers must normalise to the short "provider/attr" form
// (e.g. "aws/weight"). If a new provider (e.g. azure-, ovh-) is added but accidentally
// outputs the full annotation name, this test will catch it.
func TestProviderSpecificPropertyNameConvention(t *testing.T) {
annotations := map[string]string{
AnnotationKeyPrefix + "aws-weight": "10",
AnnotationKeyPrefix + "scw-something": "val",
AnnotationKeyPrefix + "webhook-something": "val",
AnnotationKeyPrefix + "coredns-group": "g1",
CloudflareProxiedKey: "true",
CloudflareTagsKey: "tag1",
CloudflareRegionKey: "us",
CloudflareRecordCommentKey: "comment",
CloudflareCustomHostnameKey: "host.example.com",
AliasKey: "true",
}

props, _ := ProviderSpecificAnnotations(annotations)
for _, prop := range props {
name := prop.Name
slashIdx := strings.Index(name, "/")
if slashIdx == -1 {
// No slash: provider-agnostic property (e.g. "alias") — always OK.
continue
}
// Cloudflare exception: retains the full annotation name.
if strings.HasPrefix(name, CloudflarePrefix) {
continue
}
// All other providers must use the short "provider/attr" form.
// The segment before "/" must be a plain word with no dots.
providerSegment := name[:slashIdx]
assert.NotContains(t, providerSegment, ".",
"property %q uses a full annotation name; only cloudflare is allowed to — use the short \"provider/attr\" form instead", name)
}
}

func TestGetProviderSpecificIdentifierAnnotations(t *testing.T) {
for _, tc := range []struct {
title string
Expand Down
2 changes: 2 additions & 0 deletions source/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type Config struct {
GatewayNamespace string
GatewayLabelFilter string
Compatibility string
Provider string
PodSourceDomain string
PublishInternal bool
PublishHostIP bool
Expand Down Expand Up @@ -132,6 +133,7 @@ func NewSourceConfig(cfg *externaldns.Config) *Config {
PodSourceDomain: cfg.PodSourceDomain,
PublishInternal: cfg.PublishInternal,
PublishHostIP: cfg.PublishHostIP,
Provider: cfg.Provider,
AlwaysPublishNotReadyAddresses: cfg.AlwaysPublishNotReadyAddresses,
ConnectorServer: cfg.ConnectorSourceServer,
CRDSourceAPIVersion: cfg.CRDSourceAPIVersion,
Expand Down
14 changes: 14 additions & 0 deletions source/wrappers/post_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package wrappers

import (
"context"
"strings"
"time"

log "github.com/sirupsen/logrus"
Expand All @@ -34,6 +35,7 @@ type postProcessor struct {

type PostProcessorConfig struct {
ttl int64
provider string
preferAlias bool
isConfigured bool
}
Expand All @@ -49,6 +51,17 @@ func WithTTL(ttl time.Duration) PostProcessorOption {
}
}

// WithPostProcessorProvider sets the provider used to retain provider-specific
// properties on endpoints. Empty or whitespace-only values are ignored.
func WithPostProcessorProvider(input string) PostProcessorOption {
return func(cfg *PostProcessorConfig) {
if p := strings.TrimSpace(input); p != "" {
cfg.isConfigured = true
cfg.provider = p
}
}
}

// WithPostProcessorPreferAlias enables setting alias=true on CNAME endpoints.
// This signals to providers that support ALIAS records (like PowerDNS, AWS)
// to create ALIAS records instead of CNAMEs.
Expand Down Expand Up @@ -84,6 +97,7 @@ func (pp *postProcessor) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e
continue
}
ep.WithMinTTL(pp.cfg.ttl)
ep.RetainProviderProperties(pp.cfg.provider)
// Set alias annotation for CNAME records when preferAlias is enabled
// Only set if not already explicitly configured at the source level
if pp.cfg.preferAlias && ep.RecordType == endpoint.RecordTypeCNAME {
Expand Down
Loading
Loading