Skip to content

Commit 48cfd5d

Browse files
authored
feat(aws): support alias=A and alias=AAAA to create single record type (#5997)
* feat(aws): add aws/alias-disable-a and aws/alias-disable-aaaa provider-specific options) Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * test(aws): extract and add comprehensive tests for adjustEndpointAndNewAaaaIfNeeded Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * refactor(aws): introduce AliasType and restructure endpoint adjustment logic Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * test(aws): add tests for AliasType and alias=A/AAAA support Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * docs: add alias=A and alias=AAAA options to documentation Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * chore(aws): remove unnecessary vars Signed-off-by: u-kai <76635578+u-kai@users.noreply.github.com> * docs: add example for alias=A 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 f6f114e commit 48cfd5d

8 files changed

Lines changed: 232 additions & 33 deletions

File tree

docs/annotations/annotations.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,37 @@ Additional annotations implemented by specific providers:
358358
If the value of this annotation is `true`, specifies that CNAME records generated by the
359359
resource should instead be alias records.
360360

361+
Additionally, you can set the value to `A` or `AAAA` to create only one type of alias record:
362+
363+
- `A`: Creates only an A alias record (IPv4 only)
364+
- `AAAA`: Creates only an AAAA alias record (IPv6 only)
365+
366+
This is useful when your alias target is IPv4-only (i.e., it does not have an AAAA target),
367+
and creating an AAAA alias record would fail.
368+
369+
Note: The `A` and `AAAA` values are currently only supported by the AWS Route53 provider.
370+
371+
#### Example: IPv4-only alias target
372+
373+
```yaml
374+
apiVersion: v1
375+
kind: Service
376+
metadata:
377+
name: my-app
378+
namespace: default
379+
annotations:
380+
external-dns.alpha.kubernetes.io/hostname: app.example.com
381+
external-dns.alpha.kubernetes.io/target: ipv4-only-target.example.com
382+
# Create only an A (IPv4) alias record to avoid creating an AAAA alias record for an IPv4-only target.
383+
external-dns.alpha.kubernetes.io/alias: "A"
384+
spec:
385+
type: LoadBalancer
386+
ports:
387+
- port: 80
388+
selector:
389+
app: my-app
390+
```
391+
361392
This annotation is only supported on A, AAAA, and CNAME record types. Endpoints with other
362393
record types (e.g. MX, SRV, TXT) that have this annotation set will be rejected.
363394

docs/tutorials/aws.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,13 @@ To make the target an alias, the ingress needs to be configured correctly as des
710710
In particular, the argument `--publish-service=default/nginx-ingress-controller` has to be set on the `nginx-ingress-controller` container.
711711
If one uses the `nginx-ingress` Helm chart, this flag can be set with the `controller.publishService.enabled` configuration option.
712712

713+
Additionally, you can set the value to `A` or `AAAA` to create only one type of ALIAS record:
714+
715+
- `A`: Creates only an A ALIAS record (IPv4 only)
716+
- `AAAA`: Creates only an AAAA ALIAS record (IPv6 only)
717+
718+
Note: The `A` and `AAAA` values are currently only supported by the AWS Route53 provider.
719+
713720
### target-hosted-zone
714721

715722
`external-dns.kubernetes.io/aws-target-hosted-zone` can optionally be set to the ID of a Route53 hosted zone. This will force external-dns to use the specified hosted zone when creating an ALIAS target.

endpoint/endpoint.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,36 @@ func (e *Endpoint) GetProviderSpecificProperty(key string) (string, bool) {
336336
return "", false
337337
}
338338

