feat(source): add min-ttl support (#5641)

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>

* feat(source): add min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source): add min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* feat(source/min-ttl): added min-ttl support

Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>

* feat(source): add min-ttl support

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
Co-authored-by: Michel Loiseleur <97035654+mloiseleur@users.noreply.github.com>
This commit is contained in:
Ivan Ka 2025-09-10 10:28:00 +01:00 committed by GitHub
parent 90ed615ad5
commit 7792e78eba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 357 additions and 6 deletions

View File

@ -456,6 +456,7 @@ func buildSource(ctx context.Context, cfg *externaldns.Config) (source.Source, e
combinedSource = wrappers.NewTargetFilterSource(combinedSource, targetFilter)
cfg.AddSourceWrapper("target-filter")
}
combinedSource = wrappers.NewPostProcessor(combinedSource, wrappers.WithTTL(cfg.MinTTL))
return combinedSource, nil
}

View File

@ -1,9 +1,15 @@
# Configure DNS record TTL (Time-To-Live)
An optional annotation `external-dns.alpha.kubernetes.io/ttl` is available to customize the TTL value of a DNS record.
TTL is specified as an integer encoded as string representing seconds.
> To customize DNS record TTL (Time-To-Live) in a DNS record`, you can use the `external-dns.alpha.kubernetes.io/ttl: <duration>` annotation or flag `--min-ttl=<duration>`. TTL is specified as an integer encoded as string representing seconds. Example; `1s`, `1m2s`, `1h2m11s`
To configure it, simply annotate a service/ingress, e.g.:
Behaviour:
- If the `external-dns.alpha.kubernetes.io/ttl` annotation is set, it overrides the default TTL(0) value.
- If the annotation is not set, the default TTL value is used, unless the `--min-ttl` flag is provided.
- If the annotation is set to `0`, and the `--min-ttl=1s` flag is provided, the value from `--min-ttl` will be used instead.
- Not all providers support the custom TTL value, and some may override it with their own default values.
To configure it, annotate a service/ingress, e.g.:
```yaml
apiVersion: v1
@ -140,7 +146,7 @@ The Linode Provider default TTL is used when the TTL is 0. The default is 24 hou
The TransIP Provider minimal TTL is used when the TTL is 0. The minimal TTL is 60s.
## Use Cases for `external-dns.alpha.kubernetes.io/ttl` annotation
## Use Cases for `external-dns.alpha.kubernetes.io/ttl` annotation and `--min-ttl` flag`
The `external-dns.alpha.kubernetes.io/ttl` annotation allows you to set a custom **TTL (Time To Live)** for DNS records managed by `external-dns`.

View File

@ -175,7 +175,9 @@ are published as CNAME records.
Specifies the TTL (time to live) for the resource's DNS records.
The value may be specified as either a duration or an integer number of seconds.
It must be between 1 and 2,147,483,647 seconds.
It must be between `1` and `2,147,483,647` seconds.
> Note; setting the value to `0` means, that TTL is not configured and thus use default.
## Provider-specific annotations

View File

