From 7e8d8219b3c933fed1d9aba45ba2b9662dd433e0 Mon Sep 17 00:00:00 2001 From: Peter Stokes Date: Thu, 21 Nov 2024 13:58:49 +0000 Subject: [PATCH 1/4] Add RRSetChange type to plan, representing changes to be applied to a single resource record set --- plan/plan.go | 92 ++++++++++++++ plan/plan_test.go | 314 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+) diff --git a/plan/plan.go b/plan/plan.go index 6124b76406..ba97dcc25b 100644 --- a/plan/plan.go +++ b/plan/plan.go @@ -18,6 +18,8 @@ package plan import ( "fmt" + "iter" + "maps" "strings" "github.com/google/go-cmp/cmp" @@ -51,6 +53,39 @@ type Plan struct { OwnerID string } +// RRName is a canonical name associated with a resource record (i.e. lower-case with trailing period) +type RRName string + +// RRType is a type associated with a resource record (e.g., "A", "AAAA", "TXT", etc.) +type RRType string + +// RRKey is a key for entries in maps of resource records +type RRKey struct { + Name RRName + Type RRType +} + +func newRRKey(ep *endpoint.Endpoint) RRKey { + return RRKey{ + Name: RRName(normalizeDNSName(ep.DNSName)), + Type: RRType(ep.RecordType), + } +} + +// RRSetChange represents changes to be applied to a single resource record set +// +// | Action | Create | Delete | +// |--------+--------+--------| +// | Create | * | nil | +// | Delete | nil | * | +// | Update | * | * | +type RRSetChange struct { + Name RRName + Type RRType + Create []*endpoint.Endpoint + Delete []*endpoint.Endpoint +} + // Changes holds lists of actions to be executed by dns providers type Changes struct { // Records that need to be created @@ -154,6 +189,63 @@ func (t *planTable) newPlanKey(e *endpoint.Endpoint) planKey { return key } +// Return an iterator of changes to resource records sets +func (c *Changes) All() iter.Seq[*RRSetChange] { + return func(yield func(change *RRSetChange) bool) { + rrSetCreates := map[RRKey]*RRSetChange{} + rrSetDeletes := map[RRKey]*RRSetChange{} + rrSetUpdates := map[RRKey]*RRSetChange{} + for _, action := range []struct { + endpoints *[]*endpoint.Endpoint + rrSetChanges *map[RRKey]*RRSetChange + }{ + {endpoints: &c.UpdateNew, rrSetChanges: &rrSetUpdates}, + {endpoints: &c.UpdateOld, rrSetChanges: &rrSetUpdates}, + {endpoints: &c.Create, rrSetChanges: &rrSetCreates}, + {endpoints: &c.Delete, rrSetChanges: &rrSetDeletes}, + } { + for _, ep := range *action.endpoints { + rrKey := newRRKey(ep) + change, ok := rrSetUpdates[rrKey] + if !ok && action.rrSetChanges != &rrSetUpdates { + if action.rrSetChanges != &rrSetCreates { + change, ok = rrSetCreates[rrKey] + if ok { + delete(rrSetCreates, rrKey) + rrSetUpdates[rrKey] = change + } + } + if !ok { + change, ok = (*action.rrSetChanges)[rrKey] + } + } + if !ok { + change = &RRSetChange{ + Name: rrKey.Name, + Type: rrKey.Type, + } + (*action.rrSetChanges)[rrKey] = change + } + switch action.endpoints { + case &c.Create, &c.UpdateNew: + change.Create = append(change.Create, ep) + case &c.Delete, &c.UpdateOld: + change.Delete = append(change.Delete, ep) + } + } + } + for _, rrSetChanges := range []*map[RRKey]*RRSetChange{ + &rrSetDeletes, &rrSetUpdates, &rrSetCreates, + } { + for rrSetChange := range maps.Values(*rrSetChanges) { + if !yield(rrSetChange) { + return + } + } + } + } +} + func (c *Changes) HasChanges() bool { if len(c.Create) > 0 || len(c.Delete) > 0 { return true diff --git a/plan/plan_test.go b/plan/plan_test.go index 5aecc8632e..ebd418347a 100644 --- a/plan/plan_test.go +++ b/plan/plan_test.go @@ -17,6 +17,7 @@ limitations under the License. package plan import ( + "slices" "testing" "github.com/stretchr/testify/assert" @@ -26,6 +27,319 @@ import ( "sigs.k8s.io/external-dns/internal/testutils" ) + +func TestChangesAll(t *testing.T) { + assert.Equal(t, []*RRSetChange(nil), slices.Collect((&Changes{}).All())) + assert.Equal(t, []*RRSetChange{ + &RRSetChange{ + Name: "delete.", + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Delete", + Targets: endpoint.Targets{"Delete"}, + }, + }, + }, + &RRSetChange{ + Name: "update.", + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + Targets: endpoint.Targets{"UpdateOld"}, + }, + }, + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + Targets: endpoint.Targets{"UpdateNew"}, + }, + }, + }, + &RRSetChange{ + Name: "create.", + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Create", + Targets: endpoint.Targets{"Create"}, + }, + }, + }}, slices.Collect((&Changes{ + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Create", + Targets: endpoint.Targets{"Create"}, + }, + }, + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Delete", + Targets: endpoint.Targets{"Delete"}, + }, + }, + UpdateNew: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + Targets: endpoint.Targets{"UpdateNew"}, + }, + }, + UpdateOld: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + Targets: endpoint.Targets{"UpdateOld"}, + }, + }, + }).All()), + ) + assert.ElementsMatch(t, []*RRSetChange{ + &RRSetChange{ + Name: "create.", + Type: "TXT", + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Create", + RecordType: "TXT", + SetIdentifier: "1", + Targets: endpoint.Targets{"Create/TXT/1"}, + }, + &endpoint.Endpoint{ + DNSName: "Create", + RecordType: "TXT", + SetIdentifier: "2", + Targets: endpoint.Targets{"Create/TXT/2"}, + }, + }, + }, + &RRSetChange{ + Name: "create.", + Type: "CNAME", + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Create", + RecordType: "CNAME", + Targets: endpoint.Targets{"Create/CNAME"}, + }, + }, + }}, slices.Collect((&Changes{ + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Create", + RecordType: "TXT", + SetIdentifier: "1", + Targets: endpoint.Targets{"Create/TXT/1"}, + }, + &endpoint.Endpoint{ + DNSName: "Create", + RecordType: "CNAME", + Targets: endpoint.Targets{"Create/CNAME"}, + }, + &endpoint.Endpoint{ + DNSName: "Create", + RecordType: "TXT", + SetIdentifier: "2", + Targets: endpoint.Targets{"Create/TXT/2"}, + }, + }, + }).All()), + ) + assert.ElementsMatch(t, []*RRSetChange{ + &RRSetChange{ + Name: "delete.", + Type: "TXT", + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Delete", + RecordType: "TXT", + SetIdentifier: "1", + Targets: endpoint.Targets{"Delete/TXT/1"}, + }, + &endpoint.Endpoint{ + DNSName: "Delete", + RecordType: "TXT", + SetIdentifier: "2", + Targets: endpoint.Targets{"Delete/TXT/2"}, + }, + }, + }, + &RRSetChange{ + Name: "delete.", + Type: "CNAME", + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Delete", + RecordType: "CNAME", + Targets: endpoint.Targets{"Delete/CNAME"}, + }, + }, + }}, slices.Collect((&Changes{ + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Delete", + SetIdentifier: "1", + RecordType: "TXT", + Targets: endpoint.Targets{"Delete/TXT/1"}, + }, + &endpoint.Endpoint{ + DNSName: "Delete", + RecordType: "CNAME", + Targets: endpoint.Targets{"Delete/CNAME"}, + }, + &endpoint.Endpoint{ + DNSName: "Delete", + SetIdentifier: "2", + RecordType: "TXT", + Targets: endpoint.Targets{"Delete/TXT/2"}, + }, + }, + }).All()), + ) + assert.ElementsMatch(t, []*RRSetChange{ + &RRSetChange{ + Name: "update.", + Type: "TXT", + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + SetIdentifier: "1", + RecordType: "TXT", + Targets: endpoint.Targets{"UpdateOld/TXT/1"}, + }, + &endpoint.Endpoint{ + DNSName: "Update", + SetIdentifier: "2", + RecordType: "TXT", + Targets: endpoint.Targets{"UpdateOld/TXT/2"}, + }, + }, + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + SetIdentifier: "1", + RecordType: "TXT", + Targets: endpoint.Targets{"UpdateNew/TXT/1"}, + }, + &endpoint.Endpoint{ + DNSName: "Update", + SetIdentifier: "2", + RecordType: "TXT", + Targets: endpoint.Targets{"UpdateNew/TXT/2"}, + }, + }, + }, + &RRSetChange{ + Name: "update.", + Type: "CNAME", + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + RecordType: "CNAME", + Targets: endpoint.Targets{"UpdateOld/CNAME"}, + }, + }, + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + RecordType: "CNAME", + Targets: endpoint.Targets{"UpdateNew/CNAME"}, + }, + }, + }}, slices.Collect((&Changes{ + UpdateNew: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + SetIdentifier: "1", + RecordType: "TXT", + Targets: endpoint.Targets{"UpdateNew/TXT/1"}, + }, + &endpoint.Endpoint{ + DNSName: "Update", + RecordType: "CNAME", + Targets: endpoint.Targets{"UpdateNew/CNAME"}, + }, + &endpoint.Endpoint{ + DNSName: "Update", + SetIdentifier: "2", + RecordType: "TXT", + Targets: endpoint.Targets{"UpdateNew/TXT/2"}, + }, + }, + UpdateOld: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + RecordType: endpoint.RecordTypeTXT, + SetIdentifier: "1", + Targets: endpoint.Targets{"UpdateOld/TXT/1"}, + }, + &endpoint.Endpoint{ + DNSName: "Update", + RecordType: endpoint.RecordTypeCNAME, + Targets: endpoint.Targets{"UpdateOld/CNAME"}, + }, + &endpoint.Endpoint{ + DNSName: "Update", + SetIdentifier: "2", + RecordType: endpoint.RecordTypeTXT, + Targets: endpoint.Targets{"UpdateOld/TXT/2"}, + }, + }, + }).All()), + ) + assert.ElementsMatch(t, []*RRSetChange{ + &RRSetChange{ + Name: "update.", + Type: "TXT", + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + RecordType: "TXT", + Targets: endpoint.Targets{"Delete/TXT"}, + }, + }, + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + RecordType: "TXT", + Targets: endpoint.Targets{"Create/TXT"}, + }, + }, + }}, slices.Collect((&Changes{ + Create: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + RecordType: "TXT", + Targets: endpoint.Targets{"Create/TXT"}, + }, + }, + Delete: []*endpoint.Endpoint{ + &endpoint.Endpoint{ + DNSName: "Update", + RecordType: "TXT", + Targets: endpoint.Targets{"Delete/TXT"}, + }, + }, + }).All()), + ) +} + +func TestChangesHasChanges(t *testing.T) { + assert.Equal(t, false, (&Changes{}).HasChanges()) + assert.Equal(t, true, (&Changes{ + Create: []*endpoint.Endpoint{&endpoint.Endpoint{}}, + }).HasChanges()) + assert.Equal(t, true, (&Changes{ + Delete: []*endpoint.Endpoint{&endpoint.Endpoint{}}, + }).HasChanges()) + assert.Equal(t, true, (&Changes{ + UpdateOld: []*endpoint.Endpoint{&endpoint.Endpoint{}}, + }).HasChanges()) + assert.Equal(t, true, (&Changes{ + UpdateNew: []*endpoint.Endpoint{&endpoint.Endpoint{}}, + }).HasChanges()) + assert.Equal(t, false, (&Changes{ + UpdateOld: []*endpoint.Endpoint{&endpoint.Endpoint{}}, + UpdateNew: []*endpoint.Endpoint{&endpoint.Endpoint{}}, + }).HasChanges()) +} + type PlanTestSuite struct { suite.Suite fooV1Cname *endpoint.Endpoint From 238fcf3d738b4a257c3656df977a208134082d0d Mon Sep 17 00:00:00 2001 From: Peter Stokes Date: Thu, 21 Nov 2024 10:31:12 +0000 Subject: [PATCH 2/4] Refactor Google provider tests to avoid sharing state between tests and relying upon the implementation under test to establish the initial test conditions. Also make mock interface implementations more representative of real behaviour. --- provider/google/google.go | 67 +- provider/google/google_test.go | 1391 ++++++++++++++++---------------- 2 files changed, 735 insertions(+), 723 deletions(-) diff --git a/provider/google/google.go b/provider/google/google.go index a3222ad59e..ce36549d41 100644 --- a/provider/google/google.go +++ b/provider/google/google.go @@ -49,8 +49,8 @@ type managedZonesListCallInterface interface { } type managedZonesServiceInterface interface { - Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface - List(project string) managedZonesListCallInterface + Create(managedZone *dns.ManagedZone) managedZonesCreateCallInterface + List() managedZonesListCallInterface } type resourceRecordSetsListCallInterface interface { @@ -58,7 +58,7 @@ type resourceRecordSetsListCallInterface interface { } type resourceRecordSetsClientInterface interface { - List(project string, managedZone string) resourceRecordSetsListCallInterface + List(managedZone string) resourceRecordSetsListCallInterface } type changesCreateCallInterface interface { @@ -66,42 +66,43 @@ type changesCreateCallInterface interface { } type changesServiceInterface interface { - Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface + Create(managedZone string, change *dns.Change) changesCreateCallInterface } type resourceRecordSetsService struct { + project string service *dns.ResourceRecordSetsService } -func (r resourceRecordSetsService) List(project string, managedZone string) resourceRecordSetsListCallInterface { - return r.service.List(project, managedZone) +func (r resourceRecordSetsService) List(managedZone string) resourceRecordSetsListCallInterface { + return r.service.List(r.project, managedZone) } type managedZonesService struct { + project string service *dns.ManagedZonesService } -func (m managedZonesService) Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface { - return m.service.Create(project, managedzone) +func (m managedZonesService) Create(managedZone *dns.ManagedZone) managedZonesCreateCallInterface { + return m.service.Create(m.project, managedZone) } -func (m managedZonesService) List(project string) managedZonesListCallInterface { - return m.service.List(project) +func (m managedZonesService) List() managedZonesListCallInterface { + return m.service.List(m.project) } type changesService struct { + project string service *dns.ChangesService } -func (c changesService) Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface { - return c.service.Create(project, managedZone, change) +func (c changesService) Create(managedZone string, change *dns.Change) changesCreateCallInterface { + return c.service.Create(c.project, managedZone, change) } // GoogleProvider is an implementation of Provider for Google CloudDNS. type GoogleProvider struct { provider.BaseProvider - // The Google project to work in - project string // Enabled dry-run will print any modifying actions rather than execute them. dryRun bool // Max batch size to submit to Google Cloud DNS per transaction. @@ -155,17 +156,25 @@ func NewGoogleProvider(ctx context.Context, project string, domainFilter endpoin zoneTypeFilter := provider.NewZoneTypeFilter(zoneVisibility) provider := &GoogleProvider{ - project: project, - dryRun: dryRun, - batchChangeSize: batchChangeSize, - batchChangeInterval: batchChangeInterval, - domainFilter: domainFilter, - zoneTypeFilter: zoneTypeFilter, - zoneIDFilter: zoneIDFilter, - resourceRecordSetsClient: resourceRecordSetsService{dnsClient.ResourceRecordSets}, - managedZonesClient: managedZonesService{dnsClient.ManagedZones}, - changesClient: changesService{dnsClient.Changes}, - ctx: ctx, + dryRun: dryRun, + batchChangeSize: batchChangeSize, + batchChangeInterval: batchChangeInterval, + domainFilter: domainFilter, + zoneTypeFilter: zoneTypeFilter, + zoneIDFilter: zoneIDFilter, + managedZonesClient: managedZonesService{ + project: project, + service: dnsClient.ManagedZones, + }, + resourceRecordSetsClient: resourceRecordSetsService{ + project: project, + service: dnsClient.ResourceRecordSets, + }, + changesClient: changesService{ + project: project, + service: dnsClient.Changes, + }, + ctx: ctx, } return provider, nil @@ -193,12 +202,12 @@ func (p *GoogleProvider) Zones(ctx context.Context) (map[string]*dns.ManagedZone } log.Debugf("Matching zones against domain filters: %v", p.domainFilter) - if err := p.managedZonesClient.List(p.project).Pages(ctx, f); err != nil { + if err := p.managedZonesClient.List().Pages(ctx, f); err != nil { return nil, provider.NewSoftError(fmt.Errorf("failed to list zones: %w", err)) } if len(zones) == 0 { - log.Warnf("No zones in the project, %s, match domain filters: %v", p.project, p.domainFilter) + log.Warnf("No zones match domain filters: %v", p.domainFilter) } for _, zone := range zones { @@ -227,7 +236,7 @@ func (p *GoogleProvider) Records(ctx context.Context) (endpoints []*endpoint.End } for _, z := range zones { - if err := p.resourceRecordSetsClient.List(p.project, z.Name).Pages(ctx, f); err != nil { + if err := p.resourceRecordSetsClient.List(z.Name).Pages(ctx, f); err != nil { return nil, provider.NewSoftError(fmt.Errorf("failed to list records in zone %s: %w", z.Name, err)) } } @@ -301,7 +310,7 @@ func (p *GoogleProvider) submitChange(ctx context.Context, change *dns.Change) e continue } - if _, err := p.changesClient.Create(p.project, zone, c).Do(); err != nil { + if _, err := p.changesClient.Create(zone, c).Do(); err != nil { return provider.NewSoftError(fmt.Errorf("failed to create changes: %w", err)) } diff --git a/provider/google/google_test.go b/provider/google/google_test.go index 0cec40a386..e1720daf7d 100644 --- a/provider/google/google_test.go +++ b/provider/google/google_test.go @@ -18,7 +18,10 @@ package google import ( "fmt" + "maps" "net/http" + "reflect" + "slices" "sort" "strings" "testing" @@ -35,600 +38,519 @@ import ( "sigs.k8s.io/external-dns/provider" ) -var ( - testZones = map[string]*dns.ManagedZone{} - testRecords = map[string]map[string]*dns.ResourceRecordSet{} - googleDefaultBatchChangeSize = 4000 -) - -type mockManagedZonesCreateCall struct { - project string - managedZone *dns.ManagedZone -} - -func (m *mockManagedZonesCreateCall) Do(opts ...googleapi.CallOption) (*dns.ManagedZone, error) { - zoneKey := zoneKey(m.project, m.managedZone.Name) - - if _, ok := testZones[zoneKey]; ok { - return nil, &googleapi.Error{Code: http.StatusConflict} - } - - testZones[zoneKey] = m.managedZone - - return m.managedZone, nil -} - -type mockManagedZonesListCall struct { - project string - zonesListSoftErr error -} - -func (m *mockManagedZonesListCall) Pages(ctx context.Context, f func(*dns.ManagedZonesListResponse) error) error { - zones := []*dns.ManagedZone{} - - for k, v := range testZones { - if strings.HasPrefix(k, m.project+"/") { - zones = append(zones, v) - } - } - - if m.zonesListSoftErr != nil { - return m.zonesListSoftErr - } - - return f(&dns.ManagedZonesListResponse{ManagedZones: zones}) -} - -type mockManagedZonesClient struct { - zonesErr error -} - -func (m *mockManagedZonesClient) Create(project string, managedZone *dns.ManagedZone) managedZonesCreateCallInterface { - return &mockManagedZonesCreateCall{project: project, managedZone: managedZone} -} - -func (m *mockManagedZonesClient) List(project string) managedZonesListCallInterface { - return &mockManagedZonesListCall{project: project, zonesListSoftErr: m.zonesErr} -} - -type mockResourceRecordSetsListCall struct { - project string - managedZone string - recordsListSoftErr error -} - -func (m *mockResourceRecordSetsListCall) Pages(ctx context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error { - zoneKey := zoneKey(m.project, m.managedZone) - - if _, ok := testZones[zoneKey]; !ok { - return &googleapi.Error{Code: http.StatusNotFound} - } - - resp := []*dns.ResourceRecordSet{} - - for _, v := range testRecords[zoneKey] { - resp = append(resp, v) - } - - if m.recordsListSoftErr != nil { - return m.recordsListSoftErr - } - - return f(&dns.ResourceRecordSetsListResponse{Rrsets: resp}) -} - -type mockResourceRecordSetsClient struct { - recordsErr error -} - -func (m *mockResourceRecordSetsClient) List(project string, managedZone string) resourceRecordSetsListCallInterface { - return &mockResourceRecordSetsListCall{project: project, managedZone: managedZone, recordsListSoftErr: m.recordsErr} -} - -type mockChangesCreateCall struct { - project string - managedZone string - change *dns.Change -} - -func (m *mockChangesCreateCall) Do(opts ...googleapi.CallOption) (*dns.Change, error) { - zoneKey := zoneKey(m.project, m.managedZone) - - if _, ok := testZones[zoneKey]; !ok { - return nil, &googleapi.Error{Code: http.StatusNotFound} - } - - if _, ok := testRecords[zoneKey]; !ok { - testRecords[zoneKey] = make(map[string]*dns.ResourceRecordSet) - } - - for _, c := range append(m.change.Additions, m.change.Deletions...) { - if !isValidRecordSet(c) { - return nil, &googleapi.Error{ - Code: http.StatusBadRequest, - Message: fmt.Sprintf("invalid record: %v", c), - } - } - } - - for _, del := range m.change.Deletions { - recordKey := recordKey(del.Type, del.Name) - delete(testRecords[zoneKey], recordKey) - } - - for _, add := range m.change.Additions { - recordKey := recordKey(add.Type, add.Name) - testRecords[zoneKey][recordKey] = add - } - - return m.change, nil -} - -type mockChangesClient struct{} - -func (m *mockChangesClient) Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface { - return &mockChangesCreateCall{project: project, managedZone: managedZone, change: change} -} - -func zoneKey(project, zoneName string) string { - return project + "/" + zoneName -} - -func recordKey(recordType, recordName string) string { - return recordType + "/" + recordName -} - -func isValidRecordSet(recordSet *dns.ResourceRecordSet) bool { - if !hasTrailingDot(recordSet.Name) { - return false - } - - switch recordSet.Type { - case endpoint.RecordTypeCNAME: - for _, rrd := range recordSet.Rrdatas { - if !hasTrailingDot(rrd) { - return false - } - } - case endpoint.RecordTypeA, endpoint.RecordTypeTXT: - for _, rrd := range recordSet.Rrdatas { - if hasTrailingDot(rrd) { - return false - } - } - default: - panic("unhandled record type") +func TestGoogleZonesFilters(t *testing.T) { + p := newGoogleProvider().WithMockClients(newMockClients(t).WithRecords( + &map[*dns.ManagedZone][]*dns.ResourceRecordSet{ + &dns.ManagedZone{ + Name: "domain", + DnsName: "domain.", + Id: 10000, + Visibility: "private", + }: []*dns.ResourceRecordSet{}, + &dns.ManagedZone{ + Name: "internal-1", + DnsName: "cluster.local.", + Id: 10001, + Visibility: "private", + }: []*dns.ResourceRecordSet{}, + &dns.ManagedZone{ + Name: "internal-2", + DnsName: "cluster.local.", + Id: 10002, + Visibility: "private", + }: []*dns.ResourceRecordSet{}, + &dns.ManagedZone{ + Name: "internal-3", + DnsName: "cluster.local.", + Id: 10003, + Visibility: "private", + }: []*dns.ResourceRecordSet{}, + &dns.ManagedZone{ + Name: "split-horizon-1", + DnsName: "cluster.local.", + Id: 10004, + Visibility: "public", + }: []*dns.ResourceRecordSet{}, + &dns.ManagedZone{ + Name: "split-horizon-2", + DnsName: "cluster.local.", + Id: 10004, + Visibility: "private", + }: []*dns.ResourceRecordSet{}, + &dns.ManagedZone{ + Name: "svc-local", + DnsName: "svc.local.", + Id: 10005, + Visibility: "private", + }: []*dns.ResourceRecordSet{}, + &dns.ManagedZone{ + Name: "svc-local-peer", + DnsName: "svc.local.", + Id: 10006, + Visibility: "private", + PeeringConfig: &dns.ManagedZonePeeringConfig{TargetNetwork: nil}, + }: []*dns.ResourceRecordSet{}, + }, + )) + for _, test := range []struct{ + name string + domainFilter endpoint.DomainFilter + zoneIDFilter provider.ZoneIDFilter + zoneTypeFilter provider.ZoneTypeFilter + filterdZones map[string]*dns.ManagedZone + }{ + { + name: "Domain", + domainFilter: endpoint.NewDomainFilter([]string{"domain."}), + zoneIDFilter: provider.NewZoneIDFilter([]string{}), + zoneTypeFilter: provider.NewZoneTypeFilter(""), + filterdZones: map[string]*dns.ManagedZone{ + "domain": {Name: "domain", DnsName: "domain.", Id: 10000, Visibility: "private"}, + }, + }, + { + name: "ID", + domainFilter: endpoint.NewDomainFilter([]string{}), + zoneIDFilter: provider.NewZoneIDFilter([]string{"10002"}), + zoneTypeFilter: provider.NewZoneTypeFilter(""), + filterdZones: map[string]*dns.ManagedZone{ + "internal-2": {Name: "internal-2", DnsName: "cluster.local.", Id: 10002, Visibility: "private"}, + }, + }, + { + name: "Name", + domainFilter: endpoint.NewDomainFilter([]string{}), + zoneIDFilter: provider.NewZoneIDFilter([]string{"internal-2"}), + zoneTypeFilter: provider.NewZoneTypeFilter(""), + filterdZones: map[string]*dns.ManagedZone{ + "internal-2": {Name: "internal-2", DnsName: "cluster.local.", Id: 10002, Visibility: "private"}, + }, + }, + { + name: "Public", + domainFilter: endpoint.NewDomainFilter([]string{}), + zoneIDFilter: provider.NewZoneIDFilter([]string{"10004"}), + zoneTypeFilter: provider.NewZoneTypeFilter("public"), + filterdZones: map[string]*dns.ManagedZone{ + "split-horizon-1": {Name: "split-horizon-1", DnsName: "cluster.local.", Id: 10004, Visibility: "public"}, + }, + }, + { + name: "Private", + domainFilter: endpoint.NewDomainFilter([]string{}), + zoneIDFilter: provider.NewZoneIDFilter([]string{"10004"}), + zoneTypeFilter: provider.NewZoneTypeFilter("private"), + filterdZones: map[string]*dns.ManagedZone{ + "split-horizon-2": {Name: "split-horizon-2", DnsName: "cluster.local.", Id: 10004, Visibility: "private"}, + }, + }, + { + name: "Peering", + domainFilter: endpoint.NewDomainFilter([]string{"svc.local."}), + zoneIDFilter: provider.NewZoneIDFilter([]string{""}), + zoneTypeFilter: provider.NewZoneTypeFilter(""), + filterdZones: map[string]*dns.ManagedZone{ + "svc-local": {Name: "svc-local", DnsName: "svc.local.", Id: 10005, Visibility: "private"}, + }, + }, + } { + p.domainFilter = test.domainFilter + p.zoneTypeFilter = test.zoneTypeFilter + p.zoneIDFilter = test.zoneIDFilter + t.Run(test.name, func (t *testing.T) { + zones, err := p.Zones(context.Background()) + require.NoError(t, err) + assert.Equal(t, test.filterdZones, zones) + }) } - - return true -} - -func hasTrailingDot(target string) bool { - return strings.HasSuffix(target, ".") -} - -func TestGoogleZonesIDFilter(t *testing.T) { - provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"10002"}), provider.NewZoneTypeFilter(""), false, []*endpoint.Endpoint{}) - - zones, err := provider.Zones(context.Background()) - require.NoError(t, err) - - validateZones(t, zones, map[string]*dns.ManagedZone{ - "internal-2": {Name: "internal-2", DnsName: "cluster.local.", Id: 10002, Visibility: "private"}, - }) -} - -func TestGoogleZonesNameFilter(t *testing.T) { - provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"internal-2"}), provider.NewZoneTypeFilter(""), false, []*endpoint.Endpoint{}) - - zones, err := provider.Zones(context.Background()) - require.NoError(t, err) - - validateZones(t, zones, map[string]*dns.ManagedZone{ - "internal-2": {Name: "internal-2", DnsName: "cluster.local.", Id: 10002, Visibility: "private"}, - }) } -func TestGoogleZonesVisibilityFilterPublic(t *testing.T) { - provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"split-horizon-1"}), provider.NewZoneTypeFilter("public"), false, []*endpoint.Endpoint{}) - - zones, err := provider.Zones(context.Background()) +func TestGoogleRecords(t *testing.T) { + p := newGoogleProvider().WithMockClients(newMockClients(t).WithRecords( + &map[*dns.ManagedZone][]*dns.ResourceRecordSet{ + &dns.ManagedZone{ + Name: "zone-1", + DnsName: "zone-1.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "a.zone-1.local.", + Type: "A", + Ttl: 1, + Rrdatas: []string{"1.0.0.0"}, + }, + &dns.ResourceRecordSet{ + Name: "cname.zone-1.local.", + Type: "CNAME", + Ttl: 3, + Rrdatas: []string{"cname."}, + }, + }, + &dns.ManagedZone{ + Name: "zone-2", + DnsName: "zone-2.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "a.zone-2.local.", + Type: "A", + Ttl: 2, + Rrdatas: []string{"2.0.0.0"}, + }, + }, + &dns.ManagedZone{ + Name: "zone-3", + DnsName: "zone-3.local.", + }: []*dns.ResourceRecordSet{}, + }, + )) + records, err := p.Records(context.Background()) require.NoError(t, err) - - validateZones(t, zones, map[string]*dns.ManagedZone{ - "split-horizon-1": {Name: "split-horizon-1", DnsName: "cluster.local.", Id: 10001, Visibility: "public"}, + validateEndpoints(t, records, []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("a.zone-1.local", endpoint.RecordTypeA, endpoint.TTL(1), "1.0.0.0"), + endpoint.NewEndpointWithTTL("cname.zone-1.local", endpoint.RecordTypeCNAME, endpoint.TTL(3), "cname"), + endpoint.NewEndpointWithTTL("a.zone-2.local", endpoint.RecordTypeA, endpoint.TTL(2), "2.0.0.0"), }) } -func TestGoogleZonesVisibilityFilterPrivate(t *testing.T) { - provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"split-horizon-1"}), provider.NewZoneTypeFilter("public"), false, []*endpoint.Endpoint{}) - - zones, err := provider.Zones(context.Background()) - require.NoError(t, err) - - validateZones(t, zones, map[string]*dns.ManagedZone{ - "split-horizon-1": {Name: "split-horizon-1", DnsName: "cluster.local.", Id: 10001, Visibility: "public"}, +func TestGoogleRecordsFilter(t *testing.T) { + p := newGoogleProvider().WithMockClients(newMockClients(t).WithRecords( + &map[*dns.ManagedZone][]*dns.ResourceRecordSet{ + &dns.ManagedZone{ + Name: "zone-1", + DnsName: "zone-1.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "a.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"1.0.0.0"}, + }, + }, + &dns.ManagedZone{ + Name: "zone-2", + DnsName: "zone-2.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "a.zone-2.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"2.0.0.0"}, + }, + }, + &dns.ManagedZone{ + Name: "zone-3", + DnsName: "zone-3.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "a.zone-3.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"3.0.0.0"}, + }, + }, + }, + )) + p.domainFilter = endpoint.NewDomainFilter([]string{ + // our two valid zones + "zone-1.local.", + "zone-2.local.", + // we filter for a zone that doesn't exist, should have no effect. + "zone-0.local.", + // there exists a third zone "zone-3" that we want to exclude from being managed. }) -} - -func TestGoogleZonesVisibilityFilterPrivatePeering(t *testing.T) { - provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"svc.local."}), provider.NewZoneIDFilter([]string{""}), provider.NewZoneTypeFilter("private"), false, []*endpoint.Endpoint{}) - - zones, err := provider.Zones(context.Background()) + records, err := p.Records(context.Background()) require.NoError(t, err) - - validateZones(t, zones, map[string]*dns.ManagedZone{ - "svc-local": {Name: "svc-local", DnsName: "svc.local.", Id: 1005, Visibility: "private"}, + validateEndpoints(t, records, []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("a.zone-1.local", endpoint.RecordTypeA, googleRecordTTL, "1.0.0.0"), + endpoint.NewEndpointWithTTL("a.zone-2.local", endpoint.RecordTypeA, googleRecordTTL, "2.0.0.0"), }) } -func TestGoogleRecords(t *testing.T) { - originalEndpoints := []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("list-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(1), "1.2.3.4"), - endpoint.NewEndpointWithTTL("list-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(2), "8.8.8.8"), - endpoint.NewEndpointWithTTL("list-test-alias.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(3), "foo.elb.amazonaws.com"), - } - - provider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, originalEndpoints, nil, nil) - - records, err := provider.Records(context.Background()) - require.NoError(t, err) - - validateEndpoints(t, records, originalEndpoints) -} - -func TestGoogleRecordsFilter(t *testing.T) { - originalEndpoints := []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.8.8"), - endpoint.NewEndpointWithTTL("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.8.8"), - endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.4.4"), - endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.4.4"), - endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, googleRecordTTL, "bar.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, googleRecordTTL, "qux.elb.amazonaws.com"), - } - - provider := newGoogleProvider( - t, - endpoint.NewDomainFilter([]string{ - // our two valid zones - "zone-1.ext-dns-test-2.gcp.zalan.do.", - "zone-2.ext-dns-test-2.gcp.zalan.do.", - // we filter for a zone that doesn't exist, should have no effect. - "zone-0.ext-dns-test-2.gcp.zalan.do.", - // there exists a third zone "zone-3" that we want to exclude from being managed. - }), - provider.NewZoneIDFilter([]string{""}), - false, - originalEndpoints, - nil, - nil, - ) - - // these records should be filtered out since they don't match a hosted zone or domain filter. - ignoredEndpoints := []*endpoint.Endpoint{ - endpoint.NewEndpoint("filter-create-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), - endpoint.NewEndpoint("filter-update-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), - endpoint.NewEndpoint("filter-delete-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), - endpoint.NewEndpoint("filter-create-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), - endpoint.NewEndpoint("filter-update-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), - endpoint.NewEndpoint("filter-delete-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), - } - - require.NoError(t, provider.ApplyChanges(context.Background(), &plan.Changes{ - Create: ignoredEndpoints, - })) - - records, err := provider.Records(context.Background()) - require.NoError(t, err) - - // assert that due to filtering no changes were made. - validateEndpoints(t, records, originalEndpoints) -} - func TestGoogleApplyChanges(t *testing.T) { - provider := newGoogleProvider( - t, - endpoint.NewDomainFilter([]string{ - // our two valid zones - "zone-1.ext-dns-test-2.gcp.zalan.do.", - "zone-2.ext-dns-test-2.gcp.zalan.do.", - // we filter for a zone that doesn't exist, should have no effect. - "zone-0.ext-dns-test-2.gcp.zalan.do.", - // there exists a third zone "zone-3" that we want to exclude from being managed. - }), - provider.NewZoneIDFilter([]string{""}), - false, - []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.8.8"), - endpoint.NewEndpointWithTTL("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.8.8"), - endpoint.NewEndpointWithTTL("update-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(10), "8.8.4.4"), - endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.4.4"), - endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, googleRecordTTL, "bar.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, googleRecordTTL, "qux.elb.amazonaws.com"), + records := map[*dns.ManagedZone][]*dns.ResourceRecordSet{ + &dns.ManagedZone{ + Name: "zone-1", + DnsName: "zone-1.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "update-test.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"8.8.8.8"}, + }, + &dns.ResourceRecordSet{ + Name: "delete-test.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"8.8.8.8"}, + }, + &dns.ResourceRecordSet{ + Name: "update-test-cname.zone-1.local.", + Type: "CNAME", + Ttl: googleRecordTTL, + Rrdatas: []string{"update-test-cname."}, + }, + &dns.ResourceRecordSet{ + Name: "delete-test-cname.zone-1.local.", + Type: "CNAME", + Ttl: googleRecordTTL, + Rrdatas: []string{"delete-test-cname."}, + }, + }, + &dns.ManagedZone{ + Name: "zone-2", + DnsName: "zone-2.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "update-test-ttl.zone-2.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"8.8.4.4"}, + }, + &dns.ResourceRecordSet{ + Name: "delete-test.zone-2.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"8.8.4.4"}, + }, }, - nil, - nil, - ) - - createRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpointWithTTL("create-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(15), "8.8.4.4"), - endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), - endpoint.NewEndpoint("filter-create-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), - endpoint.NewEndpoint("nomatch-create-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.1"), - } - - currentRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), - endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), - endpoint.NewEndpoint("filter-update-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), - } - updatedRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), - endpoint.NewEndpointWithTTL("update-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(25), "4.3.2.1"), - endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), - endpoint.NewEndpoint("filter-update-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "5.6.7.8"), - endpoint.NewEndpoint("nomatch-update-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.7.6.5"), - } - - deleteRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), - endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), - endpoint.NewEndpoint("filter-delete-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), - endpoint.NewEndpoint("nomatch-delete-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.1"), - } - - changes := &plan.Changes{ - Create: createRecords, - UpdateNew: updatedRecords, - UpdateOld: currentRecords, - Delete: deleteRecords, } - - require.NoError(t, provider.ApplyChanges(context.Background(), changes)) - - records, err := provider.Records(context.Background()) - require.NoError(t, err) - - validateEndpoints(t, records, []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.8.8"), - endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "1.2.3.4"), - endpoint.NewEndpointWithTTL("create-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(15), "8.8.4.4"), - endpoint.NewEndpointWithTTL("update-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(25), "4.3.2.1"), - endpoint.NewEndpointWithTTL("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, googleRecordTTL, "foo.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, googleRecordTTL, "baz.elb.amazonaws.com"), + mockClients := newMockClients(t).WithRecords(&records) + p := newGoogleProvider().WithMockClients(mockClients) + require.NoError(t, p.ApplyChanges(context.Background(), &plan.Changes{ + Create: []*endpoint.Endpoint{ + endpoint.NewEndpoint("create-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpointWithTTL("create-test-ttl.zone-2.local", endpoint.RecordTypeA, endpoint.TTL(15), "8.8.4.4"), + endpoint.NewEndpoint("create-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "create-test-cname"), + endpoint.NewEndpoint("filter-create-test.zone-3.local", endpoint.RecordTypeA, "4.2.2.2"), + endpoint.NewEndpoint("nomatch-create-test.zone-0.local", endpoint.RecordTypeA, "4.2.2.1"), + }, + UpdateOld: []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpoint("update-test-ttl.zone-2.local", endpoint.RecordTypeA, "8.8.4.4"), + endpoint.NewEndpoint("update-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "update-test-cname"), + endpoint.NewEndpoint("filter-update-test.zone-3.local", endpoint.RecordTypeA, "4.2.2.2"), + }, + UpdateNew: []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.local", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpointWithTTL("update-test-ttl.zone-2.local", endpoint.RecordTypeA, endpoint.TTL(25), "4.3.2.1"), + endpoint.NewEndpoint("update-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "updated-test-cname"), + endpoint.NewEndpoint("filter-update-test.zone-3.local", endpoint.RecordTypeA, "5.6.7.8"), + endpoint.NewEndpoint("nomatch-update-test.zone-0.local", endpoint.RecordTypeA, "8.7.6.5"), + }, + Delete: []*endpoint.Endpoint{ + endpoint.NewEndpoint("delete-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpoint("delete-test.zone-2.local", endpoint.RecordTypeA, "8.8.4.4"), + endpoint.NewEndpoint("delete-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "delete-test-cname"), + endpoint.NewEndpoint("filter-delete-test.zone-3.local", endpoint.RecordTypeA, "4.2.2.2"), + endpoint.NewEndpoint("nomatch-delete-test.zone-0.local", endpoint.RecordTypeA, "4.2.2.1"), + }, + })) + mockClients.EqualRecords(&map[*dns.ManagedZone][]*dns.ResourceRecordSet{ + &dns.ManagedZone{ + Name: "zone-1", + DnsName: "zone-1.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "create-test.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"8.8.8.8"}, + }, + &dns.ResourceRecordSet{ + Name: "update-test.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"1.2.3.4"}, + }, + &dns.ResourceRecordSet{ + Name: "create-test-cname.zone-1.local.", + Type: "CNAME", + Ttl: googleRecordTTL, + Rrdatas: []string{"create-test-cname."}, + }, + &dns.ResourceRecordSet{ + Name: "update-test-cname.zone-1.local.", + Type: "CNAME", + Ttl: googleRecordTTL, + Rrdatas: []string{"updated-test-cname."}, + }, + }, + &dns.ManagedZone{ + Name: "zone-2", + DnsName: "zone-2.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "create-test-ttl.zone-2.local.", + Type: "A", + Ttl: 15, + Rrdatas: []string{"8.8.4.4"}, + }, + &dns.ResourceRecordSet{ + Name: "update-test-ttl.zone-2.local.", + Type: "A", + Ttl: 25, + Rrdatas: []string{"4.3.2.1"}, + }, + }, }) } func TestGoogleApplyChangesDryRun(t *testing.T) { - originalEndpoints := []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.8.8"), - endpoint.NewEndpointWithTTL("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.8.8"), - endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.4.4"), - endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, googleRecordTTL, "8.8.4.4"), - endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, googleRecordTTL, "bar.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, googleRecordTTL, "qux.elb.amazonaws.com"), - } - - provider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), true, originalEndpoints, nil, nil) - - createRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), - endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), - } - - currentRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), - endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), - } - updatedRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), - endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.3.2.1"), - endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), - } - - deleteRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), - endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), - } - - changes := &plan.Changes{ - Create: createRecords, - UpdateNew: updatedRecords, - UpdateOld: currentRecords, - Delete: deleteRecords, + records := map[*dns.ManagedZone][]*dns.ResourceRecordSet{ + &dns.ManagedZone{ + Name: "zone-1", + DnsName: "zone-1.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "update-test.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"8.8.8.8"}, + }, + &dns.ResourceRecordSet{ + Name: "delete-test.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"8.8.8.8"}, + }, + &dns.ResourceRecordSet{ + Name: "update-test-cname.zone-1.local.", + Type: "CNAME", + Ttl: googleRecordTTL, + Rrdatas: []string{"update-test-cname."}, + }, + &dns.ResourceRecordSet{ + Name: "delete-test-cname.zone-1.local.", + Type: "CNAME", + Ttl: googleRecordTTL, + Rrdatas: []string{"delete-test-cname."}, + }, + }, + &dns.ManagedZone{ + Name: "zone-2", + DnsName: "zone-2.local.", + }: []*dns.ResourceRecordSet{ + &dns.ResourceRecordSet{ + Name: "update-test.zone-2.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"8.8.4.4"}, + }, + &dns.ResourceRecordSet{ + Name: "delete-test.zone-2.local.", + Type: "A", + Ttl: googleRecordTTL, + Rrdatas: []string{"8.8.4.4"}, + }, + }, } - - ctx := context.Background() - require.NoError(t, provider.ApplyChanges(ctx, changes)) - - records, err := provider.Records(ctx) - require.NoError(t, err) - - validateEndpoints(t, records, originalEndpoints) + mockClients := newMockClients(t).WithRecords(&records) + p := newGoogleProvider().WithMockClients(mockClients) + p.dryRun = true + require.NoError(t, p.ApplyChanges(context.Background(), &plan.Changes{ + Create: []*endpoint.Endpoint{ + endpoint.NewEndpoint("create-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpoint("create-test.zone-2.local", endpoint.RecordTypeA, "8.8.4.4"), + endpoint.NewEndpoint("create-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "create-test-cname"), + }, + UpdateOld: []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpoint("update-test.zone-2.local", endpoint.RecordTypeA, "8.8.4.4"), + endpoint.NewEndpoint("update-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "update-test-cname"), + }, + UpdateNew: []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.local", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("update-test.zone-2.local", endpoint.RecordTypeA, "4.3.2.1"), + endpoint.NewEndpoint("update-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "updated-test-cname"), + }, + Delete: []*endpoint.Endpoint{ + endpoint.NewEndpoint("delete-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpoint("delete-test.zone-2.local", endpoint.RecordTypeA, "8.8.4.4"), + endpoint.NewEndpoint("delete-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "delete-test-cname"), + }, + })) + mockClients.EqualRecords(&records) } func TestGoogleApplyChangesEmpty(t *testing.T) { - provider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{}, nil, nil) - assert.NoError(t, provider.ApplyChanges(context.Background(), &plan.Changes{})) + p := newGoogleProvider().WithMockClients(newMockClients(t)) + assert.NoError(t, p.ApplyChanges(context.Background(), &plan.Changes{})) } func TestNewFilteredRecords(t *testing.T) { - provider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{}, nil, nil) + p := newGoogleProvider().WithMockClients(newMockClients(t)) - records := provider.newFilteredRecords([]*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, 1, "8.8.4.4"), - endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, 120, "8.8.4.4"), - endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, 4000, "bar.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("update-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do.", endpoint.RecordTypeNS, 120, "foo.elb.amazonaws.com"), + records := p.newFilteredRecords([]*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("update-test.zone-2.local", endpoint.RecordTypeA, 1, "8.8.4.4"), + endpoint.NewEndpointWithTTL("delete-test.zone-2.local", endpoint.RecordTypeA, 120, "8.8.4.4"), + endpoint.NewEndpointWithTTL("update-test-cname.zone-1.local", endpoint.RecordTypeCNAME, 4000, "update-test-cname"), // test fallback to Ttl:300 when Ttl==0 : - endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, 0, "8.8.8.8"), - endpoint.NewEndpointWithTTL("update-test-mx.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeMX, 6000, "10 mail.elb.amazonaws.com"), - endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), - endpoint.NewEndpoint("delete-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeNS, "foo.elb.amazonaws.com"), + endpoint.NewEndpointWithTTL("update-test.zone-1.local", endpoint.RecordTypeA, 0, "8.8.8.8"), + endpoint.NewEndpointWithTTL("update-test-mx.zone-1.local", endpoint.RecordTypeMX, 6000, "10 mail"), + endpoint.NewEndpoint("delete-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpoint("delete-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "delete-test-cname"), }) validateChangeRecords(t, records, []*dns.ResourceRecordSet{ - {Name: "update-test.zone-2.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"8.8.4.4"}, Type: "A", Ttl: 1}, - {Name: "delete-test.zone-2.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"8.8.4.4"}, Type: "A", Ttl: 120}, - {Name: "update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"bar.elb.amazonaws.com."}, Type: "CNAME", Ttl: 4000}, - {Name: "update-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"foo.elb.amazonaws.com."}, Type: "NS", Ttl: 120}, - {Name: "update-test.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"8.8.8.8"}, Type: "A", Ttl: 300}, - {Name: "update-test-mx.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"10 mail.elb.amazonaws.com."}, Type: "MX", Ttl: 6000}, - {Name: "delete-test.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"8.8.8.8"}, Type: "A", Ttl: 300}, - {Name: "delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"qux.elb.amazonaws.com."}, Type: "CNAME", Ttl: 300}, - {Name: "delete-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"foo.elb.amazonaws.com."}, Type: "NS", Ttl: 300}, - }) -} - -func TestSeparateChanges(t *testing.T) { - change := &dns.Change{ - Additions: []*dns.ResourceRecordSet{ - {Name: "qux.foo.example.org.", Ttl: 1}, - {Name: "qux.bar.example.org.", Ttl: 2}, - }, - Deletions: []*dns.ResourceRecordSet{ - {Name: "wambo.foo.example.org.", Ttl: 10}, - {Name: "wambo.bar.example.org.", Ttl: 20}, - }, - } - - zones := map[string]*dns.ManagedZone{ - "foo-example-org": { - Name: "foo-example-org", - DnsName: "foo.example.org.", - }, - "bar-example-org": { - Name: "bar-example-org", - DnsName: "bar.example.org.", - }, - "baz-example-org": { - Name: "baz-example-org", - DnsName: "baz.example.org.", - }, - } - - changes := separateChange(zones, change) - require.Len(t, changes, 2) - - validateChange(t, changes["foo-example-org"], &dns.Change{ - Additions: []*dns.ResourceRecordSet{ - {Name: "qux.foo.example.org.", Ttl: 1}, - }, - Deletions: []*dns.ResourceRecordSet{ - {Name: "wambo.foo.example.org.", Ttl: 10}, - }, - }) - - validateChange(t, changes["bar-example-org"], &dns.Change{ - Additions: []*dns.ResourceRecordSet{ - {Name: "qux.bar.example.org.", Ttl: 2}, - }, - Deletions: []*dns.ResourceRecordSet{ - {Name: "wambo.bar.example.org.", Ttl: 20}, - }, + {Name: "update-test.zone-2.local.", Rrdatas: []string{"8.8.4.4"}, Type: "A", Ttl: 1}, + {Name: "delete-test.zone-2.local.", Rrdatas: []string{"8.8.4.4"}, Type: "A", Ttl: 120}, + {Name: "update-test-cname.zone-1.local.", Rrdatas: []string{"update-test-cname."}, Type: "CNAME", Ttl: 4000}, + {Name: "update-test.zone-1.local.", Rrdatas: []string{"8.8.8.8"}, Type: "A", Ttl: 300}, + {Name: "update-test-mx.zone-1.local.", Rrdatas: []string{"10 mail."}, Type: "MX", Ttl: 6000}, + {Name: "delete-test.zone-1.local.", Rrdatas: []string{"8.8.8.8"}, Type: "A", Ttl: 300}, + {Name: "delete-test-cname.zone-1.local.", Rrdatas: []string{"delete-test-cname."}, Type: "CNAME", Ttl: 300}, }) } func TestGoogleBatchChangeSet(t *testing.T) { - cs := &dns.Change{} - - for i := 1; i <= googleDefaultBatchChangeSize; i += 2 { - cs.Additions = append(cs.Additions, &dns.ResourceRecordSet{ - Name: fmt.Sprintf("host-%d.example.org.", i), - Ttl: 2, - }) - cs.Deletions = append(cs.Deletions, &dns.ResourceRecordSet{ - Name: fmt.Sprintf("host-%d.example.org.", i), - Ttl: 20, - }) - } - - batchCs := batchChange(cs, googleDefaultBatchChangeSize) - - require.Equal(t, 1, len(batchCs)) - - sortChangesByName(cs) - validateChange(t, batchCs[0], cs) -} - -func TestGoogleBatchChangeSetExceeding(t *testing.T) { - cs := &dns.Change{} - const testCount = 50 - const testLimit = 11 - const expectedBatchCount = 5 - - for i := 1; i <= testCount; i += 2 { - cs.Additions = append(cs.Additions, &dns.ResourceRecordSet{ - Name: fmt.Sprintf("host-%d.example.org.", i), - Ttl: 2, - }) - cs.Deletions = append(cs.Deletions, &dns.ResourceRecordSet{ - Name: fmt.Sprintf("host-%d.example.org.", i), - Ttl: 20, - }) + mockClients := newMockClients(t) + mockClients.changesClient.maxChangeSize = 1 + p := newGoogleProvider().WithMockClients(mockClients.WithRecords( + &map[*dns.ManagedZone][]*dns.ResourceRecordSet{ + &dns.ManagedZone{ + Name: "zone", + DnsName: "zone.local.", + }: []*dns.ResourceRecordSet{}, + }, + )) + p.batchChangeSize = mockClients.changesClient.maxChangeSize + changes := plan.Changes{ + Create: make([]*endpoint.Endpoint, mockClients.changesClient.maxChangeSize + 1), } - - batchCs := batchChange(cs, testLimit) - - require.Equal(t, expectedBatchCount, len(batchCs)) - - dnsChange := &dns.Change{} - for _, c := range batchCs { - dnsChange.Additions = append(dnsChange.Additions, c.Additions...) - dnsChange.Deletions = append(dnsChange.Deletions, c.Deletions...) + for i := 0; i < len(changes.Create); i += 1 { + changes.Create[i] = endpoint.NewEndpointWithTTL(fmt.Sprintf("record%d.zone.local.", i), endpoint.RecordTypeA, 1, "1.2.3.4") } - - require.Equal(t, len(cs.Additions), len(dnsChange.Additions)) - require.Equal(t, len(cs.Deletions), len(dnsChange.Deletions)) - - sortChangesByName(cs) - sortChangesByName(dnsChange) - - validateChange(t, dnsChange, cs) -} - -func TestGoogleBatchChangeSetExceedingNameChange(t *testing.T) { - cs := &dns.Change{} - const testLimit = 1 - - cs.Additions = append(cs.Additions, &dns.ResourceRecordSet{ - Name: "host-1.example.org.", - Ttl: 2, - }) - cs.Deletions = append(cs.Deletions, &dns.ResourceRecordSet{ - Name: "host-1.example.org.", - Ttl: 20, - }) - - batchCs := batchChange(cs, testLimit) - - require.Equal(t, 0, len(batchCs)) + ctx := context.Background() + require.NoError(t, p.ApplyChanges(ctx, &changes)) + records, err := p.Records(ctx) + require.NoError(t, err) + validateEndpoints(t, records, changes.Create) } -func TestSoftErrListZonesConflict(t *testing.T) { - p := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{}), false, []*endpoint.Endpoint{}, provider.NewSoftError(fmt.Errorf("failed to list zones")), nil) - +func TestSoftErrListZones(t *testing.T) { + mockClients := newMockClients(t) + mockClients.managedZonesClient.zonesErr = provider.NewSoftError(fmt.Errorf("failed to list zones")) + p := newGoogleProvider().WithMockClients(mockClients) zones, err := p.Zones(context.Background()) require.Error(t, err) require.ErrorIs(t, err, provider.SoftError) - require.Empty(t, zones) } -func TestSoftErrListRecordsConflict(t *testing.T) { - p := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{}), false, []*endpoint.Endpoint{}, nil, provider.NewSoftError(fmt.Errorf("failed to list records in zone"))) - +func TestSoftErrListRecords(t *testing.T) { + mockClients := newMockClients(t) + mockClients.resourceRecordSetsClient.recordsErr = provider.NewSoftError(fmt.Errorf("failed to list records in zone")) + p := newGoogleProvider().WithMockClients(mockClients.WithRecords( + &map[*dns.ManagedZone][]*dns.ResourceRecordSet{ + &dns.ManagedZone{ + Name: "zone", + DnsName: "zone.local.", + }: []*dns.ResourceRecordSet{}, + }, + )) records, err := p.Records(context.Background()) require.Error(t, err) require.ErrorIs(t, err, provider.SoftError) - require.Empty(t, records) } @@ -642,25 +564,6 @@ func sortChangesByName(cs *dns.Change) { }) } -func validateZones(t *testing.T, zones map[string]*dns.ManagedZone, expected map[string]*dns.ManagedZone) { - require.Len(t, zones, len(expected)) - - for i, zone := range zones { - validateZone(t, zone, expected[i]) - } -} - -func validateZone(t *testing.T, zone *dns.ManagedZone, expected *dns.ManagedZone) { - assert.Equal(t, expected.Name, zone.Name) - assert.Equal(t, expected.DnsName, zone.DnsName) - assert.Equal(t, expected.Visibility, zone.Visibility) -} - -func validateChange(t *testing.T, change *dns.Change, expected *dns.Change) { - validateChangeRecords(t, change.Additions, expected.Additions) - validateChangeRecords(t, change.Deletions, expected.Deletions) -} - func validateChangeRecords(t *testing.T, records []*dns.ResourceRecordSet, expected []*dns.ResourceRecordSet) { require.Len(t, records, len(expected)) @@ -676,166 +579,266 @@ func validateChangeRecord(t *testing.T, record *dns.ResourceRecordSet, expected assert.Equal(t, expected.Type, record.Type) } -func newGoogleProviderZoneOverlap(t *testing.T, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, dryRun bool, _ []*endpoint.Endpoint) *GoogleProvider { - provider := &GoogleProvider{ - project: "zalando-external-dns-test", - dryRun: false, - domainFilter: domainFilter, - zoneIDFilter: zoneIDFilter, - zoneTypeFilter: zoneTypeFilter, - resourceRecordSetsClient: &mockResourceRecordSetsClient{}, - managedZonesClient: &mockManagedZonesClient{}, - changesClient: &mockChangesClient{}, - } - - createZone(t, provider, &dns.ManagedZone{ - Name: "internal-1", - DnsName: "cluster.local.", - Id: 10001, - Visibility: "private", - }) - - createZone(t, provider, &dns.ManagedZone{ - Name: "internal-2", - DnsName: "cluster.local.", - Id: 10002, - Visibility: "private", - }) - - createZone(t, provider, &dns.ManagedZone{ - Name: "internal-3", - DnsName: "cluster.local.", - Id: 10003, - Visibility: "private", - }) +func validateEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) { + assert.True(t, testutils.SameEndpoints(endpoints, expected), "actual and expected endpoints don't match. %s:%s", endpoints, expected) +} - createZone(t, provider, &dns.ManagedZone{ - Name: "split-horizon-1", - DnsName: "cluster.local.", - Id: 10004, - Visibility: "public", - }) +type mockChangesClient struct{ + records *map[*dns.ManagedZone][]*dns.ResourceRecordSet + maxChangeSize int +} - createZone(t, provider, &dns.ManagedZone{ - Name: "split-horizon-1", - DnsName: "cluster.local.", - Id: 10004, - Visibility: "private", - }) +type mockChangesCreateCall struct { + client *mockChangesClient + managedZone string + change *dns.Change + maxChangeSize int +} - createZone(t, provider, &dns.ManagedZone{ - Name: "svc-local", - DnsName: "svc.local.", - Id: 10005, - Visibility: "private", - }) +func (m *mockChangesCreateCall) Do(opts ...googleapi.CallOption) (*dns.Change, error) { + var managedZone *dns.ManagedZone + for zone := range maps.Keys(*m.client.records) { + if zone.Name == m.managedZone { + managedZone = zone + break + } + } + if managedZone == nil { + return nil, &googleapi.Error{Code: http.StatusNotFound} + } + size := len(m.change.Additions) + len(m.change.Deletions) + if m.maxChangeSize > 0 && size > m.maxChangeSize { + return nil, &googleapi.Error{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("change size %d exceeds maximum %d", size, m.maxChangeSize), + } + } + for _, records := range [][]*dns.ResourceRecordSet{m.change.Additions, m.change.Deletions} { + for _, record := range records { + if !strings.HasSuffix(record.Name, managedZone.DnsName) { + return nil, &googleapi.Error{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("invalid name %s for zone %s", record.Name, managedZone.DnsName), + } + } + if !isValidRecordSet(record) { + return nil, &googleapi.Error{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("invalid record: %v", record), + } + } + } + } + for _, deletion := range m.change.Deletions { + index := -1 + for i, record := range (*m.client.records)[managedZone] { + if reflect.DeepEqual(record, deletion) { + index = i + break + } + } + if index == -1 { + return nil, &googleapi.Error{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("record not found: %v", deletion), + } + } + (*m.client.records)[managedZone] = append( + (*m.client.records)[managedZone][:index], + (*m.client.records)[managedZone][index+1:]... + ) + } + for _, addition := range m.change.Additions { + for _, record := range (*m.client.records)[managedZone] { + if record.Name == addition.Name && record.Type == addition.Type { + return nil, &googleapi.Error{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("record exists: %v", addition), + } + } + } + (*m.client.records)[managedZone] = append((*m.client.records)[managedZone], addition) + } + return m.change, nil +} - createZone(t, provider, &dns.ManagedZone{ - Name: "svc-local-peer", - DnsName: "svc.local.", - Id: 10006, - Visibility: "private", - PeeringConfig: &dns.ManagedZonePeeringConfig{TargetNetwork: nil}, - }) +func (m *mockChangesClient) Create(managedZone string, change *dns.Change) changesCreateCallInterface { + return &mockChangesCreateCall{client: m, managedZone: managedZone, change: change, maxChangeSize: m.maxChangeSize} +} - provider.dryRun = dryRun +type mockResourceRecordSetsClient struct { + records *map[*dns.ManagedZone][]*dns.ResourceRecordSet + recordsErr error +} - return provider +type mockResourceRecordSetsListCall struct { + client *mockResourceRecordSetsClient + managedZone string } -func newGoogleProvider(t *testing.T, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, records []*endpoint.Endpoint, zonesErr, recordsErr error) *GoogleProvider { - provider := &GoogleProvider{ - project: "zalando-external-dns-test", - dryRun: false, - domainFilter: domainFilter, - zoneIDFilter: zoneIDFilter, - resourceRecordSetsClient: &mockResourceRecordSetsClient{ - recordsErr: recordsErr, - }, - managedZonesClient: &mockManagedZonesClient{ - zonesErr: zonesErr, - }, - changesClient: &mockChangesClient{}, +func (m *mockResourceRecordSetsListCall) Pages(ctx context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error { + if m.client.recordsErr != nil { + return m.client.recordsErr + } + var managedZone *dns.ManagedZone + for zone := range maps.Keys(*m.client.records) { + if zone.Name == m.managedZone { + managedZone = zone + break + } } + if managedZone == nil { + return &googleapi.Error{Code: http.StatusNotFound} + } + return f(&dns.ResourceRecordSetsListResponse{Rrsets: (*m.client.records)[managedZone]}) +} - createZone(t, provider, &dns.ManagedZone{ - Name: "zone-1-ext-dns-test-2-gcp-zalan-do", - DnsName: "zone-1.ext-dns-test-2.gcp.zalan.do.", - }) - - createZone(t, provider, &dns.ManagedZone{ - Name: "zone-2-ext-dns-test-2-gcp-zalan-do", - DnsName: "zone-2.ext-dns-test-2.gcp.zalan.do.", - }) - - createZone(t, provider, &dns.ManagedZone{ - Name: "zone-3-ext-dns-test-2-gcp-zalan-do", - DnsName: "zone-3.ext-dns-test-2.gcp.zalan.do.", - }) - - // filtered out by domain filter - createZone(t, provider, &dns.ManagedZone{ - Name: "zone-4-ext-dns-test-3-gcp-zalan-do", - DnsName: "zone-4.ext-dns-test-3.gcp.zalan.do.", - }) +func (m *mockResourceRecordSetsClient) List(managedZone string) resourceRecordSetsListCallInterface { + return &mockResourceRecordSetsListCall{client: m, managedZone: managedZone} +} - setupGoogleRecords(t, provider, records) +type mockManagedZonesClient struct { + records *map[*dns.ManagedZone][]*dns.ResourceRecordSet + zonesErr error +} - provider.dryRun = dryRun +type mockManagedZonesCreateCall struct { + client *mockManagedZonesClient + managedZone *dns.ManagedZone +} - return provider +func (m *mockManagedZonesCreateCall) Do(opts ...googleapi.CallOption) (*dns.ManagedZone, error) { + for zone := range maps.Keys(*m.client.records) { + if zone.Name == m.managedZone.Name { + return nil, &googleapi.Error{Code: http.StatusConflict} + } + } + (*m.client.records)[m.managedZone] = []*dns.ResourceRecordSet{} + return m.managedZone, nil } -func createZone(t *testing.T, p *GoogleProvider, zone *dns.ManagedZone) { - zone.Description = "Testing zone for kubernetes.io/external-dns" +type mockManagedZonesListCall struct { + client *mockManagedZonesClient +} - if _, err := p.managedZonesClient.Create("zalando-external-dns-test", zone).Do(); err != nil { - if err, ok := err.(*googleapi.Error); !ok || err.Code != http.StatusConflict { - require.NoError(t, err) - } +func (m *mockManagedZonesListCall) Pages(ctx context.Context, f func(*dns.ManagedZonesListResponse) error) error { + if m.client.zonesErr != nil { + return m.client.zonesErr } + return f(&dns.ManagedZonesListResponse{ManagedZones: slices.Collect(maps.Keys(*m.client.records))}) } -func setupGoogleRecords(t *testing.T, provider *GoogleProvider, endpoints []*endpoint.Endpoint) { - clearGoogleRecords(t, provider, "zone-1-ext-dns-test-2-gcp-zalan-do") - clearGoogleRecords(t, provider, "zone-2-ext-dns-test-2-gcp-zalan-do") - clearGoogleRecords(t, provider, "zone-3-ext-dns-test-2-gcp-zalan-do") +func (m *mockManagedZonesClient) Create(managedZone *dns.ManagedZone) managedZonesCreateCallInterface { + return &mockManagedZonesCreateCall{client: m, managedZone: managedZone} +} - ctx := context.Background() - records, _ := provider.Records(ctx) +func (m *mockManagedZonesClient) List() managedZonesListCallInterface { + return &mockManagedZonesListCall{client: m} +} - validateEndpoints(t, records, []*endpoint.Endpoint{}) +func hasTrailingDot(target string) bool { + return strings.HasSuffix(target, ".") +} - require.NoError(t, provider.ApplyChanges(context.Background(), &plan.Changes{ - Create: endpoints, - })) +func isValidRecordSet(recordSet *dns.ResourceRecordSet) bool { + if !hasTrailingDot(recordSet.Name) { + return false + } - records, _ = provider.Records(ctx) + switch recordSet.Type { + case endpoint.RecordTypeCNAME: + for _, rrd := range recordSet.Rrdatas { + if !hasTrailingDot(rrd) { + return false + } + } + case endpoint.RecordTypeA, endpoint.RecordTypeTXT: + for _, rrd := range recordSet.Rrdatas { + if hasTrailingDot(rrd) { + return false + } + } + default: + panic("unhandled record type") + } + + return true +} - validateEndpoints(t, records, endpoints) +type mockClients struct { + t *testing.T + records *map[*dns.ManagedZone][]*dns.ResourceRecordSet + changesClient *mockChangesClient + managedZonesClient *mockManagedZonesClient + resourceRecordSetsClient *mockResourceRecordSetsClient } -func clearGoogleRecords(t *testing.T, provider *GoogleProvider, zone string) { - recordSets := []*dns.ResourceRecordSet{} +func newMockClients(t *testing.T) *mockClients { + records := map[*dns.ManagedZone][]*dns.ResourceRecordSet{} + return &mockClients{ + t: t, + records: &records, + changesClient: &mockChangesClient{ + records: &records, + }, + managedZonesClient: &mockManagedZonesClient{ + records: &records, + }, + resourceRecordSetsClient: &mockResourceRecordSetsClient{ + records: &records, + }, + } +} - provider.resourceRecordSetsClient.List(provider.project, zone).Pages(context.Background(), func(resp *dns.ResourceRecordSetsListResponse) error { - for _, r := range resp.Rrsets { - switch r.Type { - case endpoint.RecordTypeA, endpoint.RecordTypeCNAME: - recordSets = append(recordSets, r) +func (c *mockClients) WithRecords(records *map[*dns.ManagedZone][]*dns.ResourceRecordSet) *mockClients { + for managedZone, resourceRecordSets := range *records { + if _, err := c.managedZonesClient.Create(managedZone).Do(); err != nil { + if err, ok := err.(*googleapi.Error); !ok || err.Code != http.StatusConflict { + require.NoError(c.t, err) } } - return nil - }) + change := &dns.Change{ + Additions: resourceRecordSets, + } + c.resourceRecordSetsClient.List(managedZone.Name).Pages(context.Background(), func(resp *dns.ResourceRecordSetsListResponse) error { + change.Deletions = append(change.Deletions, resp.Rrsets...) + return nil + }) + if len(change.Additions) + len(change.Deletions) > 0 { + _, err := c.changesClient.Create(managedZone.Name, change).Do() + require.NoError(c.t, err) + } + } + return c +} - if len(recordSets) != 0 { - _, err := provider.changesClient.Create(provider.project, zone, &dns.Change{ - Deletions: recordSets, - }).Do() - require.NoError(t, err) +func (c *mockClients) EqualRecords(records *map[*dns.ManagedZone][]*dns.ResourceRecordSet) { + c.t.Helper() + assert.Equal(c.t, len(*records), len(*c.records)) + for expectedZone, expectedRecords := range *records { + var actualZone *dns.ManagedZone + var actualRecords []*dns.ResourceRecordSet + for searchZone, searchRecords := range *c.records { + if reflect.DeepEqual(searchZone, expectedZone) { + actualZone = searchZone + actualRecords = searchRecords + break + } + } + if actualZone == nil { + assert.ElementsMatch(c.t, slices.Collect(maps.Keys(*records)), slices.Collect(maps.Keys(*c.records))) + } + assert.ElementsMatch(c.t, expectedRecords, actualRecords) } } -func validateEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) { - assert.True(t, testutils.SameEndpoints(endpoints, expected), "actual and expected endpoints don't match. %s:%s", endpoints, expected) +func newGoogleProvider() *GoogleProvider { + return &GoogleProvider{} +} + +func (p *GoogleProvider) WithMockClients(clients *mockClients) *GoogleProvider { + p.resourceRecordSetsClient = clients.resourceRecordSetsClient + p.managedZonesClient = clients.managedZonesClient + p.changesClient = clients.changesClient + return p } From d9229d846aecb1310b6191a8ed65d6943ab27442 Mon Sep 17 00:00:00 2001 From: Peter Stokes Date: Thu, 21 Nov 2024 10:43:07 +0000 Subject: [PATCH 3/4] Refactor Google provider to process Endpoints as a sequence of changes. --- provider/google/google.go | 297 ++++++++++----------------------- provider/google/google_test.go | 22 ++- 2 files changed, 105 insertions(+), 214 deletions(-) diff --git a/provider/google/google.go b/provider/google/google.go index ce36549d41..5530811fc1 100644 --- a/provider/google/google.go +++ b/provider/google/google.go @@ -19,7 +19,7 @@ package google import ( "context" "fmt" - "sort" + "slices" "strings" "time" @@ -156,12 +156,12 @@ func NewGoogleProvider(ctx context.Context, project string, domainFilter endpoin zoneTypeFilter := provider.NewZoneTypeFilter(zoneVisibility) provider := &GoogleProvider{ - dryRun: dryRun, - batchChangeSize: batchChangeSize, + dryRun: dryRun, + batchChangeSize: batchChangeSize, batchChangeInterval: batchChangeInterval, - domainFilter: domainFilter, - zoneTypeFilter: zoneTypeFilter, - zoneIDFilter: zoneIDFilter, + domainFilter: domainFilter, + zoneTypeFilter: zoneTypeFilter, + zoneIDFilter: zoneIDFilter, managedZonesClient: managedZonesService{ project: project, service: dnsClient.ManagedZones, @@ -244,232 +244,109 @@ func (p *GoogleProvider) Records(ctx context.Context) (endpoints []*endpoint.End return endpoints, nil } -// ApplyChanges applies a given set of changes in a given zone. +// ApplyChanges applies a given set of changes. func (p *GoogleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { - change := &dns.Change{} - - change.Additions = append(change.Additions, p.newFilteredRecords(changes.Create)...) - - change.Additions = append(change.Additions, p.newFilteredRecords(changes.UpdateNew)...) - change.Deletions = append(change.Deletions, p.newFilteredRecords(changes.UpdateOld)...) - - change.Deletions = append(change.Deletions, p.newFilteredRecords(changes.Delete)...) - - return p.submitChange(ctx, change) -} - -// SupportedRecordType returns true if the record type is supported by the provider -func (p *GoogleProvider) SupportedRecordType(recordType string) bool { - switch recordType { - case "MX": - return true - default: - return provider.SupportedRecordType(recordType) - } -} - -// newFilteredRecords returns a collection of RecordSets based on the given endpoints and domainFilter. -func (p *GoogleProvider) newFilteredRecords(endpoints []*endpoint.Endpoint) []*dns.ResourceRecordSet { - records := []*dns.ResourceRecordSet{} - - for _, endpoint := range endpoints { - if p.domainFilter.Match(endpoint.DNSName) { - records = append(records, newRecord(endpoint)) - } - } - - return records -} - -// submitChange takes a zone and a Change and sends it to Google. -func (p *GoogleProvider) submitChange(ctx context.Context, change *dns.Change) error { - if len(change.Additions) == 0 && len(change.Deletions) == 0 { - log.Info("All records are already up to date") - return nil - } - zones, err := p.Zones(ctx) if err != nil { return err } - - // separate into per-zone change sets to be passed to the API. - changes := separateChange(zones, change) - - for zone, change := range changes { - for batch, c := range batchChange(change, p.batchChangeSize) { - log.Infof("Change zone: %v batch #%d", zone, batch) - for _, del := range c.Deletions { - log.Infof("Del records: %s %s %s %d", del.Name, del.Type, del.Rrdatas, del.Ttl) + zoneMap := provider.ZoneIDName{} + for _, z := range zones { + zoneMap.Add(z.Name, z.DnsName) + } + zoneBatches := map[string][]*dns.Change{} + for rrSetChange := range changes.All() { + if zone, _ := zoneMap.FindZone(string(rrSetChange.Name)); zone != "" { + change := p.newChange(rrSetChange) + changeSize := len(change.Additions) + len(change.Deletions) + if changeSize == 0 { + continue + } + if _, ok := zoneBatches[zone]; !ok { + zoneBatches[zone] = []*dns.Change{{}} } - for _, add := range c.Additions { - log.Infof("Add records: %s %s %s %d", add.Name, add.Type, add.Rrdatas, add.Ttl) + batch := zoneBatches[zone][len(zoneBatches[zone])-1] + if p.batchChangeSize > 0 && len(batch.Additions)+len(batch.Deletions)+changeSize > p.batchChangeSize { + batch = &dns.Change{} + zoneBatches[zone] = append(zoneBatches[zone], batch) } + batch.Additions = append(batch.Additions, change.Additions...) + batch.Deletions = append(batch.Deletions, change.Deletions...) + } + } + for zone, batches := range zoneBatches { + for index, batch := range batches { + log.Infof("Change zone: %v batch #%d", zone, index) + for _, record := range batch.Deletions { + log.Infof("Del records: %s %s %s %d", record.Name, record.Type, record.Rrdatas, record.Ttl) + } + for _, record := range batch.Additions { + log.Infof("Add records: %s %s %s %d", record.Name, record.Type, record.Rrdatas, record.Ttl) + } if p.dryRun { continue } - - if _, err := p.changesClient.Create(zone, c).Do(); err != nil { + if index > 0 { + time.Sleep(p.batchChangeInterval) + } + if _, err := p.changesClient.Create(zone, batch).Do(); err != nil { return provider.NewSoftError(fmt.Errorf("failed to create changes: %w", err)) } - - time.Sleep(p.batchChangeInterval) } } return nil } -// batchChange separates a zone in multiple transaction. -func batchChange(change *dns.Change, batchSize int) []*dns.Change { - changes := []*dns.Change{} - - if batchSize == 0 { - return append(changes, change) - } - - type dnsChange struct { - additions []*dns.ResourceRecordSet - deletions []*dns.ResourceRecordSet - } - - changesByName := map[string]*dnsChange{} - - for _, a := range change.Additions { - change, ok := changesByName[a.Name] - if !ok { - change = &dnsChange{} - changesByName[a.Name] = change - } - - change.additions = append(change.additions, a) - } - - for _, a := range change.Deletions { - change, ok := changesByName[a.Name] - if !ok { - change = &dnsChange{} - changesByName[a.Name] = change - } - - change.deletions = append(change.deletions, a) - } - - names := make([]string, 0) - for v := range changesByName { - names = append(names, v) - } - sort.Strings(names) - - currentChange := &dns.Change{} - var totalChanges int - for _, name := range names { - c := changesByName[name] - - totalChangesByName := len(c.additions) + len(c.deletions) - - if totalChangesByName > batchSize { - log.Warnf("Total changes for %s exceeds max batch size of %d, total changes: %d", name, - batchSize, totalChangesByName) - continue - } - - if totalChanges+totalChangesByName > batchSize { - totalChanges = 0 - changes = append(changes, currentChange) - currentChange = &dns.Change{} - } - - currentChange.Additions = append(currentChange.Additions, c.additions...) - currentChange.Deletions = append(currentChange.Deletions, c.deletions...) - - totalChanges += totalChangesByName - } - - if totalChanges > 0 { - changes = append(changes, currentChange) - } - - return changes -} - -// separateChange separates a multi-zone change into a single change per zone. -func separateChange(zones map[string]*dns.ManagedZone, change *dns.Change) map[string]*dns.Change { - changes := make(map[string]*dns.Change) - zoneNameIDMapper := provider.ZoneIDName{} - for _, z := range zones { - zoneNameIDMapper[z.Name] = z.DnsName - changes[z.Name] = &dns.Change{ - Additions: []*dns.ResourceRecordSet{}, - Deletions: []*dns.ResourceRecordSet{}, - } - } - for _, a := range change.Additions { - if zoneName, _ := zoneNameIDMapper.FindZone(provider.EnsureTrailingDot(a.Name)); zoneName != "" { - changes[zoneName].Additions = append(changes[zoneName].Additions, a) - } else { - log.Warnf("No matching zone for record addition: %s %s %s %d", a.Name, a.Type, a.Rrdatas, a.Ttl) - } - } - - for _, d := range change.Deletions { - if zoneName, _ := zoneNameIDMapper.FindZone(provider.EnsureTrailingDot(d.Name)); zoneName != "" { - changes[zoneName].Deletions = append(changes[zoneName].Deletions, d) - } else { - log.Warnf("No matching zone for record deletion: %s %s %s %d", d.Name, d.Type, d.Rrdatas, d.Ttl) - } - } - - // separating a change could lead to empty sub changes, remove them here. - for zone, change := range changes { - if len(change.Additions) == 0 && len(change.Deletions) == 0 { - delete(changes, zone) - } +// SupportedRecordType returns true if the record type is supported by the provider +func (p *GoogleProvider) SupportedRecordType(recordType string) bool { + switch recordType { + case "MX": + return true + default: + return provider.SupportedRecordType(recordType) } - - return changes } -// newRecord returns a RecordSet based on the given endpoint. -func newRecord(ep *endpoint.Endpoint) *dns.ResourceRecordSet { - // TODO(linki): works around appending a trailing dot to TXT records. I think - // we should go back to storing DNS names with a trailing dot internally. This - // way we can use it has is here and trim it off if it exists when necessary. - targets := make([]string, len(ep.Targets)) - copy(targets, []string(ep.Targets)) - if ep.RecordType == endpoint.RecordTypeCNAME { - targets[0] = provider.EnsureTrailingDot(targets[0]) - } - - if ep.RecordType == endpoint.RecordTypeMX { - for i, mxRecord := range ep.Targets { - targets[i] = provider.EnsureTrailingDot(mxRecord) - } - } - - if ep.RecordType == endpoint.RecordTypeSRV { - for i, srvRecord := range ep.Targets { - targets[i] = provider.EnsureTrailingDot(srvRecord) - } - } - - if ep.RecordType == endpoint.RecordTypeNS { - for i, nsRecord := range ep.Targets { - targets[i] = provider.EnsureTrailingDot(nsRecord) +// newChange returns a DNS change based upon the given resource record set change. +func (p *GoogleProvider) newChange(rrSetChange *plan.RRSetChange) *dns.Change { + change := dns.Change{} + for index, endpoints := range [][]*endpoint.Endpoint{rrSetChange.Delete, rrSetChange.Create} { + for _, ep := range endpoints { + record := dns.ResourceRecordSet{ + Name: provider.EnsureTrailingDot(ep.DNSName), + Ttl: googleRecordTTL, + Type: ep.RecordType, + } + if ep.RecordTTL.IsConfigured() { + record.Ttl = int64(ep.RecordTTL) + } + // TODO(linki): works around appending a trailing dot to TXT records. I think + // we should go back to storing DNS names with a trailing dot internally. This + // way we can use it has is here and trim it off if it exists when necessary. + switch record.Type { + case endpoint.RecordTypeCNAME: + record.Rrdatas = []string{provider.EnsureTrailingDot(ep.Targets[0])} + case endpoint.RecordTypeMX: + fallthrough + case endpoint.RecordTypeNS: + fallthrough + case endpoint.RecordTypeSRV: + record.Rrdatas = make([]string, len(ep.Targets)) + for i, target := range ep.Targets { + record.Rrdatas[i] = provider.EnsureTrailingDot(target) + } + default: + record.Rrdatas = slices.Clone(ep.Targets) + } + switch index { + case 0: + change.Deletions = append(change.Deletions, &record) + case 1: + change.Additions = append(change.Additions, &record) + } } } - - // no annotation results in a Ttl of 0, default to 300 for backwards-compatibility - var ttl int64 = googleRecordTTL - if ep.RecordTTL.IsConfigured() { - ttl = int64(ep.RecordTTL) - } - - return &dns.ResourceRecordSet{ - Name: provider.EnsureTrailingDot(ep.DNSName), - Rrdatas: targets, - Ttl: ttl, - Type: ep.RecordType, - } + return &change } diff --git a/provider/google/google_test.go b/provider/google/google_test.go index e1720daf7d..cdb0fe824a 100644 --- a/provider/google/google_test.go +++ b/provider/google/google_test.go @@ -321,6 +321,7 @@ func TestGoogleApplyChanges(t *testing.T) { endpoint.NewEndpoint("create-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpointWithTTL("create-test-ttl.zone-2.local", endpoint.RecordTypeA, endpoint.TTL(15), "8.8.4.4"), endpoint.NewEndpoint("create-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "create-test-cname"), + endpoint.NewEndpoint("create-test-ns.zone-1.local", endpoint.RecordTypeNS, "create-test-ns"), endpoint.NewEndpoint("filter-create-test.zone-3.local", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("nomatch-create-test.zone-0.local", endpoint.RecordTypeA, "4.2.2.1"), }, @@ -374,6 +375,12 @@ func TestGoogleApplyChanges(t *testing.T) { Ttl: googleRecordTTL, Rrdatas: []string{"updated-test-cname."}, }, + &dns.ResourceRecordSet{ + Name: "create-test-ns.zone-1.local.", + Type: "NS", + Ttl: googleRecordTTL, + Rrdatas: []string{"create-test-ns."}, + }, }, &dns.ManagedZone{ Name: "zone-2", @@ -477,10 +484,11 @@ func TestGoogleApplyChangesEmpty(t *testing.T) { assert.NoError(t, p.ApplyChanges(context.Background(), &plan.Changes{})) } -func TestNewFilteredRecords(t *testing.T) { +func TestNewChange(t *testing.T) { p := newGoogleProvider().WithMockClients(newMockClients(t)) - records := p.newFilteredRecords([]*endpoint.Endpoint{ + records := []*dns.ResourceRecordSet{} + for _, ep := range []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("update-test.zone-2.local", endpoint.RecordTypeA, 1, "8.8.4.4"), endpoint.NewEndpointWithTTL("delete-test.zone-2.local", endpoint.RecordTypeA, 120, "8.8.4.4"), endpoint.NewEndpointWithTTL("update-test-cname.zone-1.local", endpoint.RecordTypeCNAME, 4000, "update-test-cname"), @@ -489,7 +497,13 @@ func TestNewFilteredRecords(t *testing.T) { endpoint.NewEndpointWithTTL("update-test-mx.zone-1.local", endpoint.RecordTypeMX, 6000, "10 mail"), endpoint.NewEndpoint("delete-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("delete-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "delete-test-cname"), - }) + } { + records = append(records, p.newChange(&plan.RRSetChange{ + Name: plan.RRName(provider.EnsureTrailingDot(ep.DNSName)), + Type: plan.RRType(ep.RecordType), + Create: []*endpoint.Endpoint{ep}, + }).Additions...) + } validateChangeRecords(t, records, []*dns.ResourceRecordSet{ {Name: "update-test.zone-2.local.", Rrdatas: []string{"8.8.4.4"}, Type: "A", Ttl: 1}, @@ -746,7 +760,7 @@ func isValidRecordSet(recordSet *dns.ResourceRecordSet) bool { } switch recordSet.Type { - case endpoint.RecordTypeCNAME: + case endpoint.RecordTypeCNAME, endpoint.RecordTypeMX, endpoint.RecordTypeNS, endpoint.RecordTypeSRV: for _, rrd := range recordSet.Rrdatas { if !hasTrailingDot(rrd) { return false From 15dea04e2e64d7dd4eeae6ee0cfbb0fac6a6c2d5 Mon Sep 17 00:00:00 2001 From: Peter Stokes Date: Thu, 21 Nov 2024 10:20:06 +0000 Subject: [PATCH 4/4] Add routing policy support to Google provider. --- docs/tutorials/google.md | 54 +++ main.go | 2 +- pkg/apis/externaldns/types.go | 3 + pkg/apis/externaldns/types_test.go | 4 + provider/google/google.go | 319 ++++++++++++++++-- provider/google/google_test.go | 510 ++++++++++++++++++++++++++--- source/source.go | 6 + 7 files changed, 812 insertions(+), 86 deletions(-) create mode 100644 docs/tutorials/google.md diff --git a/docs/tutorials/google.md b/docs/tutorials/google.md new file mode 100644 index 0000000000..2370cf6ab8 --- /dev/null +++ b/docs/tutorials/google.md @@ -0,0 +1,54 @@ +## Annotations + +Annotations which are specific to the Google +[CloudDNS](https://cloud.google.com/dns/docs/overview) provider. + +### Routing Policy + +The [routing policy](https://cloud.google.com/dns/docs/routing-policies-overview) +for resource record sets managed by ExternalDNS may be specified by applying the +`external-dns.alpha.kubernetes.io/google-routing-policy` annotation on any of the +supported [sources](../sources/about.md). + +#### Geolocation routing policies + +Specifying a value of `geo` for the `external-dns.alpha.kubernetes.io/google-routing-policy` +annotation will enable geolocation routing for associated resource record sets. The +location attributed to resource record sets may be deduced for instances of ExternalDNS +running within the Google Cloud platform or may be specified via the `--google-location` +command-line argument. Alternatively, a location may be explicitly specified via the +`external-dns.alpha.kubernetes.io/google-location` annotation, where the value is one +of Google Cloud's [locations/regions](https://cloud.google.com/docs/geography-and-regions). + +For example: +```yaml +apiVersion: externaldns.k8s.io/v1alpha1 +kind: DNSEndpoint +metadata: + name: geo-example + annotations: + external-dns.alpha.kubernetes.io/google-routing-policy: "geo" + external-dns.alpha.kubernetes.io/google-location: "us-east1" +``` + +#### Weighted Round Robin routing policies + +Specifying a value of `wrr` for the `external-dns.alpha.kubernetes.io/google-routing-policy` +annotation will enable weighted round-robin routing for associated resource record sets. +The weight to be attributed to resource record sets may be specified via the +`external-dns.alpha.kubernetes.io/google-weight` annotation, where the value is a string +representation of a floating-point number. The `external-dns.alpha.kubernetes.io/set-identifier` +annotation must also be applied providing a string value representation of an index into +the list of potential responses. + +For example: +```yaml +apiVersion: externaldns.k8s.io/v1alpha1 +kind: DNSEndpoint +metadata: + name: wrr-example + annotations: + external-dns.alpha.kubernetes.io/google-routing-policy: "wrr" + external-dns.alpha.kubernetes.io/google-weight: "100.0" + external-dns.alpha.kubernetes.io/set-identifier: "0" +``` \ No newline at end of file diff --git a/main.go b/main.go index 7d32da4a94..a737be38ee 100644 --- a/main.go +++ b/main.go @@ -248,7 +248,7 @@ func main() { case "cloudflare": p, err = cloudflare.NewCloudFlareProvider(domainFilter, zoneIDFilter, cfg.CloudflareProxied, cfg.DryRun, cfg.CloudflareDNSRecordsPerPage, cfg.CloudflareRegionKey) case "google": - p, err = google.NewGoogleProvider(ctx, cfg.GoogleProject, domainFilter, zoneIDFilter, cfg.GoogleBatchChangeSize, cfg.GoogleBatchChangeInterval, cfg.GoogleZoneVisibility, cfg.DryRun) + p, err = google.NewGoogleProvider(ctx, cfg.GoogleProject, cfg.GoogleLocation, domainFilter, zoneIDFilter, cfg.GoogleBatchChangeSize, cfg.GoogleBatchChangeInterval, cfg.GoogleZoneVisibility, cfg.DryRun) case "digitalocean": p, err = digitalocean.NewDigitalOceanProvider(ctx, domainFilter, cfg.DryRun, cfg.DigitalOceanAPIPageSize) case "ovh": diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 6cd78ee593..e46afc277e 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -69,6 +69,7 @@ type Config struct { Provider string ProviderCacheTime time.Duration GoogleProject string + GoogleLocation string GoogleBatchChangeSize int GoogleBatchChangeInterval time.Duration GoogleZoneVisibility string @@ -234,6 +235,7 @@ var defaultConfig = &Config{ Provider: "", ProviderCacheTime: 0, GoogleProject: "", + GoogleLocation: "", GoogleBatchChangeSize: 1000, GoogleBatchChangeInterval: time.Second, GoogleZoneVisibility: "", @@ -463,6 +465,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("zone-name-filter", "Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneNameFilter) app.Flag("zone-id-filter", "Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneIDFilter) app.Flag("google-project", "When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP.").Default(defaultConfig.GoogleProject).StringVar(&cfg.GoogleProject) + app.Flag("google-location", "When using the Google provider, current location is auto-detected, when running on GCP. Specify location with this. May be specified when running outside GCP.").Default(defaultConfig.GoogleLocation).StringVar(&cfg.GoogleLocation) app.Flag("google-batch-change-size", "When using the Google provider, set the maximum number of changes that will be applied in each batch.").Default(strconv.Itoa(defaultConfig.GoogleBatchChangeSize)).IntVar(&cfg.GoogleBatchChangeSize) app.Flag("google-batch-change-interval", "When using the Google provider, set the interval between batch changes.").Default(defaultConfig.GoogleBatchChangeInterval.String()).DurationVar(&cfg.GoogleBatchChangeInterval) app.Flag("google-zone-visibility", "When using the Google provider, filter for zones with this visibility (optional, options: public, private)").Default(defaultConfig.GoogleZoneVisibility).EnumVar(&cfg.GoogleZoneVisibility, "", "public", "private") diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index ab77cc9ec8..2eda4d7aab 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -43,6 +43,7 @@ var ( Compatibility: "", Provider: "google", GoogleProject: "", + GoogleLocation: "", GoogleBatchChangeSize: 1000, GoogleBatchChangeInterval: time.Second, GoogleZoneVisibility: "", @@ -142,6 +143,7 @@ var ( Compatibility: "mate", Provider: "google", GoogleProject: "project", + GoogleLocation: "location", GoogleBatchChangeSize: 100, GoogleBatchChangeInterval: time.Second * 2, GoogleZoneVisibility: "private", @@ -271,6 +273,7 @@ func TestParseFlags(t *testing.T) { "--compatibility=mate", "--provider=google", "--google-project=project", + "--google-location=location", "--google-batch-change-size=100", "--google-batch-change-interval=2s", "--google-zone-visibility=private", @@ -391,6 +394,7 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_COMPATIBILITY": "mate", "EXTERNAL_DNS_PROVIDER": "google", "EXTERNAL_DNS_GOOGLE_PROJECT": "project", + "EXTERNAL_DNS_GOOGLE_LOCATION": "location", "EXTERNAL_DNS_GOOGLE_BATCH_CHANGE_SIZE": "100", "EXTERNAL_DNS_GOOGLE_BATCH_CHANGE_INTERVAL": "2s", "EXTERNAL_DNS_GOOGLE_ZONE_VISIBILITY": "private", diff --git a/provider/google/google.go b/provider/google/google.go index 5530811fc1..ba0add6b23 100644 --- a/provider/google/google.go +++ b/provider/google/google.go @@ -18,8 +18,11 @@ package google import ( "context" + "encoding/json" "fmt" + "net/http" "slices" + "strconv" "strings" "time" @@ -38,6 +41,13 @@ import ( const ( googleRecordTTL = 300 + + providerSpecificRoutingPolicy = "google-routing-policy" + providerSpecificRoutingPolicyNone = "" + providerSpecificRoutingPolicyGeo = "geo" + providerSpecificRoutingPolicyWrr = "wrr" + providerSpecificLocation = "google-location" + providerSpecificWeight = "google-weight" ) type managedZonesCreateCallInterface interface { @@ -53,11 +63,16 @@ type managedZonesServiceInterface interface { List() managedZonesListCallInterface } +type resourceRecordSetsGetCallInterface interface { + Do(opts ...googleapi.CallOption) (*dns.ResourceRecordSet, error) +} + type resourceRecordSetsListCallInterface interface { Pages(ctx context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error } type resourceRecordSetsClientInterface interface { + Get(managedZone string, name string, type_ string) resourceRecordSetsGetCallInterface List(managedZone string) resourceRecordSetsListCallInterface } @@ -74,6 +89,10 @@ type resourceRecordSetsService struct { service *dns.ResourceRecordSetsService } +func (r resourceRecordSetsService) Get(managedZone string, name string, type_ string) resourceRecordSetsGetCallInterface { + return r.service.Get(r.project, managedZone, name, type_) +} + func (r resourceRecordSetsService) List(managedZone string) resourceRecordSetsListCallInterface { return r.service.List(r.project, managedZone) } @@ -103,6 +122,8 @@ func (c changesService) Create(managedZone string, change *dns.Change) changesCr // GoogleProvider is an implementation of Provider for Google CloudDNS. type GoogleProvider struct { provider.BaseProvider + // The default location to use + location string // Enabled dry-run will print any modifying actions rather than execute them. dryRun bool // Max batch size to submit to Google Cloud DNS per transaction. @@ -126,7 +147,7 @@ type GoogleProvider struct { } // NewGoogleProvider initializes a new Google CloudDNS based Provider. -func NewGoogleProvider(ctx context.Context, project string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, batchChangeSize int, batchChangeInterval time.Duration, zoneVisibility string, dryRun bool) (*GoogleProvider, error) { +func NewGoogleProvider(ctx context.Context, project string, location string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, batchChangeSize int, batchChangeInterval time.Duration, zoneVisibility string, dryRun bool) (*GoogleProvider, error) { gcloud, err := google.DefaultClient(ctx, dns.NdevClouddnsReadwriteScope) if err != nil { return nil, err @@ -153,6 +174,14 @@ func NewGoogleProvider(ctx context.Context, project string, domainFilter endpoin project = mProject } + if location == "" { + if zone, err := metadata.ZoneWithContext(ctx); err != nil { + parts := strings.Split(zone, "-") + location = strings.Join(parts[:len(parts)-1], "-") + log.Infof("Google location auto-detected: %s", location) + } + } + zoneTypeFilter := provider.NewZoneTypeFilter(zoneVisibility) provider := &GoogleProvider{ @@ -162,6 +191,7 @@ func NewGoogleProvider(ctx context.Context, project string, domainFilter endpoin domainFilter: domainFilter, zoneTypeFilter: zoneTypeFilter, zoneIDFilter: zoneIDFilter, + location: location, managedZonesClient: managedZonesService{ project: project, service: dnsClient.ManagedZones, @@ -229,6 +259,25 @@ func (p *GoogleProvider) Records(ctx context.Context) (endpoints []*endpoint.End if !p.SupportedRecordType(r.Type) { continue } + if r.RoutingPolicy != nil { + if r.RoutingPolicy.Geo != nil { + for _, item := range r.RoutingPolicy.Geo.Items { + ep := endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.Ttl), item.Rrdatas...) + ep.WithProviderSpecific(providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo) + ep.WithProviderSpecific(providerSpecificLocation, item.Location) + endpoints = append(endpoints, ep.WithSetIdentifier(item.Location)) + } + } + if r.RoutingPolicy.Wrr != nil { + for index, item := range r.RoutingPolicy.Wrr.Items { + ep := endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.Ttl), item.Rrdatas...) + ep.WithProviderSpecific(providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr) + ep.WithProviderSpecific(providerSpecificWeight, strconv.FormatFloat(item.Weight, 'g', 2, 64)) + endpoints = append(endpoints, ep.WithSetIdentifier(strconv.FormatInt(int64(index), 10))) + } + } + continue + } endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.Ttl), r.Rrdatas...)) } @@ -244,6 +293,36 @@ func (p *GoogleProvider) Records(ctx context.Context) (endpoints []*endpoint.End return endpoints, nil } +// AdjustEndpoints augments Endpoints generated by various sources to be equivalent to those returned by Records +func (p *GoogleProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { + for _, ep := range endpoints { + if routingPolicy, ok := ep.GetProviderSpecificProperty(providerSpecificRoutingPolicy); ok { + switch routingPolicy { + case providerSpecificRoutingPolicyGeo: + location, ok := ep.GetProviderSpecificProperty(providerSpecificLocation) + if !ok && p.location != "" { + ep.WithProviderSpecific(providerSpecificLocation, p.location) + location = p.location + } + ep.WithSetIdentifier(location) + case providerSpecificRoutingPolicyWrr: + weight, ok := ep.GetProviderSpecificProperty(providerSpecificWeight) + if !ok { + weight = "100" + } + if weight, err := strconv.ParseFloat(weight, 64); err == nil { + ep.WithProviderSpecific(providerSpecificWeight, strconv.FormatFloat(weight, 'g', 2, 64)) + } + if index, err := strconv.ParseInt(ep.SetIdentifier, 10, 64); err != nil || index < 0 { + resource := ep.Labels[endpoint.ResourceLabelKey] + log.Warnf("Endpoint generated from '%s' has 'wrr' routing policy with non-integer set identifier '%s'", resource, ep.SetIdentifier) + } + } + } + } + return endpoints, nil +} + // ApplyChanges applies a given set of changes. func (p *GoogleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { zones, err := p.Zones(ctx) @@ -257,7 +336,7 @@ func (p *GoogleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes zoneBatches := map[string][]*dns.Change{} for rrSetChange := range changes.All() { if zone, _ := zoneMap.FindZone(string(rrSetChange.Name)); zone != "" { - change := p.newChange(rrSetChange) + change := p.newChange(rrSetChange, zone) changeSize := len(change.Additions) + len(change.Deletions) if changeSize == 0 { continue @@ -274,7 +353,6 @@ func (p *GoogleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes batch.Deletions = append(batch.Deletions, change.Deletions...) } } - for zone, batches := range zoneBatches { for index, batch := range batches { log.Infof("Change zone: %v batch #%d", zone, index) @@ -295,7 +373,6 @@ func (p *GoogleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes } } } - return nil } @@ -310,43 +387,217 @@ func (p *GoogleProvider) SupportedRecordType(recordType string) bool { } // newChange returns a DNS change based upon the given resource record set change. -func (p *GoogleProvider) newChange(rrSetChange *plan.RRSetChange) *dns.Change { +func (p *GoogleProvider) newChange(rrSetChange *plan.RRSetChange, zone string) *dns.Change { change := dns.Change{} - for index, endpoints := range [][]*endpoint.Endpoint{rrSetChange.Delete, rrSetChange.Create} { - for _, ep := range endpoints { - record := dns.ResourceRecordSet{ - Name: provider.EnsureTrailingDot(ep.DNSName), - Ttl: googleRecordTTL, - Type: ep.RecordType, + current, err := p.resourceRecordSetsClient.Get(zone, string(rrSetChange.Name), string(rrSetChange.Type)).Do() + if err != nil { + if err, ok := err.(*googleapi.Error); !ok || err.Code != http.StatusNotFound || len(rrSetChange.Delete) > 0 { + log.Errorf("Error obtaining resource record set %s %s: %v", rrSetChange.Name, rrSetChange.Type, err) + return &dns.Change{} + } + current = &dns.ResourceRecordSet{} + } else { + change.Deletions = []*dns.ResourceRecordSet{current} + } + desired := &dns.ResourceRecordSet{} + data, err := current.MarshalJSON() + if err == nil { + err = json.Unmarshal(data, desired) + } + if err != nil { + log.Errorf("Error processing resource record set %s %s: %v", rrSetChange.Name, rrSetChange.Type, err) + return &dns.Change{} + } + for _, ep := range rrSetChange.Delete { + routingPolicy, _ := ep.GetProviderSpecificProperty(providerSpecificRoutingPolicy) + switch routingPolicy { + case providerSpecificRoutingPolicyNone: + if len(desired.Rrdatas) == len(ep.Targets) { + match := true + for _, data := range rrSetDatas(ep) { + if !slices.Contains(desired.Rrdatas, data) { + match = false + break + } + } + if match { + desired = &dns.ResourceRecordSet{} + } + } + case providerSpecificRoutingPolicyGeo: + if desired.RoutingPolicy == nil || desired.RoutingPolicy.Geo == nil { + continue + } + if location, ok := ep.GetProviderSpecificProperty(providerSpecificLocation); ok { + filter := func(item *dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem) bool { + if item.Location == location && len(item.Rrdatas) == len(ep.Targets) { + for _, data := range rrSetDatas(ep) { + if !slices.Contains(item.Rrdatas, data) { + return false + } + } + return true + } + return false + } + index := slices.IndexFunc(desired.RoutingPolicy.Geo.Items, filter) + for index != -1 { + desired.RoutingPolicy.Geo.Items = slices.Delete( + desired.RoutingPolicy.Geo.Items, index, index+1, + ) + index = slices.IndexFunc(desired.RoutingPolicy.Geo.Items, filter) + } + if len(desired.RoutingPolicy.Geo.Items) == 0 { + desired = &dns.ResourceRecordSet{} + } } - if ep.RecordTTL.IsConfigured() { - record.Ttl = int64(ep.RecordTTL) + case providerSpecificRoutingPolicyWrr: + if desired.RoutingPolicy == nil || desired.RoutingPolicy.Wrr == nil { + continue } - // TODO(linki): works around appending a trailing dot to TXT records. I think - // we should go back to storing DNS names with a trailing dot internally. This - // way we can use it has is here and trim it off if it exists when necessary. - switch record.Type { - case endpoint.RecordTypeCNAME: - record.Rrdatas = []string{provider.EnsureTrailingDot(ep.Targets[0])} - case endpoint.RecordTypeMX: - fallthrough - case endpoint.RecordTypeNS: - fallthrough - case endpoint.RecordTypeSRV: - record.Rrdatas = make([]string, len(ep.Targets)) - for i, target := range ep.Targets { - record.Rrdatas[i] = provider.EnsureTrailingDot(target) + index, err := strconv.ParseInt(ep.SetIdentifier, 10, 64) + length := int64(len(desired.RoutingPolicy.Wrr.Items)) + weight, ok := ep.GetProviderSpecificProperty(providerSpecificWeight) + if ok && err == nil && index >= 0 && index < length { + weight, err := strconv.ParseFloat(weight, 64) + if err != nil { + continue + } + item := desired.RoutingPolicy.Wrr.Items[index] + if item.Weight != weight || len(item.Rrdatas) != len(ep.Targets) { + continue + } + match := true + for _, data := range rrSetDatas(ep) { + if !slices.Contains(item.Rrdatas, data) { + match = false + break + } + } + if match { + if index+1 < length { + desired.RoutingPolicy.Wrr.Items[index] = rrSetRoutingPolicyWrrItemPlaceholder(desired.Type) + } else { + desired.RoutingPolicy.Wrr.Items = desired.RoutingPolicy.Wrr.Items[:index] + } + } + filter := func(item *dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem) bool { + return item.Weight != 0 + } + index := slices.IndexFunc(desired.RoutingPolicy.Wrr.Items, filter) + if index == -1 { + desired = &dns.ResourceRecordSet{} } - default: - record.Rrdatas = slices.Clone(ep.Targets) } - switch index { - case 0: - change.Deletions = append(change.Deletions, &record) - case 1: - change.Additions = append(change.Additions, &record) + } + } + for _, ep := range rrSetChange.Create { + desired.Name = string(rrSetChange.Name) + desired.Type = string(rrSetChange.Type) + if ep.RecordTTL.IsConfigured() { + desired.Ttl = int64(ep.RecordTTL) + } + desired.Rrdatas = rrSetDatas(ep) + routingPolicy, _ := ep.GetProviderSpecificProperty(providerSpecificRoutingPolicy) + switch routingPolicy { + case providerSpecificRoutingPolicyGeo: + if location, ok := ep.GetProviderSpecificProperty(providerSpecificLocation); ok { + if desired.RoutingPolicy == nil { + desired.RoutingPolicy = &dns.RRSetRoutingPolicy{} + } + if desired.RoutingPolicy.Geo == nil { + desired.RoutingPolicy.Geo = &dns.RRSetRoutingPolicyGeoPolicy{} + } + index := -1 + for i, item := range desired.RoutingPolicy.Geo.Items { + if item.Location == location { + index = i + break + } + } + if index == -1 { + index = len(desired.RoutingPolicy.Geo.Items) + desired.RoutingPolicy.Geo.Items = append(desired.RoutingPolicy.Geo.Items, nil) + } + desired.RoutingPolicy.Geo.Items[index] = &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: location, + Rrdatas: desired.Rrdatas, + } + desired.Rrdatas = nil + } + case providerSpecificRoutingPolicyWrr: + index, err := strconv.ParseInt(ep.SetIdentifier, 10, 64) + weight, ok := ep.GetProviderSpecificProperty(providerSpecificWeight) + if ok && err == nil && index >= 0 { + weight, err := strconv.ParseFloat(weight, 64) + if err != nil { + continue + } + if desired.RoutingPolicy == nil { + desired.RoutingPolicy = &dns.RRSetRoutingPolicy{} + } + if desired.RoutingPolicy.Wrr == nil { + desired.RoutingPolicy.Wrr = &dns.RRSetRoutingPolicyWrrPolicy{} + } + length := int64(len(desired.RoutingPolicy.Wrr.Items)) + if index >= length { + desired.RoutingPolicy.Wrr.Items = slices.Grow(desired.RoutingPolicy.Wrr.Items, int(index+1-length))[:index+1] + for i := length; i < index; i++ { + desired.RoutingPolicy.Wrr.Items[i] = rrSetRoutingPolicyWrrItemPlaceholder(desired.Type) + } + } + desired.RoutingPolicy.Wrr.Items[index] = &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: weight, + Rrdatas: desired.Rrdatas, + } + desired.Rrdatas = nil } } } + if desired.Name != "" { + if desired.Ttl == 0 { + desired.Ttl = googleRecordTTL + } + change.Additions = []*dns.ResourceRecordSet{desired} + } return &change } + +// Return Resource Record Set data for given endpoint +func rrSetDatas(ep *endpoint.Endpoint) []string { + // TODO(linki): works around appending a trailing dot to TXT records. I think + // we should go back to storing DNS names with a trailing dot internally. This + // way we can use it has is here and trim it off if it exists when necessary. + switch ep.RecordType { + case endpoint.RecordTypeCNAME: + return []string{provider.EnsureTrailingDot(ep.Targets[0])} + case endpoint.RecordTypeMX: + fallthrough + case endpoint.RecordTypeNS: + fallthrough + case endpoint.RecordTypeSRV: + rrdatas := make([]string, len(ep.Targets)) + for i, target := range ep.Targets { + rrdatas[i] = provider.EnsureTrailingDot(target) + } + return rrdatas + default: + return slices.Clone(ep.Targets) + } +} + +// Return a Weighted Round Robin routing policy item placeholder for given resource record type +func rrSetRoutingPolicyWrrItemPlaceholder(type_ string) *dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem { + var rrdatas []string + switch type_ { + case "A": + rrdatas = []string{"0.0.0.0"} + case "AAAA": + rrdatas = []string{"::"} + default: + rrdatas = []string{"."} + } + return &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Rrdatas: rrdatas, + } +} diff --git a/provider/google/google_test.go b/provider/google/google_test.go index cdb0fe824a..c12c7d5ede 100644 --- a/provider/google/google_test.go +++ b/provider/google/google_test.go @@ -184,6 +184,40 @@ func TestGoogleRecords(t *testing.T) { Ttl: 3, Rrdatas: []string{"cname."}, }, + &dns.ResourceRecordSet{ + Name: "geo.zone-1.local.", + Type: "A", + Ttl: 10, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Geo: &dns.RRSetRoutingPolicyGeoPolicy{ + Items: []*dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "location", + Rrdatas: []string{"1.0.0.0"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "wrr.zone-1.local.", + Type: "A", + Ttl: 10, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Wrr: &dns.RRSetRoutingPolicyWrrPolicy{ + Items: []*dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.0.0.0"}, + }, + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.0.0.1"}, + }, + }, + }, + }, + }, }, &dns.ManagedZone{ Name: "zone-2", @@ -207,6 +241,21 @@ func TestGoogleRecords(t *testing.T) { validateEndpoints(t, records, []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("a.zone-1.local", endpoint.RecordTypeA, endpoint.TTL(1), "1.0.0.0"), endpoint.NewEndpointWithTTL("cname.zone-1.local", endpoint.RecordTypeCNAME, endpoint.TTL(3), "cname"), + endpoint.NewEndpointWithTTL("geo.zone-1.local", endpoint.RecordTypeA, endpoint.TTL(10), "1.0.0.0").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "location", + ).WithSetIdentifier("location"), + endpoint.NewEndpointWithTTL("wrr.zone-1.local", endpoint.RecordTypeA, endpoint.TTL(10), "1.0.0.0").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithProviderSpecific( + providerSpecificWeight, "50", + ).WithSetIdentifier("0"), + endpoint.NewEndpointWithTTL("wrr.zone-1.local", endpoint.RecordTypeA, endpoint.TTL(10), "1.0.0.1").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithProviderSpecific( + providerSpecificWeight, "50", + ).WithSetIdentifier("1"), endpoint.NewEndpointWithTTL("a.zone-2.local", endpoint.RecordTypeA, endpoint.TTL(2), "2.0.0.0"), }) } @@ -265,6 +314,44 @@ func TestGoogleRecordsFilter(t *testing.T) { }) } +func TestGoogleAdjustEndpoints(t *testing.T) { + p := newGoogleProvider() + p.location = "default" + endpoints, err := p.AdjustEndpoints([]*endpoint.Endpoint{ + endpoint.NewEndpoint("a.record.", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("geo.record.", endpoint.RecordTypeA, "1.2.3.4").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ), + endpoint.NewEndpoint("geo-location.record.", endpoint.RecordTypeA, "1.2.3.4").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "location", + ), + endpoint.NewEndpoint("wrr.record.", endpoint.RecordTypeA, "1.2.3.4").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithSetIdentifier("identifier"), + }) + assert.NoError(t, err) + assert.Equal(t, endpoints, []*endpoint.Endpoint{ + endpoint.NewEndpoint("a.record.", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("geo.record.", endpoint.RecordTypeA, "1.2.3.4").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, p.location, + ).WithSetIdentifier(p.location), + endpoint.NewEndpoint("geo-location.record.", endpoint.RecordTypeA, "1.2.3.4").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "location", + ).WithSetIdentifier("location"), + endpoint.NewEndpoint("wrr.record.", endpoint.RecordTypeA, "1.2.3.4").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithProviderSpecific( + providerSpecificWeight, "1e+02", + ).WithSetIdentifier("identifier"), + }) +} + func TestGoogleApplyChanges(t *testing.T) { records := map[*dns.ManagedZone][]*dns.ResourceRecordSet{ &dns.ManagedZone{ @@ -295,6 +382,142 @@ func TestGoogleApplyChanges(t *testing.T) { Ttl: googleRecordTTL, Rrdatas: []string{"delete-test-cname."}, }, + &dns.ResourceRecordSet{ + Name: "geo-update.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Geo: &dns.RRSetRoutingPolicyGeoPolicy{ + Items: []*dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "current", + Rrdatas: []string{"1.1.1.1"}, + }, + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "update", + Rrdatas: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "geo-update-create.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Geo: &dns.RRSetRoutingPolicyGeoPolicy{ + Items: []*dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "current", + Rrdatas: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "geo-update-delete.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Geo: &dns.RRSetRoutingPolicyGeoPolicy{ + Items: []*dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "current", + Rrdatas: []string{"1.1.1.1"}, + }, + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "delete", + Rrdatas: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "geo-delete.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Geo: &dns.RRSetRoutingPolicyGeoPolicy{ + Items: []*dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "delete", + Rrdatas: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "wrr-update.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Wrr: &dns.RRSetRoutingPolicyWrrPolicy{ + Items: []*dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.1.1.1"}, + }, + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.1.1.2"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "wrr-update-create.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Wrr: &dns.RRSetRoutingPolicyWrrPolicy{ + Items: []*dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "wrr-update-delete.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Wrr: &dns.RRSetRoutingPolicyWrrPolicy{ + Items: []*dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.1.1.1"}, + }, + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.1.1.2"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "wrr-delete.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Wrr: &dns.RRSetRoutingPolicyWrrPolicy{ + Items: []*dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 100.0, + Rrdatas: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, }, &dns.ManagedZone{ Name: "zone-2", @@ -324,12 +547,42 @@ func TestGoogleApplyChanges(t *testing.T) { endpoint.NewEndpoint("create-test-ns.zone-1.local", endpoint.RecordTypeNS, "create-test-ns"), endpoint.NewEndpoint("filter-create-test.zone-3.local", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("nomatch-create-test.zone-0.local", endpoint.RecordTypeA, "4.2.2.1"), + endpoint.NewEndpoint("geo-create.zone-1.local.", endpoint.RecordTypeA, "2.2.2.2").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "create", + ), + endpoint.NewEndpoint("geo-update-create.zone-1.local.", endpoint.RecordTypeA, "2.2.2.2").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "create", + ), + endpoint.NewEndpoint("wrr-create.zone-1.local.", endpoint.RecordTypeA, "2.2.2.2").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithProviderSpecific( + providerSpecificWeight, "100", + ).WithSetIdentifier("1"), + endpoint.NewEndpoint("wrr-update-create.zone-1.local.", endpoint.RecordTypeA, "2.2.2.2").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithProviderSpecific( + providerSpecificWeight, "50", + ).WithSetIdentifier("1"), }, UpdateOld: []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("update-test-ttl.zone-2.local", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("update-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "update-test-cname"), endpoint.NewEndpoint("filter-update-test.zone-3.local", endpoint.RecordTypeA, "4.2.2.2"), + endpoint.NewEndpoint("geo-update.zone-1.local.", endpoint.RecordTypeA, "1.1.1.1").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "update", + ), + endpoint.NewEndpoint("wrr-update.zone-1.local.", endpoint.RecordTypeA, "1.1.1.2").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithProviderSpecific( + providerSpecificWeight, "50", + ).WithSetIdentifier("1"), }, UpdateNew: []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.local", endpoint.RecordTypeA, "1.2.3.4"), @@ -337,6 +590,16 @@ func TestGoogleApplyChanges(t *testing.T) { endpoint.NewEndpoint("update-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "updated-test-cname"), endpoint.NewEndpoint("filter-update-test.zone-3.local", endpoint.RecordTypeA, "5.6.7.8"), endpoint.NewEndpoint("nomatch-update-test.zone-0.local", endpoint.RecordTypeA, "8.7.6.5"), + endpoint.NewEndpoint("geo-update.zone-1.local.", endpoint.RecordTypeA, "2.2.2.2").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "update", + ), + endpoint.NewEndpoint("wrr-update.zone-1.local.", endpoint.RecordTypeA, "2.2.2.2").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithProviderSpecific( + providerSpecificWeight, "50", + ).WithSetIdentifier("1"), }, Delete: []*endpoint.Endpoint{ endpoint.NewEndpoint("delete-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), @@ -344,6 +607,31 @@ func TestGoogleApplyChanges(t *testing.T) { endpoint.NewEndpoint("delete-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "delete-test-cname"), endpoint.NewEndpoint("filter-delete-test.zone-3.local", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("nomatch-delete-test.zone-0.local", endpoint.RecordTypeA, "4.2.2.1"), + endpoint.NewEndpoint("geo-update.zone-1.local.", endpoint.RecordTypeA, "1.1.1.1").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "delete", + ), + endpoint.NewEndpoint("geo-update-delete.zone-1.local.", endpoint.RecordTypeA, "1.1.1.1").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "delete", + ), + endpoint.NewEndpoint("geo-delete.zone-1.local.", endpoint.RecordTypeA, "1.1.1.1").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyGeo, + ).WithProviderSpecific( + providerSpecificLocation, "delete", + ), + endpoint.NewEndpoint("wrr-update-delete.zone-1.local.", endpoint.RecordTypeA, "1.1.1.2").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithProviderSpecific( + providerSpecificWeight, "50.0", + ).WithSetIdentifier("1"), + endpoint.NewEndpoint("wrr-delete.zone-1.local.", endpoint.RecordTypeA, "1.1.1.1").WithProviderSpecific( + providerSpecificRoutingPolicy, providerSpecificRoutingPolicyWrr, + ).WithProviderSpecific( + providerSpecificWeight, "100.0", + ).WithSetIdentifier("0"), }, })) mockClients.EqualRecords(&map[*dns.ManagedZone][]*dns.ResourceRecordSet{ @@ -381,6 +669,146 @@ func TestGoogleApplyChanges(t *testing.T) { Ttl: googleRecordTTL, Rrdatas: []string{"create-test-ns."}, }, + &dns.ResourceRecordSet{ + Name: "geo-create.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Geo: &dns.RRSetRoutingPolicyGeoPolicy{ + Items: []*dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "create", + Rrdatas: []string{"2.2.2.2"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "geo-update.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Geo: &dns.RRSetRoutingPolicyGeoPolicy{ + Items: []*dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "current", + Rrdatas: []string{"1.1.1.1"}, + }, + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "update", + Rrdatas: []string{"2.2.2.2"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "geo-update-create.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Geo: &dns.RRSetRoutingPolicyGeoPolicy{ + Items: []*dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "current", + Rrdatas: []string{"1.1.1.1"}, + }, + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "create", + Rrdatas: []string{"2.2.2.2"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "geo-update-delete.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Geo: &dns.RRSetRoutingPolicyGeoPolicy{ + Items: []*dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + &dns.RRSetRoutingPolicyGeoPolicyGeoPolicyItem{ + Location: "current", + Rrdatas: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "wrr-create.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Wrr: &dns.RRSetRoutingPolicyWrrPolicy{ + Items: []*dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 0, + Rrdatas: []string{"0.0.0.0"}, + }, + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 100.0, + Rrdatas: []string{"2.2.2.2"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "wrr-update.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Wrr: &dns.RRSetRoutingPolicyWrrPolicy{ + Items: []*dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.1.1.1"}, + }, + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"2.2.2.2"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "wrr-update-create.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Wrr: &dns.RRSetRoutingPolicyWrrPolicy{ + Items: []*dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.1.1.1"}, + }, + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"2.2.2.2"}, + }, + }, + }, + }, + }, + &dns.ResourceRecordSet{ + Name: "wrr-update-delete.zone-1.local.", + Type: "A", + Ttl: googleRecordTTL, + RoutingPolicy: &dns.RRSetRoutingPolicy{ + Wrr: &dns.RRSetRoutingPolicyWrrPolicy{ + Items: []*dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + &dns.RRSetRoutingPolicyWrrPolicyWrrPolicyItem{ + Weight: 50.0, + Rrdatas: []string{"1.1.1.1"}, + }, + }, + }, + }, + }, }, &dns.ManagedZone{ Name: "zone-2", @@ -484,38 +912,6 @@ func TestGoogleApplyChangesEmpty(t *testing.T) { assert.NoError(t, p.ApplyChanges(context.Background(), &plan.Changes{})) } -func TestNewChange(t *testing.T) { - p := newGoogleProvider().WithMockClients(newMockClients(t)) - - records := []*dns.ResourceRecordSet{} - for _, ep := range []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("update-test.zone-2.local", endpoint.RecordTypeA, 1, "8.8.4.4"), - endpoint.NewEndpointWithTTL("delete-test.zone-2.local", endpoint.RecordTypeA, 120, "8.8.4.4"), - endpoint.NewEndpointWithTTL("update-test-cname.zone-1.local", endpoint.RecordTypeCNAME, 4000, "update-test-cname"), - // test fallback to Ttl:300 when Ttl==0 : - endpoint.NewEndpointWithTTL("update-test.zone-1.local", endpoint.RecordTypeA, 0, "8.8.8.8"), - endpoint.NewEndpointWithTTL("update-test-mx.zone-1.local", endpoint.RecordTypeMX, 6000, "10 mail"), - endpoint.NewEndpoint("delete-test.zone-1.local", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("delete-test-cname.zone-1.local", endpoint.RecordTypeCNAME, "delete-test-cname"), - } { - records = append(records, p.newChange(&plan.RRSetChange{ - Name: plan.RRName(provider.EnsureTrailingDot(ep.DNSName)), - Type: plan.RRType(ep.RecordType), - Create: []*endpoint.Endpoint{ep}, - }).Additions...) - } - - validateChangeRecords(t, records, []*dns.ResourceRecordSet{ - {Name: "update-test.zone-2.local.", Rrdatas: []string{"8.8.4.4"}, Type: "A", Ttl: 1}, - {Name: "delete-test.zone-2.local.", Rrdatas: []string{"8.8.4.4"}, Type: "A", Ttl: 120}, - {Name: "update-test-cname.zone-1.local.", Rrdatas: []string{"update-test-cname."}, Type: "CNAME", Ttl: 4000}, - {Name: "update-test.zone-1.local.", Rrdatas: []string{"8.8.8.8"}, Type: "A", Ttl: 300}, - {Name: "update-test-mx.zone-1.local.", Rrdatas: []string{"10 mail."}, Type: "MX", Ttl: 6000}, - {Name: "delete-test.zone-1.local.", Rrdatas: []string{"8.8.8.8"}, Type: "A", Ttl: 300}, - {Name: "delete-test-cname.zone-1.local.", Rrdatas: []string{"delete-test-cname."}, Type: "CNAME", Ttl: 300}, - }) -} - func TestGoogleBatchChangeSet(t *testing.T) { mockClients := newMockClients(t) mockClients.changesClient.maxChangeSize = 1 @@ -578,21 +974,6 @@ func sortChangesByName(cs *dns.Change) { }) } -func validateChangeRecords(t *testing.T, records []*dns.ResourceRecordSet, expected []*dns.ResourceRecordSet) { - require.Len(t, records, len(expected)) - - for i := range records { - validateChangeRecord(t, records[i], expected[i]) - } -} - -func validateChangeRecord(t *testing.T, record *dns.ResourceRecordSet, expected *dns.ResourceRecordSet) { - assert.Equal(t, expected.Name, record.Name) - assert.Equal(t, expected.Rrdatas, record.Rrdatas) - assert.Equal(t, expected.Ttl, record.Ttl) - assert.Equal(t, expected.Type, record.Type) -} - func validateEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) { assert.True(t, testutils.SameEndpoints(endpoints, expected), "actual and expected endpoints don't match. %s:%s", endpoints, expected) } @@ -657,10 +1038,7 @@ func (m *mockChangesCreateCall) Do(opts ...googleapi.CallOption) (*dns.Change, e Message: fmt.Sprintf("record not found: %v", deletion), } } - (*m.client.records)[managedZone] = append( - (*m.client.records)[managedZone][:index], - (*m.client.records)[managedZone][index+1:]... - ) + (*m.client.records)[managedZone] = slices.Delete((*m.client.records)[managedZone], index, index + 1) } for _, addition := range m.change.Additions { for _, record := range (*m.client.records)[managedZone] { @@ -685,6 +1063,32 @@ type mockResourceRecordSetsClient struct { recordsErr error } +type mockResourceRecordSetsGetCall struct { + client *mockResourceRecordSetsClient + managedZone string + name string + type_ string +} + +func (m *mockResourceRecordSetsGetCall) Do(opts ...googleapi.CallOption) (*dns.ResourceRecordSet, error) { + if m.client.recordsErr != nil { + return nil, m.client.recordsErr + } + var managedZone *dns.ManagedZone + for zone := range maps.Keys(*m.client.records) { + if zone.Name == m.managedZone { + managedZone = zone + break + } + } + for _, record := range (*m.client.records)[managedZone] { + if record.Name == m.name && record.Type == m.type_ { + return record, nil + } + } + return nil, &googleapi.Error{Code: http.StatusNotFound} +} + type mockResourceRecordSetsListCall struct { client *mockResourceRecordSetsClient managedZone string @@ -707,6 +1111,10 @@ func (m *mockResourceRecordSetsListCall) Pages(ctx context.Context, f func(*dns. return f(&dns.ResourceRecordSetsListResponse{Rrsets: (*m.client.records)[managedZone]}) } +func (m *mockResourceRecordSetsClient) Get(managedZone string, name string, type_ string) resourceRecordSetsGetCallInterface { + return &mockResourceRecordSetsGetCall{client: m, managedZone: managedZone, name: name, type_: type_} +} + func (m *mockResourceRecordSetsClient) List(managedZone string) resourceRecordSetsListCallInterface { return &mockResourceRecordSetsListCall{client: m, managedZone: managedZone} } diff --git a/source/source.go b/source/source.go index 012cf82739..c1899ccde3 100644 --- a/source/source.go +++ b/source/source.go @@ -212,6 +212,12 @@ func getProviderSpecificAnnotations(annotations map[string]string) (endpoint.Pro Name: fmt.Sprintf("aws/%s", attr), Value: v, }) + } else if strings.HasPrefix(k, "external-dns.alpha.kubernetes.io/google-") { + attr := strings.TrimPrefix(k, "external-dns.alpha.kubernetes.io/") + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: attr, + Value: v, + }) } else if strings.HasPrefix(k, "external-dns.alpha.kubernetes.io/scw-") { attr := strings.TrimPrefix(k, "external-dns.alpha.kubernetes.io/scw-") providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{