339+
type AliasType string
340+
341+
const (
342+
// AliasNone indicates alias property is not set
343+
AliasNone AliasType = ""
344+
// AliasFalse indicates alias property is set to false
345+
AliasFalse AliasType = "false"
346+
// AliasTrue indicates alias property is set to true (both A and AAAA)
347+
AliasTrue AliasType = "true"
348+
// AliasA indicates alias property is set to A record only
349+
AliasA AliasType = "A"
350+
// AliasAAAA indicates alias property is set to AAAA record only
351+
AliasAAAA AliasType = "AAAA"
352+
)
353+
354+
func (e *Endpoint) GetAliasProperty() AliasType {
355+
switch a, ok := e.GetProviderSpecificProperty("alias"); {
356+
case a == "true" && ok:
357+
return AliasTrue
358+
case a == "false" && ok:
359+
return AliasFalse
360+
case a == "A" && ok:
361+
return AliasA
362+
case a == "AAAA" && ok:
363+
return AliasAAAA
364+
default:
365+
return AliasNone
366+
}
367+
}
368+
339369
// GetBoolProviderSpecificProperty returns a boolean provider-specific property value.
340370
func (e *Endpoint) GetBoolProviderSpecificProperty(key string) (bool, bool) {
341371
prop, ok := e.GetProviderSpecificProperty(key)

endpoint/endpoint_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1701,6 +1701,72 @@ func TestNewEndpointWithTTLPreservesDotsInTXTRecords(t *testing.T) {
17011701
assert.Equal(t, "target.example.com", cnameEndpoint.Targets[0], "CNAME record should have trailing dot trimmed")
17021702
}
17031703

1704+
func TestGetAliasProperty(t *testing.T) {
1705+
tests := []struct {
1706+
name string
1707+
endpoint Endpoint
1708+
expected AliasType
1709+
}{
1710+
{
1711+
name: "no alias property returns AliasNone",
1712+
endpoint: Endpoint{},
1713+
expected: AliasNone,
1714+
},
1715+
{
1716+
name: "alias=true returns AliasTrue",
1717+
endpoint: Endpoint{
1718+
ProviderSpecific: []ProviderSpecificProperty{
1719+
{Name: "alias", Value: "true"},
1720+
},
1721+
},
1722+
expected: AliasTrue,
1723+
},
1724+
{
1725+
name: "alias=false returns AliasFalse",
1726+
endpoint: Endpoint{
1727+
ProviderSpecific: []ProviderSpecificProperty{
1728+
{Name: "alias", Value: "false"},
1729+
},
1730+
},
1731+
expected: AliasFalse,
1732+
},
1733+
{
1734+
name: "alias=A returns AliasA",
1735+
endpoint: Endpoint{
1736+
ProviderSpecific: []ProviderSpecificProperty{
1737+
{Name: "alias", Value: "A"},
1738+
},
1739+
},
1740+
expected: AliasA,
1741+
},
1742+
{
1743+
name: "alias=AAAA returns AliasAAAA",
1744+
endpoint: Endpoint{
1745+
ProviderSpecific: []ProviderSpecificProperty{
1746+
{Name: "alias", Value: "AAAA"},
1747+
},
1748+
},
1749+
expected: AliasAAAA,
1750+
},
1751+
{
1752+
name: "alias with invalid value returns AliasNone",
1753+
endpoint: Endpoint{
1754+
ProviderSpecific: []ProviderSpecificProperty{
1755+
{Name: "alias", Value: "invalid"},
1756+
},
1757+
},
1758+
expected: AliasNone,
1759+
},
1760+
}
1761+
1762+
for _, tt := range tests {
1763+
t.Run(tt.name, func(t *testing.T) {
1764+
result := tt.endpoint.GetAliasProperty()
1765+
assert.Equal(t, tt.expected, result)
1766+
})
1767+
}
1768+
}
1769+
17041770
func TestGetBoolProviderSpecificProperty(t *testing.T) {
17051771
tests := []struct {
17061772
name string

provider/aws/aws.go

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -853,21 +853,13 @@ func (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoi
853853
}
854854

855855
func (p *AWSProvider) adjustEndpointAndNewAaaaIfNeeded(ep *endpoint.Endpoint) *endpoint.Endpoint {
856-
var aaaa *endpoint.Endpoint
857856
switch ep.RecordType {
858857
case endpoint.RecordTypeA, endpoint.RecordTypeAAAA:
859858
p.adjustAandAAAARecord(ep)
860859
case endpoint.RecordTypeCNAME:
861-
p.adjustCNAMERecord(ep)
862-
adjustGeoProximityLocationEndpoint(ep)
863-
if isAlias, _ := ep.GetBoolProviderSpecificProperty(endpoint.ProviderSpecificAlias); isAlias {
864-
aaaa = ep.DeepCopy()
865-
aaaa.RecordType = endpoint.RecordTypeAAAA
866-
}
867-
return aaaa
860+
return p.adjustCNAMERecordAndNewAaaaIfNeeded(ep)
868861
}
869-
adjustGeoProximityLocationEndpoint(ep)
870-
return aaaa
862+
return nil
871863
}
872864

873865
func (p *AWSProvider) adjustAliasRecord(ep *endpoint.Endpoint) {
@@ -886,39 +878,43 @@ func (p *AWSProvider) adjustAliasRecord(ep *endpoint.Endpoint) {
886878
}
887879

888880
func (p *AWSProvider) adjustAandAAAARecord(ep *endpoint.Endpoint) {
889-
isAlias, _ := ep.GetBoolProviderSpecificProperty(endpoint.ProviderSpecificAlias)
890-
if isAlias {
881+
if ep.GetAliasProperty() == endpoint.AliasTrue {
891882
p.adjustAliasRecord(ep)
892883
} else {
893884
ep.DeleteProviderSpecificProperty(endpoint.ProviderSpecificAlias)
894885
ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)
895886
}
887+
adjustGeoProximityLocationEndpoint(ep)
896888
}
897889

898-
func (p *AWSProvider) adjustCNAMERecord(ep *endpoint.Endpoint) {
899-
isAlias, exists := ep.GetBoolProviderSpecificProperty(endpoint.ProviderSpecificAlias)
900-
901-
// fallback to determining alias based on preferCNAME if not explicitly set
902-
if !exists {
903-
isAlias = useAlias(ep, p.preferCNAME)
890+
func (p *AWSProvider) adjustCNAMERecordAndNewAaaaIfNeeded(ep *endpoint.Endpoint) *endpoint.Endpoint {
891+
// ensure alias property is set
892+
if ep.GetAliasProperty() == endpoint.AliasNone {
893+
isAlias := useAlias(ep, p.preferCNAME)
904894
log.Debugf("Modifying endpoint: %v, setting %s=%v", ep, endpoint.ProviderSpecificAlias, isAlias)
905895
ep.SetProviderSpecificProperty(endpoint.ProviderSpecificAlias, strconv.FormatBool(isAlias))
906896
}
907897

908-
// if not an alias, ensure alias properties are adjusted accordingly
909-
if !isAlias {
910-
if exists {
911-
// normalize to string "false" when provider specific alias is set to false or other non-true value
912-
ep.SetProviderSpecificProperty(endpoint.ProviderSpecificAlias, "false")
913-
}
914-
ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)
915-
}
916-
917-
// if an alias, convert to A record and adjust alias properties
918-
if isAlias {
898+
switch ep.GetAliasProperty() {
899+
case endpoint.AliasTrue:
900+
ep.RecordType = endpoint.RecordTypeA
901+
p.adjustAliasRecord(ep)
902+
adjustGeoProximityLocationEndpoint(ep)
903+
aaaa := ep.DeepCopy()
904+
aaaa.RecordType = endpoint.RecordTypeAAAA
905+
return aaaa
906+
case endpoint.AliasA:
919907
ep.RecordType = endpoint.RecordTypeA
920908
p.adjustAliasRecord(ep)
909+
case endpoint.AliasAAAA:
910+
ep.RecordType = endpoint.RecordTypeAAAA
911+
p.adjustAliasRecord(ep)
912+
case endpoint.AliasFalse:
913+
ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)
921914
}
915+
916+
adjustGeoProximityLocationEndpoint(ep)
917+
return nil
922918
}
923919

924920
// if the endpoint is using geoproximity, set the bias to 0 if not set

provider/aws/aws_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3308,6 +3308,66 @@ func TestAWSProvider_adjustEndpointAndNewAaaaIfNeeded(t *testing.T) {
33083308
},
33093309
expectedAaaa: nil,
33103310
},
3311+
{
3312+
name: "CNAME record with alias=A should convert to A record only and not create AAAA",
3313+
ep: &endpoint.Endpoint{
3314+
DNSName: "test.foo.bar.",
3315+
RecordType: endpoint.RecordTypeCNAME,
3316+
Targets: endpoint.Targets{"same-zone-target.foo.bar."},
3317+
ProviderSpecific: endpoint.ProviderSpecific{
3318+
{
3319+
Name: endpoint.ProviderSpecificAlias,
3320+
Value: "A",
3321+
},
3322+
},
3323+
},
3324+
expected: &endpoint.Endpoint{
3325+
DNSName: "test.foo.bar.",
3326+
RecordType: endpoint.RecordTypeA,
3327+
Targets: endpoint.Targets{"same-zone-target.foo.bar."},
3328+
ProviderSpecific: endpoint.ProviderSpecific{
3329+
{
3330+
Name: endpoint.ProviderSpecificAlias,
3331+
Value: "A",
3332+
},
3333+
{
3334+
Name: providerSpecificEvaluateTargetHealth,
3335+
Value: "false",
3336+
},
3337+
},
3338+
},
3339+
expectedAaaa: nil,
3340+
},
3341+
{
3342+
name: "CNAME record with alias=AAAA should convert to AAAA record only and not create A",
3343+
ep: &endpoint.Endpoint{
3344+
DNSName: "test.foo.bar.",
3345+
RecordType: endpoint.RecordTypeCNAME,
3346+
Targets: endpoint.Targets{"same-zone-target.foo.bar."},
3347+
ProviderSpecific: endpoint.ProviderSpecific{
3348+
{
3349+
Name: endpoint.ProviderSpecificAlias,
3350+
Value: "AAAA",
3351+
},
3352+
},
3353+
},
3354+
expected: &endpoint.Endpoint{
3355+
DNSName: "test.foo.bar.",
3356+
RecordType: endpoint.RecordTypeAAAA,
3357+
Targets: endpoint.Targets{"same-zone-target.foo.bar."},
3358+
ProviderSpecific: endpoint.ProviderSpecific{
3359+
{
3360+
Name: endpoint.ProviderSpecificAlias,
3361+
Value: "AAAA",
3362+
},
3363+
{
3364+
Name: providerSpecificEvaluateTargetHealth,
3365+
Value: "false",
3366+
},
3367+
},
3368+
},
3369+
expectedAaaa: nil,
3370+
},
33113371

33123372
// --- MX / other records ---
33133373
{

registry/txt/registry.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,7 @@ func (im *TXTRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error
252252
SetIdentifier: ep.SetIdentifier,
253253
}
254254

255-
// AWS Alias records have "new" format encoded as type "cname"
256-
if isAlias, found := ep.GetBoolProviderSpecificProperty(endpoint.ProviderSpecificAlias); found && isAlias && ep.RecordType == endpoint.RecordTypeA {
255+
if shouldUseCNAMEForTxtRecord(ep) {
257256
key.RecordType = endpoint.RecordTypeCNAME
258257
}
259258

@@ -296,6 +295,13 @@ func (im *TXTRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error
296295
return endpoints, nil
297296
}
298297

298+
// shouldUseCNAMEForTxtRecord checks if the endpoint is an alias A record converted from CNAME.
299+
// TXT ownership records use CNAME as the record type for such records.
300+
func shouldUseCNAMEForTxtRecord(ep *endpoint.Endpoint) bool {
301+
aliasType := ep.GetAliasProperty()
302+
return (aliasType == endpoint.AliasTrue || aliasType == endpoint.AliasA) && ep.RecordType == endpoint.RecordTypeA
303+
}
304+
299305
// generateTXTRecord generates TXT records in either both formats (old and new) or new format only,
300306
// depending on the newFormatOnly configuration. The old format is maintained for backwards
301307
// compatibility but can be disabled to reduce the number of DNS records.
@@ -308,8 +314,8 @@ func (im *TXTRegistry) generateTXTRecordWithFilter(r *endpoint.Endpoint, filter
308314

309315
// Always create new format record
310316
recordType := r.RecordType
311-
// AWS Alias records are encoded as type "cname"
312-
if isAlias, found := r.GetBoolProviderSpecificProperty(endpoint.ProviderSpecificAlias); found && isAlias && recordType == endpoint.RecordTypeA {
317+
318+
if shouldUseCNAMEForTxtRecord(r) {
313319
recordType = endpoint.RecordTypeCNAME
314320
}
315321

registry/txt/registry_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,7 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) {
10911091
newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""),
10921092
newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""),
10931093
newEndpointWithOwner("new-alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "").WithProviderSpecific(endpoint.ProviderSpecificAlias, "true"),
1094+
newEndpointWithOwner("new-alias-a-only.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "").WithProviderSpecific(endpoint.ProviderSpecificAlias, "A"),
10941095
},
10951096
Delete: []*endpoint.Endpoint{
10961097
newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"),
@@ -1110,6 +1111,8 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) {
11101111
newTXTEndpointWithOwnedRecord("cname-example", "\"heritage=external-dns,external-dns/owner=owner\"", "example"),
11111112
newEndpointWithOwner("new-alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "owner").WithProviderSpecific(endpoint.ProviderSpecificAlias, "true"),
11121113
newTXTEndpointWithOwnedRecord("cname-new-alias.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "new-alias.test-zone.example.org").WithProviderSpecific(endpoint.ProviderSpecificAlias, "true"),
1114+
newEndpointWithOwner("new-alias-a-only.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "owner").WithProviderSpecific(endpoint.ProviderSpecificAlias, "A"),
1115+
newTXTEndpointWithOwnedRecord("cname-new-alias-a-only.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "new-alias-a-only.test-zone.example.org").WithProviderSpecific(endpoint.ProviderSpecificAlias, "A"),
11131116
},
11141117
Delete: []*endpoint.Endpoint{
11151118
newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"),

0 commit comments

Comments
 (0)