@ -31,6 +31,7 @@ Wrappers solve these key challenges:
| `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. |
### Use Cases

View File

@ -174,6 +174,7 @@
| `--[no-]once` | When enabled, exits the synchronization loop after the first iteration (default: disabled) |
| `--[no-]dry-run` | When enabled, prints DNS record changes rather than actually performing them (default: disabled) |
| `--[no-]events` | When enabled, in addition to running every interval, the reconciliation loop will get triggered when supported sources change (default: disabled) |
| `--min-ttl=MIN-TTL` | Configure global TTL for records in duration format. This value is used when the TTL for a source is not set or set to 0. (optional; examples: 1m12s, 72s, 72) |
| `--log-format=text` | The format in which log messages are printed (default: text, options: text, json) |
| `--metrics-address=":7979"` | Specify where to serve the metrics and health check endpoint (default: :7979) |
| `--log-level=info` | Set the level of logging. (default: info, options: panic, debug, info, warning, error, fatal) |

View File

@ -423,6 +423,14 @@ func (e *Endpoint) CheckEndpoint() bool {
return true
}
// WithMinTTL sets the endpoint's TTL to the given value if the current TTL is not configured.
func (e *Endpoint) WithMinTTL(ttl int64) {
if !e.RecordTTL.IsConfigured() && ttl > 0 {
log.Debugf("Overriding existing TTL %d with new value %d for endpoint %s", e.RecordTTL, ttl, e.DNSName)
e.RecordTTL = TTL(ttl)
}
}
// NewMXRecord parses a string representation of an MX record target (e.g., "10 mail.example.com")
// and returns an MXTarget struct. Returns an error if the input is invalid.
func NewMXRecord(target string) (*MXTarget, error) {

View File

@ -989,3 +989,50 @@ func TestTargets_UniqueOrdered(t *testing.T) {
})
}
}
func TestEndpoint_WithMinTTL(t *testing.T) {
tests := []struct {
name string
initialTTL TTL
inputTTL int64
expectedTTL TTL
isConfigured bool
}{
{
name: "sets TTL when not configured and input > 0",
initialTTL: 0,
inputTTL: 300,
expectedTTL: 300,
isConfigured: true,
},
{
name: "does not override when already configured",
initialTTL: 120,
inputTTL: 300,
expectedTTL: 120,
isConfigured: true,
},
{
name: "does not set when input is zero",
initialTTL: 30,
inputTTL: 0,
expectedTTL: 30,
isConfigured: true,
},
{
name: "does not set when input is negative",
initialTTL: 0,
inputTTL: -10,
expectedTTL: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ep := &Endpoint{RecordTTL: tt.initialTTL}
ep.WithMinTTL(tt.inputTTL)
assert.Equal(t, tt.expectedTTL, ep.RecordTTL)
assert.Equal(t, tt.isConfigured, ep.RecordTTL.IsConfigured())
})
}
}

View File

@ -149,6 +149,7 @@ type Config struct {
TXTEncryptAESKey string `secure:"yes"`
Interval time.Duration
MinEventSyncInterval time.Duration
MinTTL time.Duration
Once bool
DryRun bool
UpdateEvents bool
@ -720,6 +721,7 @@ func App(cfg *Config) *kingpin.Application {
app.Flag("once", "When enabled, exits the synchronization loop after the first iteration (default: disabled)").BoolVar(&cfg.Once)
app.Flag("dry-run", "When enabled, prints DNS record changes rather than actually performing them (default: disabled)").BoolVar(&cfg.DryRun)
app.Flag("events", "When enabled, in addition to running every interval, the reconciliation loop will get triggered when supported sources change (default: disabled)").BoolVar(&cfg.UpdateEvents)
app.Flag("min-ttl", "Configure global TTL for records in duration format. This value is used when the TTL for a source is not set or set to 0. (optional; examples: 1m12s, 72s, 72)").DurationVar(&cfg.MinTTL)
// Miscellaneous flags
app.Flag("log-format", "The format in which log messages are printed (default: text, options: text, json)").Default(defaultConfig.LogFormat).EnumVar(&cfg.LogFormat, "text", "json")

View File

@ -0,0 +1,82 @@
/*
Copyright 2025 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"
"time"
log "github.com/sirupsen/logrus"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/source"
)
type postProcessor struct {
source source.Source
cfg PostProcessorConfig
}
type PostProcessorConfig struct {
ttl int64
isConfigured bool
}
type PostProcessorOption func(*PostProcessorConfig)
func WithTTL(ttl time.Duration) PostProcessorOption {
return func(cfg *PostProcessorConfig) {
if int64(ttl.Seconds()) > 0 {
cfg.isConfigured = true
cfg.ttl = int64(ttl.Seconds())
}
}
}
func NewPostProcessor(source source.Source, opts ...PostProcessorOption) source.Source {
cfg := PostProcessorConfig{}
for _, opt := range opts {
opt(&cfg)
}
return &postProcessor{source: source, cfg: cfg}
}
func (pp *postProcessor) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {
endpoints, err := pp.source.Endpoints(ctx)
if err != nil {
return nil, err
}
if !pp.cfg.isConfigured {
return endpoints, nil
}
for _, ep := range endpoints {
if ep == nil {
continue
}
ep.WithMinTTL(pp.cfg.ttl)
// Additional post-processing can be added here.
}
return endpoints, nil
}
func (pp *postProcessor) AddEventHandler(ctx context.Context, handler func()) {
log.Debug("postProcessor: adding event handler")
pp.source.AddEventHandler(ctx, handler)
}

View File

@ -0,0 +1,192 @@
/*
Copyright 2025 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"
"testing"
"time"
"github.com/stretchr/testify/require"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/internal/testutils"
)
type mockSource struct {
endpoints []*endpoint.Endpoint
err error
}
func TestWithTTL(t *testing.T) {
tests := []struct {
name string
ttlStr string
expectErr bool
expectTTL int64
isConfigured bool
}{
{
name: "valid 10m6s",
ttlStr: "10m6s",
expectErr: false,
expectTTL: 606,
isConfigured: true,
},
{
name: "valid 5m",
ttlStr: "5m",
expectTTL: 300,
isConfigured: true,
},
{
name: "zero duration",
ttlStr: "0s",
expectTTL: 0,
},
{
name: "empty duration",
ttlStr: "0s",
expectTTL: 0,
},
{
name: "invalid duration",
ttlStr: "notaduration",
expectErr: true,
expectTTL: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &PostProcessorConfig{}
ttl, err := time.ParseDuration(tt.ttlStr)
if tt.expectErr {
require.Error(t, err, "should fail to parse duration string")
return
}
require.NoError(t, err, "should parse duration string")
opt := WithTTL(ttl)
opt(cfg)
require.Equal(t, tt.isConfigured, cfg.isConfigured, "isConfigured mismatch")
require.Equal(t, tt.expectTTL, cfg.ttl, "ttl mismatch")
})
}
}
func TestPostProcessorEndpointsWithTTL(t *testing.T) {
tests := []struct {
title string
ttl string
endpoints []*endpoint.Endpoint
expected []*endpoint.Endpoint
expectErr bool
}{
{
title: "process endpoints with TTL set",
ttl: "6s",
endpoints: []*endpoint.Endpoint{
endpoint.NewEndpoint("foo-1", "A", "1.2.3.4"),
endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"),
endpoint.NewEndpointWithTTL("foo-3", "A", 0, "1.2.3.6"),
},
expected: []*endpoint.Endpoint{
endpoint.NewEndpointWithTTL("foo-1", "A", 6, "1.2.3.4"),
endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"),
endpoint.NewEndpointWithTTL("foo-3", "A", 6, "1.2.3.6"),
},
},
{
title: "skip endpoints processing with TTL set to 0",
ttl: "0s",
endpoints: []*endpoint.Endpoint{
endpoint.NewEndpoint("foo-1", "A", "1.2.3.4"),
endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"),
endpoint.NewEndpointWithTTL("foo-3", "A", 0, "1.2.3.6"),
},
expected: []*endpoint.Endpoint{
endpoint.NewEndpoint("foo-1", "A", "1.2.3.4"),
endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"),
endpoint.NewEndpointWithTTL("foo-3", "A", 0, "1.2.3.6"),
},
},
{
title: "skip endpoints processing for nill endpoint",
ttl: "0s",
endpoints: []*endpoint.Endpoint{
nil,
endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"),
},
expected: []*endpoint.Endpoint{
nil,
endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"),
},
},
{
title: "endpoint foo-2 with TTL configured while foo-1 without TTL configured",
ttl: "1s",
endpoints: []*endpoint.Endpoint{
{DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.5"}},
{DNSName: "foo-2", Targets: endpoint.Targets{"1.2.3.6"}, RecordTTL: endpoint.TTL(0)},
},
expected: []*endpoint.Endpoint{
{DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.5"}, RecordTTL: endpoint.TTL(1)},
{DNSName: "foo-2", Targets: endpoint.Targets{"1.2.3.6"}, RecordTTL: endpoint.TTL(1)},
},
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
ms := new(testutils.MockSource)
ms.On("Endpoints").Return(tt.endpoints, nil)
ttl, _ := time.ParseDuration(tt.ttl)
src := NewPostProcessor(ms, WithTTL(ttl))
endpoints, err := src.Endpoints(context.Background())
require.NoError(t, err)
validateEndpoints(t, endpoints, tt.expected)
})
}
}
func TestPostProcessor_AddEventHandler(t *testing.T) {
tests := []struct {
title string
input []string
times int
}{
{
title: "should add event handler",
times: 1,
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
mockSource := testutils.NewMockSource()
src := NewPostProcessor(mockSource)
src.AddEventHandler(t.Context(), func() {})
mockSource.AssertNumberOfCalls(t, "AddEventHandler", tt.times)
})
}
}

View File

@ -26,11 +26,16 @@ import (
func sortEndpoints(endpoints []*endpoint.Endpoint) {
for _, ep := range endpoints {
sort.Strings([]string(ep.Targets))
if ep != nil {
ep.Targets = endpoint.NewTargets(ep.Targets...)
}
}
sort.Slice(endpoints, func(i, k int) bool {
// Sort by DNSName, RecordType, and Targets
ei, ek := endpoints[i], endpoints[k]
if ei == nil || ek == nil {
return true
}
if ei.DNSName != ek.DNSName {
return ei.DNSName < ek.DNSName
}
@ -69,6 +74,10 @@ func validateEndpoints(t *testing.T, endpoints, expected []*endpoint.Endpoint) {
func validateEndpoint(t *testing.T, endpoint, expected *endpoint.Endpoint) {
t.Helper()
if endpoint == nil && expected == nil {
return
}
if endpoint.DNSName != expected.DNSName {
t.Errorf("DNSName expected %q, got %q", expected.DNSName, endpoint.DNSName)
}