Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/annotations/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,37 @@ Additional annotations implemented by specific providers:
If the value of this annotation is `true`, specifies that CNAME records generated by the
resource should instead be alias records.

Additionally, you can set the value to `A` or `AAAA` to create only one type of alias record:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing full example, hard to understand how to configure it.

example

apiVersion: v1
kind: RESOURCE
metadata:
  name: app-assets-dns
  namespace: default
  annotations:
    xternal-dns.alpha.kubernetes.io/ABRAKADABRA: ABRAKADABRA

# Result:
# DNS -> TARGET (CNAME)


- `A`: Creates only an A alias record (IPv4 only)
- `AAAA`: Creates only an AAAA alias record (IPv6 only)

This is useful when your alias target is IPv4-only (i.e., it does not have an AAAA target),
and creating an AAAA alias record would fail.

Note: The `A` and `AAAA` values are currently only supported by the AWS Route53 provider.

#### Example: IPv4-only alias target

```yaml
apiVersion: v1
kind: Service
metadata:
name: my-app
namespace: default
annotations:
external-dns.alpha.kubernetes.io/hostname: app.example.com
external-dns.alpha.kubernetes.io/target: ipv4-only-target.example.com
# Create only an A (IPv4) alias record to avoid creating an AAAA alias record for an IPv4-only target.
external-dns.alpha.kubernetes.io/alias: "A"
spec:
type: LoadBalancer
ports:
- port: 80
selector:
app: my-app
```

This annotation is only supported on A, AAAA, and CNAME record types. Endpoints with other
record types (e.g. MX, SRV, TXT) that have this annotation set will be rejected.

Expand Down
7 changes: 7 additions & 0 deletions docs/tutorials/aws.md
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,13 @@ To make the target an alias, the ingress needs to be configured correctly as des
In particular, the argument `--publish-service=default/nginx-ingress-controller` has to be set on the `nginx-ingress-controller` container.
If one uses the `nginx-ingress` Helm chart, this flag can be set with the `controller.publishService.enabled` configuration option.

Additionally, you can set the value to `A` or `AAAA` to create only one type of ALIAS record:

- `A`: Creates only an A ALIAS record (IPv4 only)
- `AAAA`: Creates only an AAAA ALIAS record (IPv6 only)

Note: The `A` and `AAAA` values are currently only supported by the AWS Route53 provider.

### target-hosted-zone

