diff --git a/docs/annotations/annotations.md b/docs/annotations/annotations.md index 6855322012..ac7b37af6b 100644 --- a/docs/annotations/annotations.md +++ b/docs/annotations/annotations.md @@ -361,6 +361,47 @@ Specifies the set identifier for DNS records generated by the resource. A set identifier differentiates among multiple DNS record sets that have the same combination of domain and type. Which record set or sets are returned to queries is then determined by the configured routing policy. +Required for AWS Route53 routing policies (weighted, latency, failover, geolocation, geoproximity, multi-value). +See the [AWS tutorial — Routing policies](../tutorials/aws.md#routing-policies) for the full list of annotations +and examples. + +Notes: + +- The annotation is provider-agnostic in design but is primarily used with AWS Route53 routing policies. +- The value is arbitrary but must be **unique per record set** for the same domain and type combination. +- For Gateway API sources, this annotation must be placed on **Route resources** (e.g., `HTTPRoute`), not on + the `Gateway` resource itself. See [Gateway API Annotation Placement](#gateway-api-annotation-placement). + +### Gateway API with HTTPRoute + +When using Gateway API, place `set-identifier` on the Route resource, not the Gateway: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: my-gateway + annotations: + # target goes on the Gateway + external-dns.alpha.kubernetes.io/target: "alb-123.us-east-1.elb.amazonaws.com" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: my-route + annotations: + # set-identifier and routing policy go on the Route + external-dns.alpha.kubernetes.io/set-identifier: backend-v1 + external-dns.alpha.kubernetes.io/aws-weight: "100" +spec: + parentRefs: + - name: my-gateway + hostnames: + - app.example.com +``` + +> Placing `set-identifier` on the Gateway instead of the Route is a common mistake — the Gateway source only reads the `target` annotation. + ## Gateway API Annotation Placement When using Gateway API sources (`gateway-httproute`, `gateway-grpcroute`, `gateway-tlsroute`, etc.), annotations diff --git a/docs/tutorials/aws.md b/docs/tutorials/aws.md index 7389542087..2de2e8b2e9 100644 --- a/docs/tutorials/aws.md +++ b/docs/tutorials/aws.md @@ -1023,6 +1023,90 @@ For any given DNS name, only **one** of the following routing policies can be us - `external-dns.alpha.kubernetes.io/aws-geoproximity-bias` - Multi-value answer:`external-dns.alpha.kubernetes.io/aws-multi-value-answer` +#### Weighted Routing + +Route traffic across two Services by weight. Both share the same hostname but carry different identifiers and weights: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: my-service-v1 + annotations: + external-dns.alpha.kubernetes.io/hostname: app.example.com + external-dns.alpha.kubernetes.io/set-identifier: app-v1 + external-dns.alpha.kubernetes.io/aws-weight: "80" +spec: + type: LoadBalancer +--- +apiVersion: v1 +kind: Service +metadata: + name: my-service-v2 + annotations: + external-dns.alpha.kubernetes.io/hostname: app.example.com + external-dns.alpha.kubernetes.io/set-identifier: app-v2 + external-dns.alpha.kubernetes.io/aws-weight: "20" +spec: + type: LoadBalancer +``` + +> ExternalDNS will create two Route53 weighted record sets for `app.example.com`, sending 80% of traffic to `my-service-v1` and 20% to `my-service-v2`. + +#### Failover Routing + +Designate a primary and secondary record for active/passive failover: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: my-ingress-primary + annotations: + external-dns.alpha.kubernetes.io/set-identifier: my-app-primary + external-dns.alpha.kubernetes.io/aws-failover: PRIMARY +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: my-ingress-secondary + annotations: + external-dns.alpha.kubernetes.io/set-identifier: my-app-secondary + external-dns.alpha.kubernetes.io/aws-failover: SECONDARY +``` + +> Route53 will serve the `PRIMARY` record when healthy, and automatically fall back to `SECONDARY` when the health check fails. + +#### Latency-Based Routing + +Route users to the nearest region by latency: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: my-service-us + annotations: + external-dns.alpha.kubernetes.io/hostname: api.example.com + external-dns.alpha.kubernetes.io/set-identifier: api-us-east-1 + external-dns.alpha.kubernetes.io/aws-region: us-east-1 +spec: + type: LoadBalancer +--- +apiVersion: v1 +kind: Service +metadata: + name: my-service-eu + annotations: + external-dns.alpha.kubernetes.io/hostname: api.example.com + external-dns.alpha.kubernetes.io/set-identifier: api-eu-west-1 + external-dns.alpha.kubernetes.io/aws-region: eu-west-1 +spec: + type: LoadBalancer +``` + +> Route53 will direct each user to the region with the lowest latency. + ### Associating DNS records with healthchecks You can configure Route53 to associate DNS records with healthchecks for automated DNS failover using diff --git a/source/utils_test.go b/source/utils_test.go index 91789de33d..351a0a892f 100644 --- a/source/utils_test.go +++ b/source/utils_test.go @@ -294,6 +294,27 @@ func TestMergeEndpoints(t *testing.T) { endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, 600, "5.6.7.8"), }, }, + { + name: "same DNSName and RecordType with different SetIdentifier not merged", + input: []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("us-east-1"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "5.6.7.8").WithSetIdentifier("eu-west-1"), + }, + expected: []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("us-east-1"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "5.6.7.8").WithSetIdentifier("eu-west-1"), + }, + }, + { + name: "same DNSName, RecordType and SetIdentifier targets are merged", + input: []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("us-east-1"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "5.6.7.8").WithSetIdentifier("us-east-1"), + }, + expected: []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "5.6.7.8").WithSetIdentifier("us-east-1"), + }, + }, } for _, tt := range tests { diff --git a/source/wrappers/dedupsource_test.go b/source/wrappers/dedupsource_test.go index 66a57779f8..b10dd321c5 100644 --- a/source/wrappers/dedupsource_test.go +++ b/source/wrappers/dedupsource_test.go @@ -126,6 +126,27 @@ func testDedupEndpoints(t *testing.T) { {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, + { + "two endpoints with same dnsname, same type, same target but different SetIdentifier return two endpoints", + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "eu-west-1"}, + }, + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "eu-west-1"}, + }, + }, + { + "two endpoints with same dnsname, same type, same target and same SetIdentifier return one endpoint", + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, + }, + []*endpoint.Endpoint{ + {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, + }, + }, { "no endpoints returns empty endpoints", []*endpoint.Endpoint{},