mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2026-05-04 14:21:33 +02:00
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:
parent
90ed615ad5
commit
7792e78eba
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) |
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
82
source/wrappers/post_processor.go
Normal file
82
source/wrappers/post_processor.go
Normal 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)
|
||||
}
|
||||
192
source/wrappers/post_processor_test.go
Normal file
192
source/wrappers/post_processor_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user