diff --git a/docs/annotations/annotations.md b/docs/annotations/annotations.md index eeb9a8e0eb..47815af341 100644 --- a/docs/annotations/annotations.md +++ b/docs/annotations/annotations.md @@ -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: + +- `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. diff --git a/docs/tutorials/aws.md b/docs/tutorials/aws.md index 254446b6d0..fba1287f50 100644 --- a/docs/tutorials/aws.md +++ b/docs/tutorials/aws.md @@ -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. diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index 438e53c956..8f809d322c 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -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) diff --git a/endpoint/endpoint_test.go b/endpoint/endpoint_test.go index 18ba134060..8ec502267c 100644 --- a/endpoint/endpoint_test.go +++ b/endpoint/endpoint_test.go @@ -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 diff --git a/provider/aws/aws.go b/provider/aws/aws.go index 10fbadc022..82c12e9a12 100644 --- a/provider/aws/aws.go +++ b/provider/aws/aws.go @@ -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) { @@ -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 diff --git a/provider/aws/aws_test.go b/provider/aws/aws_test.go index ea23c34554..7e2fa5fb16 100644 --- a/provider/aws/aws_test.go +++ b/provider/aws/aws_test.go @@ -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 --- { diff --git a/registry/txt/registry.go b/registry/txt/registry.go index a52ec2940e..462c95794c 100644 --- a/registry/txt/registry.go +++ b/registry/txt/registry.go @@ -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 } @@ -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 +} + // 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. @@ -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 } diff --git a/registry/txt/registry_test.go b/registry/txt/registry_test.go index 9a81f9ade8..f0d45f4711 100644 --- a/registry/txt/registry_test.go +++ b/registry/txt/registry_test.go @@ -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"), @@ -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"),