diff --git a/controller/execute.go b/controller/execute.go index a69a1a7ae..2278178f7 100644 --- a/controller/execute.go +++ b/controller/execute.go @@ -430,6 +430,8 @@ func buildSource(ctx context.Context, cfg *externaldns.Config) (source.Source, e targetFilter := endpoint.NewTargetNetFilterWithExclusions(cfg.TargetNetFilter, cfg.ExcludeTargetNets) combinedSource = wrappers.NewNAT64Source(combinedSource, cfg.NAT64Networks) combinedSource = wrappers.NewTargetFilterSource(combinedSource, targetFilter) + // should be the last step, so that the post-processed modifications applied + combinedSource = wrappers.NewPostProcessor(combinedSource, wrappers.WithTTL(cfg.MinTTL)) return combinedSource, nil } diff --git a/docs/advanced/ttl.md b/docs/advanced/ttl.md index 4dff4e6f3..de0809b46 100644 --- a/docs/advanced/ttl.md +++ b/docs/advanced/ttl.md @@ -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: ` annotation or flag `--min-ttl=`. 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`. diff --git a/docs/flags.md b/docs/flags.md index 59f928882..5cbddd3d5 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -173,6 +173,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 TTL for records in duration format. This value will be used if the TTL for a source is not set. (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) | diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index 68f82378a..fc479e840 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -415,6 +415,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) { diff --git a/endpoint/endpoint_test.go b/endpoint/endpoint_test.go index d87aaab3c..4aa63936d 100644 --- a/endpoint/endpoint_test.go +++ b/endpoint/endpoint_test.go @@ -968,3 +968,50 @@ func TestEndpoint_UniqueOrderedTargets(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()) + }) + } +} diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index c4df4e35f..fd93c7082 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -147,6 +147,7 @@ type Config struct { TXTEncryptAESKey string `secure:"yes"` Interval time.Duration MinEventSyncInterval time.Duration + MinTTL time.Duration Once bool DryRun bool UpdateEvents bool @@ -632,6 +633,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 TTL for records in duration format. This value will be used if the TTL for a source is not set. (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") diff --git a/source/wrappers/post_processor.go b/source/wrappers/post_processor.go new file mode 100644 index 000000000..811ff127d --- /dev/null +++ b/source/wrappers/post_processor.go @@ -0,0 +1,76 @@ +/* +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" + + "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 { + ep.WithMinTTL(pp.cfg.ttl) + // Additional post-processing can be added here. + } + + return endpoints, nil +} + +func (pp *postProcessor) AddEventHandler(_ context.Context, handler func()) { + +} diff --git a/source/wrappers/post_processor_test.go b/source/wrappers/post_processor_test.go new file mode 100644 index 000000000..534541d68 --- /dev/null +++ b/source/wrappers/post_processor_test.go @@ -0,0 +1,169 @@ +/* +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" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "sigs.k8s.io/external-dns/endpoint" +) + +type mockSource struct { + endpoints []*endpoint.Endpoint + err error +} + +func (m *mockSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { + for _, ep := range m.endpoints { + if ep == nil { + return m.endpoints, fmt.Errorf("skipped nil endpoint") + } + } + return m.endpoints, m.err +} +func (m *mockSource) AddEventHandler(_ context.Context, _ func()) {} + +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 as nill endpoint detected", + 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"), + }, + expectErr: true, + }, + } + for _, tt := range tests { + + t.Run(tt.title, func(t *testing.T) { + + ms := mockSource{endpoints: tt.endpoints} + ttl, _ := time.ParseDuration(tt.ttl) + src := NewPostProcessor(&ms, WithTTL(ttl)) + + endpoints, err := src.Endpoints(context.Background()) + if tt.expectErr { + require.Error(t, err, "expected error for test case: %s", tt.title) + return + } + validateEndpoints(t, endpoints, tt.expected) + }) + } +}