feat(aws): add support for geoproximity routing (#5347)

* feat(aws): add support for geoproximity routing

* remove the invalid test

* make some changes based on review comments

* fix linting errors

* make changes based on review feedback

* add more tests to get better coverage

* update docs

* make the linter happy

* address review feedback

This commit addresses the review feedback by making the following changes:

- use a more object-oriented approach for geoProximity handling
- change log levels to warnings instead of errors
- add more test cases for geoProximity

* fix linting error

* use shorter annotation names
This commit is contained in:
Prasad Katti 2025-07-03 08:19:26 -07:00 committed by GitHub
parent dfb64ae813
commit d79dd835af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 614 additions and 12 deletions

View File

@ -894,6 +894,11 @@ For any given DNS name, only **one** of the following routing policies can be us
- `external-dns.alpha.kubernetes.io/aws-geolocation-continent-code`
- `external-dns.alpha.kubernetes.io/aws-geolocation-country-code`
- `external-dns.alpha.kubernetes.io/aws-geolocation-subdivision-code`
- Geoproximity routing:
- `external-dns.alpha.kubernetes.io/aws-geoproximity-region`
- `external-dns.alpha.kubernetes.io/aws-geoproximity-local-zone-group`
- `external-dns.alpha.kubernetes.io/aws-geoproximity-coordinates`
- `external-dns.alpha.kubernetes.io/aws-geoproximity-bias`
- Multi-value answer:`external-dns.alpha.kubernetes.io/aws-multi-value-answer`
### Associating DNS records with healthchecks

View File

@ -53,19 +53,27 @@ const (
// providerSpecificEvaluateTargetHealth specifies whether an AWS ALIAS record
// has the EvaluateTargetHealth field set to true. Present iff the endpoint
// has a `providerSpecificAlias` value of `true`.
providerSpecificEvaluateTargetHealth = "aws/evaluate-target-health"
providerSpecificWeight = "aws/weight"
providerSpecificRegion = "aws/region"
providerSpecificFailover = "aws/failover"
providerSpecificGeolocationContinentCode = "aws/geolocation-continent-code"
providerSpecificGeolocationCountryCode = "aws/geolocation-country-code"
providerSpecificGeolocationSubdivisionCode = "aws/geolocation-subdivision-code"
providerSpecificMultiValueAnswer = "aws/multi-value-answer"
providerSpecificHealthCheckID = "aws/health-check-id"
sameZoneAlias = "same-zone"
providerSpecificEvaluateTargetHealth = "aws/evaluate-target-health"
providerSpecificWeight = "aws/weight"
providerSpecificRegion = "aws/region"
providerSpecificFailover = "aws/failover"
providerSpecificGeolocationContinentCode = "aws/geolocation-continent-code"
providerSpecificGeolocationCountryCode = "aws/geolocation-country-code"
providerSpecificGeolocationSubdivisionCode = "aws/geolocation-subdivision-code"
providerSpecificGeoProximityLocationAWSRegion = "aws/geoproximity-region"
providerSpecificGeoProximityLocationBias = "aws/geoproximity-bias"
providerSpecificGeoProximityLocationCoordinates = "aws/geoproximity-coordinates"
providerSpecificGeoProximityLocationLocalZoneGroup = "aws/geoproximity-local-zone-group"
providerSpecificMultiValueAnswer = "aws/multi-value-answer"
providerSpecificHealthCheckID = "aws/health-check-id"
sameZoneAlias = "same-zone"
// Currently supported up to 10 health checks or hosted zones.
// https://docs.aws.amazon.com/Route53/latest/APIReference/API_ListTagsForResources.html#API_ListTagsForResources_RequestSyntax
batchSize = 10
batchSize = 10
minLatitude = -90.0
maxLatitude = 90.0
minLongitude = -180.0
maxLongitude = 180.0
)
// see elb: https://docs.aws.amazon.com/general/latest/gr/elb.html
@ -231,6 +239,12 @@ type profiledZone struct {
zone *route53types.HostedZone
}
type geoProximity struct {
location *route53types.GeoProximityLocation
endpoint *endpoint.Endpoint
isSet bool
}
func (cs Route53Changes) Route53Changes() []route53types.Change {
var ret []route53types.Change
for _, c := range cs {
@ -542,6 +556,8 @@ func (p *AWSProvider) records(ctx context.Context, zones map[string]*profiledZon
ep.WithProviderSpecific(providerSpecificGeolocationSubdivisionCode, *r.GeoLocation.SubdivisionCode)
}
}
case r.GeoProximityLocation != nil:
handleGeoProximityLocationRecord(&r, ep)
default:
// one of the above needs to be set, otherwise SetIdentifier doesn't make sense
}
@ -560,6 +576,25 @@ func (p *AWSProvider) records(ctx context.Context, zones map[string]*profiledZon
return endpoints, nil
}
func handleGeoProximityLocationRecord(r *route53types.ResourceRecordSet, ep *endpoint.Endpoint) {
if region := aws.ToString(r.GeoProximityLocation.AWSRegion); region != "" {
ep.WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, region)
}
if bias := r.GeoProximityLocation.Bias; bias != nil {
ep.WithProviderSpecific(providerSpecificGeoProximityLocationBias, fmt.Sprintf("%d", aws.ToInt32(bias)))
}
if coords := r.GeoProximityLocation.Coordinates; coords != nil {
coordinates := fmt.Sprintf("%s,%s", aws.ToString(coords.Latitude), aws.ToString(coords.Longitude))
ep.WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, coordinates)
}
if localZoneGroup := aws.ToString(r.GeoProximityLocation.LocalZoneGroup); localZoneGroup != "" {
ep.WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, localZoneGroup)
}
}
// Identify if old and new endpoints require DELETE/CREATE instead of UPDATE.
func (p *AWSProvider) requiresDeleteCreate(old *endpoint.Endpoint, newE *endpoint.Endpoint) bool {
// a change of a record type
@ -832,12 +867,32 @@ func (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoi
} else {
ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)
}
adjustGeoProximityLocationEndpoint(ep)
}
endpoints = append(endpoints, aliasCnameAaaaEndpoints...)
return endpoints, nil
}
// if the endpoint is using geoproximity, set the bias to 0 if not set
// this is needed to avoid unnecessary Upserts if the desired endpoint doesn't specify a bias
func adjustGeoProximityLocationEndpoint(ep *endpoint.Endpoint) {
if ep.SetIdentifier == "" {
return
}
_, ok1 := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion)
_, ok2 := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup)
_, ok3 := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates)
if ok1 || ok2 || ok3 {
// check if ep has bias property and if not, set it to 0
if _, ok := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationBias); !ok {
ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationBias, "0")
}
}
}
// newChange returns a route53 Change
// returned Change is based on the given record by the given action, e.g.
// action=ChangeActionCreate returns a change for creation of the record and
@ -926,6 +981,8 @@ func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.E
if useGeolocation {
change.ResourceRecordSet.GeoLocation = geolocation
}
withChangeForGeoProximityEndpoint(change, ep)
}
if prop, ok := ep.GetProviderSpecificProperty(providerSpecificHealthCheckID); ok {
@ -939,6 +996,99 @@ func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.E
return change
}
func newGeoProximity(ep *endpoint.Endpoint) *geoProximity {
return &geoProximity{
location: &route53types.GeoProximityLocation{},
endpoint: ep,
isSet: false,
}
}
func (gp *geoProximity) withAWSRegion() *geoProximity {
if prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion); ok {
gp.location.AWSRegion = aws.String(prop)
gp.isSet = true
}
return gp
}
// add a method to set the local zone group for the geoproximity location
func (gp *geoProximity) withLocalZoneGroup() *geoProximity {
if prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup); ok {
gp.location.LocalZoneGroup = aws.String(prop)
gp.isSet = true
}
return gp
}
// add a method to set the bias for the geoproximity location
func (gp *geoProximity) withBias() *geoProximity {
if prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationBias); ok {
bias, err := strconv.ParseInt(prop, 10, 32)
if err != nil {
log.Warnf("Failed parsing value of %s: %s: %v; using bias of 0", providerSpecificGeoProximityLocationBias, prop, err)
bias = 0
}
gp.location.Bias = aws.Int32(int32(bias))
gp.isSet = true
}
return gp
}
// validateCoordinates checks if the given latitude and longitude are valid.
func validateCoordinates(lat, long string) error {
latitude, err := strconv.ParseFloat(lat, 64)
if err != nil || latitude < minLatitude || latitude > maxLatitude {
return fmt.Errorf("invalid latitude: must be a number between %f and %f", minLatitude, maxLatitude)
}
longitude, err := strconv.ParseFloat(long, 64)
if err != nil || longitude < minLongitude || longitude > maxLongitude {
return fmt.Errorf("invalid longitude: must be a number between %f and %f", minLongitude, maxLongitude)
}
return nil
}
func (gp *geoProximity) withCoordinates() *geoProximity {
if prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates); ok {
coordinates := strings.Split(prop, ",")
if len(coordinates) == 2 {
latitude := coordinates[0]
longitude := coordinates[1]
if err := validateCoordinates(latitude, longitude); err != nil {
log.Warnf("Invalid coordinates %s for name=%s setIdentifier=%s; %v", prop, gp.endpoint.DNSName, gp.endpoint.SetIdentifier, err)
} else {
gp.location.Coordinates = &route53types.Coordinates{
Latitude: aws.String(latitude),
Longitude: aws.String(longitude),
}
gp.isSet = true
}
} else {
log.Warnf("Invalid coordinates format for %s: %s; expected format 'latitude,longitude'", providerSpecificGeoProximityLocationCoordinates, prop)
}
}
return gp
}
func (gp *geoProximity) build() *route53types.GeoProximityLocation {
if gp.isSet {
return gp.location
}
return nil
}
func withChangeForGeoProximityEndpoint(change *Route53Change, ep *endpoint.Endpoint) {
geoProx := newGeoProximity(ep).
withAWSRegion().
withCoordinates().
withLocalZoneGroup().
withBias()
change.ResourceRecordSet.GeoProximityLocation = geoProx.build()
}
// searches for `changes` that are contained in `queue` and returns the `changes` separated by whether they were found in the queue (`foundChanges`) or not (`notFoundChanges`)
func findChangesInQueue(changes Route53Changes, queue Route53Changes) (Route53Changes, Route53Changes) {
if queue == nil {

View File

@ -583,6 +583,42 @@ func TestAWSRecords(t *testing.T) {
SubdivisionCode: aws.String("NY"),
},
},
{
Name: aws.String("geoproximitylocation-region.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeA,
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
SetIdentifier: aws.String("test-set-1"),
GeoProximityLocation: &route53types.GeoProximityLocation{
AWSRegion: aws.String("us-west-2"),
Bias: aws.Int32(10),
},
},
{
Name: aws.String("geoproximitylocation-localzone.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeA,
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
SetIdentifier: aws.String("test-set-1"),
GeoProximityLocation: &route53types.GeoProximityLocation{
LocalZoneGroup: aws.String("usw2-pdx1-az1"),
Bias: aws.Int32(10),
},
},
{
Name: aws.String("geoproximitylocation-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeA,
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
SetIdentifier: aws.String("test-set-1"),
GeoProximityLocation: &route53types.GeoProximityLocation{
Coordinates: &route53types.Coordinates{
Latitude: aws.String("90"),
Longitude: aws.String("90"),
},
Bias: aws.Int32(0),
},
},
{
Name: aws.String("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeCname,
@ -636,6 +672,9 @@ func TestAWSRecords(t *testing.T) {
endpoint.NewEndpointWithTTL("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeolocationContinentCode, "EU"),
endpoint.NewEndpointWithTTL("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "4.3.2.1").WithSetIdentifier("test-set-2").WithProviderSpecific(providerSpecificGeolocationCountryCode, "DE"),
endpoint.NewEndpointWithTTL("geolocation-subdivision-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeolocationSubdivisionCode, "NY"),
endpoint.NewEndpointWithTTL("geoproximitylocation-region.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"),
endpoint.NewEndpointWithTTL("geoproximitylocation-localzone.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, "usw2-pdx1-az1").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"),
endpoint.NewEndpointWithTTL("geoproximitylocation-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, "90,90").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "0"),
endpoint.NewEndpointWithTTL("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "foo.example.com").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificWeight, "10").WithProviderSpecific(providerSpecificHealthCheckID, "foo-bar-healthcheck-id").WithProviderSpecific(providerSpecificAlias, "false"),
endpoint.NewEndpointWithTTL("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "4.3.2.1").WithSetIdentifier("test-set-2").WithProviderSpecific(providerSpecificWeight, "20").WithProviderSpecific(providerSpecificHealthCheckID, "abc-def-healthcheck-id"),
endpoint.NewEndpointWithTTL("mail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, endpoint.TTL(defaultTTL), "10 mailhost1.example.com", "20 mailhost2.example.com"),
@ -670,6 +709,7 @@ func TestAWSAdjustEndpoints(t *testing.T) {
endpoint.NewEndpoint("cname-test-elb-no-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "false"),
endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health
endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
endpoint.NewEndpoint("a-test-geoproximity-no-bias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2"),
}
records, err := provider.AdjustEndpoints(records)
@ -687,6 +727,7 @@ func TestAWSAdjustEndpoints(t *testing.T) {
endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health
endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"),
endpoint.NewEndpoint("a-test-geoproximity-no-bias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "0"),
})
}
@ -845,6 +886,27 @@ func TestAWSApplyChanges(t *testing.T) {
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}, {Value: aws.String("2606:4700:4700::1001")}},
},
{
Name: aws.String("delete-test-geoproximity.zone-2.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeA,
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
SetIdentifier: aws.String("geoproximity-delete"),
GeoProximityLocation: &route53types.GeoProximityLocation{
AWSRegion: aws.String("us-west-2"),
Bias: aws.Int32(10),
},
},
{
Name: aws.String("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeA,
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
SetIdentifier: aws.String("geoproximity-update"),
GeoProximityLocation: &route53types.GeoProximityLocation{
LocalZoneGroup: aws.String("usw2-lax1-az2"),
},
},
{
Name: aws.String("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeA,
@ -915,6 +977,13 @@ func TestAWSApplyChanges(t *testing.T) {
endpoint.NewEndpoint("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"),
endpoint.NewEndpoint("create-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"),
endpoint.NewEndpoint("create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mailhost1.foo.elb.amazonaws.com"),
endpoint.NewEndpoint("create-test-geoproximity-region.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").
WithSetIdentifier("geoproximity-region").
WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").
WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"),
endpoint.NewEndpoint("create-test-geoproximity-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").
WithSetIdentifier("geoproximity-coordinates").
WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, "60,60"),
}
currentRecords := []*endpoint.Endpoint{
@ -930,6 +999,9 @@ func TestAWSApplyChanges(t *testing.T) {
endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "bar.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"),
endpoint.NewEndpoint("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"),
endpoint.NewEndpoint("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").
WithSetIdentifier("geoproximity-update").
WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, "usw2-lax1-az2"),
endpoint.NewEndpoint("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("weighted-to-simple").WithProviderSpecific(providerSpecificWeight, "10"),
endpoint.NewEndpoint("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"),
endpoint.NewEndpoint("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithProviderSpecific(providerSpecificWeight, "10"),
@ -951,6 +1023,9 @@ func TestAWSApplyChanges(t *testing.T) {
endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "baz.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"),
endpoint.NewEndpoint("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001", "2606:4700:4700::1111"),
endpoint.NewEndpoint("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").
WithSetIdentifier("geoproximity-update").
WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, "usw2-phx2-az1"),
endpoint.NewEndpoint("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"),
endpoint.NewEndpoint("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("simple-to-weighted").WithProviderSpecific(providerSpecificWeight, "10"),
endpoint.NewEndpoint("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithProviderSpecific(providerSpecificRegion, "us-east-1"),
@ -969,6 +1044,7 @@ func TestAWSApplyChanges(t *testing.T) {
endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "qux.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"),
endpoint.NewEndpoint("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"),
endpoint.NewEndpoint("delete-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"),
endpoint.NewEndpoint("delete-test-geoproximity.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("geoproximity-delete").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"),
endpoint.NewEndpoint("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "30 mailhost1.foo.elb.amazonaws.com"),
}
@ -1118,6 +1194,40 @@ func TestAWSApplyChanges(t *testing.T) {
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mailhost1.foo.elb.amazonaws.com")}},
},
{
Name: aws.String("create-test-geoproximity-region.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeA,
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
SetIdentifier: aws.String("geoproximity-region"),
GeoProximityLocation: &route53types.GeoProximityLocation{
AWSRegion: aws.String("us-west-2"),
Bias: aws.Int32(10),
},
},
{
Name: aws.String("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeA,
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}},
SetIdentifier: aws.String("geoproximity-update"),
GeoProximityLocation: &route53types.GeoProximityLocation{
LocalZoneGroup: aws.String("usw2-phx2-az1"),
},
},
{
Name: aws.String("create-test-geoproximity-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: route53types.RRTypeA,
TTL: aws.Int64(defaultTTL),
ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}},
SetIdentifier: aws.String("geoproximity-coordinates"),
GeoProximityLocation: &route53types.GeoProximityLocation{
Coordinates: &route53types.Coordinates{
Latitude: aws.String("60"),
Longitude: aws.String("60"),
},
},
},
})
validateRecords(t, listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do."), []route53types.ResourceRecordSet{
{
@ -1902,7 +2012,7 @@ func validateEndpoints(t *testing.T, provider *AWSProvider, endpoints []*endpoin
normalized, err := provider.AdjustEndpoints(endpoints)
assert.NoError(t, err)
assert.True(t, testutils.SameEndpoints(normalized, expected), "actual and normalized endpoints don't match. %+v:%+v", endpoints, normalized)
assert.True(t, testutils.SameEndpoints(normalized, expected), "normalized and expected endpoints don't match. %+v:%+v", normalized, expected)
}
func validateAWSZones(t *testing.T, zones map[string]*route53types.HostedZone, expected map[string]*route53types.HostedZone) {
@ -2370,3 +2480,340 @@ func TestConvertOctalToAscii(t *testing.T) {
})
}
}
func TestGeoProximityWithAWSRegion(t *testing.T) {
tests := []struct {
name string
region string
hasRegion bool
expectedSet bool
expectedRegion string
}{
{
name: "valid AWS region",
region: "us-west-2",
hasRegion: true,
expectedSet: true,
expectedRegion: "us-west-2",
},
{
name: "another valid AWS region",
region: "eu-central-1",
hasRegion: true,
expectedSet: true,
expectedRegion: "eu-central-1",
},
{
name: "empty region string",
region: "",
hasRegion: true,
expectedSet: true,
expectedRegion: "",
},
{
name: "no region property set",
region: "",
hasRegion: false,
expectedSet: false,
expectedRegion: "",
},
{
name: "region with special characters",
region: "us-gov-west-1",
hasRegion: true,
expectedSet: true,
expectedRegion: "us-gov-west-1",
},
{
name: "region with numbers",
region: "ap-southeast-3",
hasRegion: true,
expectedSet: true,
expectedRegion: "ap-southeast-3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ep := &endpoint.Endpoint{
DNSName: "test.example.com",
SetIdentifier: "test-set",
}
if tt.hasRegion {
ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion, tt.region)
}
gp := newGeoProximity(ep)
result := gp.withAWSRegion()
assert.Equal(t, tt.expectedSet, result.isSet)
if tt.expectedSet {
assert.NotNil(t, result.location.AWSRegion)
assert.Equal(t, tt.expectedRegion, *result.location.AWSRegion)
} else {
assert.Nil(t, result.location.AWSRegion)
}
// Verify the method returns the same instance for chaining
assert.Equal(t, gp, result)
})
}
}
func TestGeoProximityWithLocalZoneGroup(t *testing.T) {
tests := []struct {
name string
localZoneGroup string
hasLocalZoneGroup bool
expectedSet bool
expectedLocalZoneGroup string
}{
{
name: "valid local zone group",
localZoneGroup: "usw2-lax1-az1",
hasLocalZoneGroup: true,
expectedSet: true,
expectedLocalZoneGroup: "usw2-lax1-az1",
},
{
name: "empty local zone group",
localZoneGroup: "",
hasLocalZoneGroup: true,
expectedSet: true,
expectedLocalZoneGroup: "",
},
{
name: "no local zone group property",
localZoneGroup: "",
hasLocalZoneGroup: false,
expectedSet: false,
expectedLocalZoneGroup: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ep := &endpoint.Endpoint{
DNSName: "test.example.com",
SetIdentifier: "test-set",
}
if tt.hasLocalZoneGroup {
ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup, tt.localZoneGroup)
}
gp := newGeoProximity(ep)
result := gp.withLocalZoneGroup()
assert.Equal(t, tt.expectedSet, result.isSet)
if tt.expectedSet {
assert.NotNil(t, result.location.LocalZoneGroup)
assert.Equal(t, tt.expectedLocalZoneGroup, *result.location.LocalZoneGroup)
} else {
assert.Nil(t, result.location.LocalZoneGroup)
}
// Verify method returns same instance for chaining
assert.Equal(t, gp, result)
})
}
}
func TestGeoProximityWithCoordinates(t *testing.T) {
tests := []struct {
name string
coordinates string
expectedSet bool
expectedLat string
expectedLong string
shouldHaveCoords bool
}{
{
name: "valid coordinates",
coordinates: "45.0,90.0",
expectedSet: true,
expectedLat: "45.0",
expectedLong: "90.0",
shouldHaveCoords: true,
},
{
name: "edge case min coordinates",
coordinates: "-90.0,-180.0",
expectedSet: true,
expectedLat: "-90.0",
expectedLong: "-180.0",
shouldHaveCoords: true,
},
{
name: "edge case max coordinates",
coordinates: "90.0,180.0",
expectedSet: true,
expectedLat: "90.0",
expectedLong: "180.0",
shouldHaveCoords: true,
},
{
name: "invalid latitude too high",
coordinates: "91.0,90.0",
expectedSet: false,
shouldHaveCoords: false,
},
{
name: "invalid longitude too low",
coordinates: "45.0,-181.0",
expectedSet: false,
shouldHaveCoords: false,
},
{
name: "invalid format - single value",
coordinates: "45.0",
expectedSet: false,
shouldHaveCoords: false,
},
{
name: "invalid format - three values",
coordinates: "45.0,90.0,10.0",
expectedSet: false,
shouldHaveCoords: false,
},
{
name: "invalid format - non-numeric",
coordinates: "abc,def",
expectedSet: false,
shouldHaveCoords: false,
},
{
name: "no coordinates property",
coordinates: "",
expectedSet: false,
shouldHaveCoords: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ep := &endpoint.Endpoint{}
if tt.coordinates != "" {
ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates, tt.coordinates)
}
gp := newGeoProximity(ep)
result := gp.withCoordinates()
assert.Equal(t, tt.expectedSet, result.isSet)
if tt.shouldHaveCoords {
assert.NotNil(t, result.location.Coordinates)
assert.Equal(t, tt.expectedLat, *result.location.Coordinates.Latitude)
assert.Equal(t, tt.expectedLong, *result.location.Coordinates.Longitude)
} else {
assert.Nil(t, result.location.Coordinates)
}
})
}
}
func TestGeoProximityWithBias(t *testing.T) {
tests := []struct {
name string
bias string
hasBias bool
expectedSet bool
expectedBias int32
}{
{
name: "valid positive bias",
bias: "10",
hasBias: true,
expectedSet: true,
expectedBias: 10,
},
{
name: "valid negative bias",
bias: "-5",
hasBias: true,
expectedSet: true,
expectedBias: -5,
},
{
name: "zero bias",
bias: "0",
hasBias: true,
expectedSet: true,
expectedBias: 0,
},
{
name: "large positive bias",
bias: "99",
hasBias: true,
expectedSet: true,
expectedBias: 99,
},
{
name: "large negative bias",
bias: "-99",
hasBias: true,
expectedSet: true,
expectedBias: -99,
},
{
name: "invalid bias - non-numeric",
bias: "abc",
hasBias: true,
expectedSet: true,
expectedBias: 0, // defaults to 0 on error
},
{
name: "invalid bias - float",
bias: "10.5",
hasBias: true,
expectedSet: true,
expectedBias: 0, // defaults to 0 on error
},
{
name: "empty bias string",
bias: "",
hasBias: true,
expectedSet: true,
expectedBias: 0, // defaults to 0 on error
},
{
name: "no bias property",
bias: "",
hasBias: false,
expectedSet: false,
expectedBias: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ep := &endpoint.Endpoint{
DNSName: "test.example.com",
SetIdentifier: "test-set",
}
if tt.hasBias {
ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationBias, tt.bias)
}
gp := newGeoProximity(ep)
result := gp.withBias()
assert.Equal(t, tt.expectedSet, result.isSet)
if tt.expectedSet {
assert.NotNil(t, result.location.Bias)
assert.Equal(t, tt.expectedBias, *result.location.Bias)
} else {
assert.Nil(t, result.location.Bias)
}
// Verify method returns same instance for chaining
assert.Equal(t, gp, result)
})
}
}