`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.
Expand Down
30 changes: 30 additions & 0 deletions endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,36 @@ func (e *Endpoint) GetProviderSpecificProperty(key string) (string, bool) {
return "", false
}

type AliasType string

const (
// AliasNone indicates alias property is not set
AliasNone AliasType = ""
// AliasFalse indicates alias property is set to false
AliasFalse AliasType = "false"
// AliasTrue indicates alias property is set to true (both A and AAAA)
AliasTrue AliasType = "true"
// AliasA indicates alias property is set to A record only
AliasA AliasType = "A"
// AliasAAAA indicates alias property is set to AAAA record only
AliasAAAA AliasType = "AAAA"
)

func (e *Endpoint) GetAliasProperty() AliasType {
switch a, ok := e.GetProviderSpecificProperty("alias"); {
case a == "true" && ok:
return AliasTrue
case a == "false" && ok:
return AliasFalse
case a == "A" && ok:
return AliasA
case a == "AAAA" && ok:
return AliasAAAA
default:
return AliasNone
}
}

// GetBoolProviderSpecificProperty returns a boolean provider-specific property value.
func (e *Endpoint) GetBoolProviderSpecificProperty(key string) (bool, bool) {
prop, ok := e.GetProviderSpecificProperty(key)
Expand Down
66 changes: 66 additions & 0 deletions endpoint/endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,72 @@ func TestNewEndpointWithTTLPreservesDotsInTXTRecords(t *testing.T) {
assert.Equal(t, "target.example.com", cnameEndpoint.Targets[0], "CNAME record should have trailing dot trimmed")
}

func TestGetAliasProperty(t *testing.T) {
tests := []struct {
name string
endpoint Endpoint
expected AliasType
}{
{
name: "no alias property returns AliasNone",
endpoint: Endpoint{},
expected: AliasNone,
},
{
name: "alias=true returns AliasTrue",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "alias", Value: "true"},
},
},
expected: AliasTrue,
},
{
name: "alias=false returns AliasFalse",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "alias", Value: "false"},
},
},
expected: AliasFalse,
},
{
name: "alias=A returns AliasA",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "alias", Value: "A"},
},
},
expected: AliasA,
},
{
name: "alias=AAAA returns AliasAAAA",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "alias", Value: "AAAA"},
},
},
expected: AliasAAAA,
},
{
name: "alias with invalid value returns AliasNone",
endpoint: Endpoint{
ProviderSpecific: []ProviderSpecificProperty{
{Name: "alias", Value: "invalid"},
},
},
expected: AliasNone,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.endpoint.GetAliasProperty()
assert.Equal(t, tt.expected, result)
})
}
}

func TestGetBoolProviderSpecificProperty(t *testing.T) {
tests := []struct {
name string
Expand Down
54 changes: 25 additions & 29 deletions provider/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -853,21 +853,13 @@ func (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoi
}

func (p *AWSProvider) adjustEndpointAndNewAaaaIfNeeded(ep *endpoint.Endpoint) *endpoint.Endpoint {
var aaaa *endpoint.Endpoint
switch ep.RecordType {
case endpoint.RecordTypeA, endpoint.RecordTypeAAAA:
p.adjustAandAAAARecord(ep)
case endpoint.RecordTypeCNAME:
p.adjustCNAMERecord(ep)
adjustGeoProximityLocationEndpoint(ep)
if isAlias, _ := ep.GetBoolProviderSpecificProperty(endpoint.ProviderSpecificAlias); isAlias {
aaaa = ep.DeepCopy()
aaaa.RecordType = endpoint.RecordTypeAAAA
}
return aaaa
return p.adjustCNAMERecordAndNewAaaaIfNeeded(ep)
}
adjustGeoProximityLocationEndpoint(ep)
return aaaa
return nil
}

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

func (p *AWSProvider) adjustAandAAAARecord(ep *endpoint.Endpoint) {
isAlias, _ := ep.GetBoolProviderSpecificProperty(endpoint.ProviderSpecificAlias)
if isAlias {
if ep.GetAliasProperty() == endpoint.AliasTrue {
p.adjustAliasRecord(ep)
} else {
ep.DeleteProviderSpecificProperty(endpoint.ProviderSpecificAlias)
ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)
}
adjustGeoProximityLocationEndpoint(ep)
}

func (p *AWSProvider) adjustCNAMERecord(ep *endpoint.Endpoint) {
isAlias, exists := ep.GetBoolProviderSpecificProperty(endpoint.ProviderSpecificAlias)

// fallback to determining alias based on preferCNAME if not explicitly set
if !exists {
isAlias = useAlias(ep, p.preferCNAME)
func (p *AWSProvider) adjustCNAMERecordAndNewAaaaIfNeeded(ep *endpoint.Endpoint) *endpoint.Endpoint {
// ensure alias property is set
if ep.GetAliasProperty() == endpoint.AliasNone {
isAlias := useAlias(ep, p.preferCNAME)
log.Debugf("Modifying endpoint: %v, setting %s=%v", ep, endpoint.ProviderSpecificAlias, isAlias)
ep.SetProviderSpecificProperty(endpoint.ProviderSpecificAlias, strconv.FormatBool(isAlias))
}

// if not an alias, ensure alias properties are adjusted accordingly
if !isAlias {
if exists {
// normalize to string "false" when provider specific alias is set to false or other non-true value
ep.SetProviderSpecificProperty(endpoint.ProviderSpecificAlias, "false")
}
ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)
}

// if an alias, convert to A record and adjust alias properties
if isAlias {
switch ep.GetAliasProperty() {
case endpoint.AliasTrue:
ep.RecordType = endpoint.RecordTypeA
p.adjustAliasRecord(ep)
adjustGeoProximityLocationEndpoint(ep)
aaaa := ep.DeepCopy()
aaaa.RecordType = endpoint.RecordTypeAAAA
return aaaa
case endpoint.AliasA:
ep.RecordType = endpoint.RecordTypeA
p.adjustAliasRecord(ep)
case endpoint.AliasAAAA:
ep.RecordType = endpoint.RecordTypeAAAA
p.adjustAliasRecord(ep)
case endpoint.AliasFalse:
ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)
}

adjustGeoProximityLocationEndpoint(ep)
return nil
}

// if the endpoint is using geoproximity, set the bias to 0 if not set
Expand Down
60 changes: 60 additions & 0 deletions provider/aws/aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3308,6 +3308,66 @@ func TestAWSProvider_adjustEndpointAndNewAaaaIfNeeded(t *testing.T) {
},
expectedAaaa: nil,
},
{
name: "CNAME record with alias=A should convert to A record only and not create AAAA",
ep: &endpoint.Endpoint{
DNSName: "test.foo.bar.",
RecordType: endpoint.RecordTypeCNAME,
Targets: endpoint.Targets{"same-zone-target.foo.bar."},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: endpoint.ProviderSpecificAlias,
Value: "A",
},
},
},
expected: &endpoint.Endpoint{
DNSName: "test.foo.bar.",
RecordType: endpoint.RecordTypeA,
Targets: endpoint.Targets{"same-zone-target.foo.bar."},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: endpoint.ProviderSpecificAlias,
Value: "A",
},
{
Name: providerSpecificEvaluateTargetHealth,
Value: "false",
},
},
},
expectedAaaa: nil,
},
{
name: "CNAME record with alias=AAAA should convert to AAAA record only and not create A",
ep: &endpoint.Endpoint{
DNSName: "test.foo.bar.",
RecordType: endpoint.RecordTypeCNAME,
Targets: endpoint.Targets{"same-zone-target.foo.bar."},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: endpoint.ProviderSpecificAlias,
Value: "AAAA",
},
},
},
expected: &endpoint.Endpoint{
DNSName: "test.foo.bar.",
RecordType: endpoint.RecordTypeAAAA,
Targets: endpoint.Targets{"same-zone-target.foo.bar."},
ProviderSpecific: endpoint.ProviderSpecific{
{
Name: endpoint.ProviderSpecificAlias,
Value: "AAAA",
},
{
Name: providerSpecificEvaluateTargetHealth,
Value: "false",
},
},
},
expectedAaaa: nil,
},

// --- MX / other records ---
{
Expand Down
14 changes: 10 additions & 4 deletions registry/txt/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,7 @@ func (im *TXTRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error
SetIdentifier: ep.SetIdentifier,
}

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

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

// shouldUseCNAMEForTxtRecord checks if the endpoint is an alias A record converted from CNAME.
// TXT ownership records use CNAME as the record type for such records.
func shouldUseCNAMEForTxtRecord(ep *endpoint.Endpoint) bool {
aliasType := ep.GetAliasProperty()
return (aliasType == endpoint.AliasTrue || aliasType == endpoint.AliasA) && ep.RecordType == endpoint.RecordTypeA
}
Comment on lines +300 to +303
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@u-kai Thanks for the rebase. You have addressed many comments 👍 .
I have still one, though: What should be the behavior of this func when aliasType is endpoint.AliasAAAA ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mloiseleur
Thanks for the comments!

AliasAAAA doesn't need any special handling in shouldUseCNAMEForTxtRecord.

The CNAME encoding for alias A records may exist for backward compatibility: before #3910, alias records were represented as RecordTypeCNAME in external-dns.
When that PR changed them to RecordTypeA, the TXT ownership records had to keep using "cname" as the encoded type.

The alias AAAA record was never represented as CNAME, so its TXT record should use "AAAA" as the record type.


// generateTXTRecord generates TXT records in either both formats (old and new) or new format only,
// depending on the newFormatOnly configuration. The old format is maintained for backwards
// compatibility but can be disabled to reduce the number of DNS records.
Expand All @@ -308,8 +314,8 @@ func (im *TXTRegistry) generateTXTRecordWithFilter(r *endpoint.Endpoint, filter

// Always create new format record
recordType := r.RecordType
// AWS Alias records are encoded as type "cname"
if isAlias, found := r.GetBoolProviderSpecificProperty(endpoint.ProviderSpecificAlias); found && isAlias && recordType == endpoint.RecordTypeA {

if shouldUseCNAMEForTxtRecord(r) {
recordType = endpoint.RecordTypeCNAME
}

Expand Down
3 changes: 3 additions & 0 deletions registry/txt/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,7 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) {
newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""),
newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""),
newEndpointWithOwner("new-alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "").WithProviderSpecific(endpoint.ProviderSpecificAlias, "true"),
newEndpointWithOwner("new-alias-a-only.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "").WithProviderSpecific(endpoint.ProviderSpecificAlias, "A"),
},
Delete: []*endpoint.Endpoint{
newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"),
Expand All @@ -1110,6 +1111,8 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) {
newTXTEndpointWithOwnedRecord("cname-example", "\"heritage=external-dns,external-dns/owner=owner\"", "example"),
newEndpointWithOwner("new-alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "owner").WithProviderSpecific(endpoint.ProviderSpecificAlias, "true"),
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"),
newEndpointWithOwner("new-alias-a-only.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "owner").WithProviderSpecific(endpoint.ProviderSpecificAlias, "A"),
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"),
},
Delete: []*endpoint.Endpoint{
newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"),
Expand Down
Loading