Skip to content

Commit 9bb899c

Browse files
authored
fix(provider): normalize alias=A/AAAA to alias=true after AdjustEndpoints (#6454)
* fix(provider): normalize alias=A/AAAA to alias=true after AdjustEndpoints Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * refactor(provider): move AliasNormalizingProvider wrapping into factory.Select Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * refactor(provider): rename AliasNormalizingProvider to AliasNormalizingMiddleware Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * test(factory): restore inner provider type assertions after middleware wrapping Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * refactor(factory): move AliasNormalizingMiddleware into factory package Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> --------- Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com>
1 parent 8339b0d commit 9bb899c

4 files changed

Lines changed: 165 additions & 4 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package factory
18+
19+
import (
20+
"sigs.k8s.io/external-dns/endpoint"
21+
"sigs.k8s.io/external-dns/provider"
22+
)
23+
24+
// AliasNormalizingMiddleware wraps a Provider and normalizes alias ProviderSpecific
25+
// values after AdjustEndpoints. Providers may convert CNAME endpoints to A/AAAA
26+
// alias records but leave the alias property as "A" or "AAAA", while Records()
27+
// always returns "true" for alias records. This mismatch causes the plan to
28+
// generate a spurious update on every reconciliation loop.
29+
type AliasNormalizingMiddleware struct {
30+
provider.Provider
31+
}
32+
33+
func newAliasNormalizingMiddleware(p provider.Provider) *AliasNormalizingMiddleware {
34+
return &AliasNormalizingMiddleware{Provider: p}
35+
}
36+
37+
// AdjustEndpoints delegates to the inner provider then normalizes alias values
38+
// so they match what Records() returns.
39+
func (p *AliasNormalizingMiddleware) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
40+
eps, err := p.Provider.AdjustEndpoints(endpoints)
41+
if err != nil {
42+
return nil, err
43+
}
44+
for _, ep := range eps {
45+
alias := ep.GetAliasProperty()
46+
if (ep.RecordType == endpoint.RecordTypeA && alias == endpoint.AliasA) ||
47+
(ep.RecordType == endpoint.RecordTypeAAAA && alias == endpoint.AliasAAAA) {
48+
ep.SetProviderSpecificProperty(endpoint.ProviderSpecificAlias, string(endpoint.AliasTrue))
49+
}
50+
}
51+
return eps, nil
52+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package factory
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
26+
"sigs.k8s.io/external-dns/endpoint"
27+
"sigs.k8s.io/external-dns/plan"
28+
"sigs.k8s.io/external-dns/provider"
29+
)
30+
31+
type stubProvider struct {
32+
provider.BaseProvider
33+
}
34+
35+
func (s *stubProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { return nil, nil }
36+
func (s *stubProvider) ApplyChanges(_ context.Context, _ *plan.Changes) error { return nil }
37+
func (s *stubProvider) AdjustEndpoints(eps []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
38+
return eps, nil
39+
}
40+
41+
func TestAliasNormalizingMiddleware(t *testing.T) {
42+
tests := []struct {
43+
name string
44+
recordType string
45+
aliasIn endpoint.AliasType
46+
aliasWanted endpoint.AliasType
47+
}{
48+
{
49+
name: "A with alias=A normalized to true",
50+
recordType: endpoint.RecordTypeA,
51+
aliasIn: endpoint.AliasA,
52+
aliasWanted: endpoint.AliasTrue,
53+
},
54+
{
55+
name: "AAAA with alias=AAAA normalized to true",
56+
recordType: endpoint.RecordTypeAAAA,
57+
aliasIn: endpoint.AliasAAAA,
58+
aliasWanted: endpoint.AliasTrue,
59+
},
60+
{
61+
name: "A with alias=true unchanged",
62+
recordType: endpoint.RecordTypeA,
63+
aliasIn: endpoint.AliasTrue,
64+
aliasWanted: endpoint.AliasTrue,
65+
},
66+
{
67+
name: "A with alias=AAAA not touched",
68+
recordType: endpoint.RecordTypeA,
69+
aliasIn: endpoint.AliasAAAA,
70+
aliasWanted: endpoint.AliasAAAA,
71+
},
72+
{
73+
name: "AAAA with alias=A not touched",
74+
recordType: endpoint.RecordTypeAAAA,
75+
aliasIn: endpoint.AliasA,
76+
aliasWanted: endpoint.AliasA,
77+
},
78+
{
79+
name: "A with alias=false unchanged",
80+
recordType: endpoint.RecordTypeA,
81+
aliasIn: endpoint.AliasFalse,
82+
aliasWanted: endpoint.AliasFalse,
83+
},
84+
{
85+
name: "A with no alias property unchanged",
86+
recordType: endpoint.RecordTypeA,
87+
aliasIn: endpoint.AliasNone,
88+
aliasWanted: endpoint.AliasNone,
89+
},
90+
}
91+
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
ep := endpoint.NewEndpoint("example.com", tt.recordType, "target.example.com")
95+
if tt.aliasIn != endpoint.AliasNone {
96+
ep = ep.WithProviderSpecific(endpoint.ProviderSpecificAlias, string(tt.aliasIn))
97+
}
98+
99+
p := newAliasNormalizingMiddleware(&stubProvider{})
100+
result, err := p.AdjustEndpoints([]*endpoint.Endpoint{ep})
101+
require.NoError(t, err)
102+
require.Len(t, result, 1)
103+
104+
assert.Equal(t, tt.aliasWanted, result[0].GetAliasProperty())
105+
})
106+
}
107+
}

provider/factory/provider.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ func Select(
6767
if err != nil {
6868
return nil, err
6969
}
70-
if p != nil && cfg.ProviderCacheTime > 0 {
71-
return provider.NewCachedProvider(p, cfg.ProviderCacheTime), nil
70+
if cfg.ProviderCacheTime > 0 {
71+
p = provider.NewCachedProvider(p, cfg.ProviderCacheTime)
7272
}
73-
return p, nil
73+
return newAliasNormalizingMiddleware(p), nil
7474
}
7575

7676
// providers looks up the constructor for the named provider.

provider/factory/provider_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,9 @@ func TestSelectProvider(t *testing.T) {
134134
} else {
135135
require.NoError(t, err)
136136
require.NotNil(t, p)
137-
assert.Contains(t, reflect.TypeOf(p).String(), tt.expectedType)
137+
mw, ok := p.(*AliasNormalizingMiddleware)
138+
require.True(t, ok, "expected outer *AliasNormalizingMiddleware, got %T", p)
139+
assert.Equal(t, tt.expectedType, reflect.TypeOf(mw.Provider).String())
138140
}
139141
})
140142
}

0 commit comments

Comments
 (0)