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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 188 additions & 3 deletions provider/google/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"fmt"
"sort"
"strings"
"time"

"cloud.google.com/go/compute/metadata"
Expand Down Expand Up @@ -230,20 +231,204 @@ func (p *GoogleProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, err
return endpoints, nil
}

type typeSwap struct {
old *endpoint.Endpoint
new *endpoint.Endpoint
}

var typeSwapTypePriority = map[string]int{
endpoint.RecordTypeA: 0,
endpoint.RecordTypeAAAA: 1,
endpoint.RecordTypeCNAME: 2,
}

// ApplyChanges applies a given set of changes in a given zone.
func (p *GoogleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
swaps, creates, deletes := p.extractTypeSwaps(changes.Create, changes.Delete)

change := &dns.Change{}

change.Additions = append(change.Additions, p.newFilteredRecords(changes.Create)...)
for _, swap := range swaps {
change.Deletions = append(change.Deletions, p.newFilteredRecords([]*endpoint.Endpoint{swap.old})...)
change.Additions = append(change.Additions, p.newFilteredRecords([]*endpoint.Endpoint{swap.new})...)
}

change.Additions = append(change.Additions, p.newFilteredRecords(creates)...)

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)...)
change.Deletions = append(change.Deletions, p.newFilteredRecords(changes.UpdateOld)...)
change.Deletions = append(change.Deletions, p.newFilteredRecords(deletes)...)

return p.submitChange(ctx, change)
}

// extractTypeSwaps pairs create/delete records of differing types so we can swap
// them while preserving ownership labels, skipping TXT records and preferring
// higher-priority, labeled deletions.
func (p *GoogleProvider) extractTypeSwaps(
create, deleteEndpoints []*endpoint.Endpoint,
) ([]typeSwap, []*endpoint.Endpoint, []*endpoint.Endpoint) {
swaps := make([]typeSwap, 0)
remainingCreates := make([]*endpoint.Endpoint, 0, len(create))
remainingDeletes := make([]*endpoint.Endpoint, 0, len(deleteEndpoints))

// Index deletions by name then by record type
typeBucketByName := make(map[string]map[string][]*endpoint.Endpoint)

for _, del := range deleteEndpoints {
if !eligibleForTypeSwap(del) {
remainingDeletes = append(remainingDeletes, del)
continue
}
nameKey := swapKey(del.DNSName)
buckets := typeBucketByName[nameKey]
if buckets == nil {
buckets = make(map[string][]*endpoint.Endpoint)
typeBucketByName[nameKey] = buckets
}
buckets[del.RecordType] = append(buckets[del.RecordType], del)
}

for _, createEP := range create {
if !eligibleForTypeSwap(createEP) {
remainingCreates = append(remainingCreates, createEP)
continue
}

nameKey := swapKey(createEP.DNSName)
buckets, ok := typeBucketByName[nameKey]
if !ok {
remainingCreates = append(remainingCreates, createEP)
continue
}

// Find any delete with a different type to form a swap.
var (
matched *endpoint.Endpoint
matchedType string
matchedIndex int
bestTypeRank int
bestOwnerRank int
)

bestTypeRank = len(typeSwapTypePriority) + 1
bestOwnerRank = 1

for delType, list := range buckets {
if delType == createEP.RecordType || len(list) == 0 {
continue
}

typeRank := swapTypePriority(delType)

for idx, candidate := range list {
ownerRank := 1
if candidate.Labels != nil {
if _, ok := candidate.Labels[endpoint.OwnerLabelKey]; ok {
ownerRank = 0
}
}

better := matched == nil
if !better && typeRank != bestTypeRank {
better = typeRank < bestTypeRank
}
if !better && ownerRank != bestOwnerRank {
better = ownerRank < bestOwnerRank
}
if !better && idx != matchedIndex {
better = idx < matchedIndex
}
if !better && delType != matchedType {
better = delType < matchedType
}

if better {
matched = candidate
matchedType = delType
matchedIndex = idx
bestTypeRank = typeRank
bestOwnerRank = ownerRank
}
}
}

if matched == nil {
remainingCreates = append(remainingCreates, createEP)
continue
}

// Consume the matched deletion from the bucket.
bucket := buckets[matchedType]
switch len(bucket) {
case 1:
delete(buckets, matchedType)
default:
buckets[matchedType] = append(bucket[:matchedIndex], bucket[matchedIndex+1:]...)
}

if len(buckets) == 0 {
delete(typeBucketByName, nameKey)
}

// Ensure create endpoint has owner label
if matched.Labels != nil {
if createEP.Labels == nil {
createEP.Labels = map[string]string{}
}
if owner, ok := matched.Labels[endpoint.OwnerLabelKey]; ok {
if _, exists := createEP.Labels[endpoint.OwnerLabelKey]; !exists {
createEP.Labels[endpoint.OwnerLabelKey] = owner
}
}
}

swaps = append(swaps, typeSwap{old: matched, new: createEP})
}

nameKeys := make([]string, 0, len(typeBucketByName))
for name := range typeBucketByName {
nameKeys = append(nameKeys, name)
}
sort.Strings(nameKeys)

for _, name := range nameKeys {
byType := typeBucketByName[name]
typeKeys := make([]string, 0, len(byType))
for recordType := range byType {
typeKeys = append(typeKeys, recordType)
}
sort.Strings(typeKeys)
for _, recordType := range typeKeys {
remainingDeletes = append(remainingDeletes, byType[recordType]...)
}
}

return swaps, remainingCreates, remainingDeletes
}

// Returns true only for non-TXT records
func eligibleForTypeSwap(ep *endpoint.Endpoint) bool {
if ep == nil {
return false
}

return ep.RecordType != endpoint.RecordTypeTXT
}

func swapKey(name string) string {
return strings.ToLower(provider.EnsureTrailingDot(name))
}

func swapTypePriority(recordType string) int {
if priority, ok := typeSwapTypePriority[recordType]; ok {
return priority
}

return len(typeSwapTypePriority) + 1
}

// SupportedRecordType returns true if the record type is supported by the provider
func (p *GoogleProvider) SupportedRecordType(recordType string) bool {
switch recordType {
Expand Down
147 changes: 147 additions & 0 deletions provider/google/google_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,78 @@ func TestGoogleApplyChanges(t *testing.T) {
})
}

func TestGoogleApplyChangesTypeSwap(t *testing.T) {
provider := newGoogleProvider(
t,
endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}),
provider.NewZoneIDFilter([]string{""}),
false,
[]*endpoint.Endpoint{},
nil,
nil,
)

existing := endpoint.NewEndpointWithTTL(
"typeswap.zone-1.ext-dns-test-2.gcp.zalan.do",
endpoint.RecordTypeA,
endpoint.TTL(180),
"203.0.113.10",
)

setupGoogleRecords(t, provider, []*endpoint.Endpoint{existing})

records, err := provider.Records(context.Background())
require.NoError(t, err)
require.Len(t, records, 1)

deleteRecord := endpoint.NewEndpointWithTTL(
existing.DNSName,
existing.RecordType,
existing.RecordTTL,
records[0].Targets...,
)
deleteRecord.Labels = map[string]string{endpoint.OwnerLabelKey: "owner-1"}

createRecord := endpoint.NewEndpoint(
existing.DNSName,
endpoint.RecordTypeCNAME,
"target.typeswap.example.",
)
createRecord.Labels = nil
require.True(t, provider.domainFilter.Match(createRecord.DNSName))

changes := &plan.Changes{
Create: []*endpoint.Endpoint{createRecord},
Delete: []*endpoint.Endpoint{deleteRecord},
}

swaps, remainingCreates, remainingDeletes := provider.extractTypeSwaps(changes.Create, changes.Delete)
require.Len(t, swaps, 1)
require.Empty(t, remainingCreates)
require.Empty(t, remainingDeletes)
deletionRecords := provider.newFilteredRecords([]*endpoint.Endpoint{swaps[0].old})
additionRecords := provider.newFilteredRecords([]*endpoint.Endpoint{swaps[0].new})
require.Len(t, deletionRecords, 1)
require.Len(t, additionRecords, 1)

require.NoError(t, provider.ApplyChanges(context.Background(), changes))

updatedRecords, err := provider.Records(context.Background())
require.NoError(t, err)

expectedRecord := endpoint.NewEndpointWithTTL(
existing.DNSName,
endpoint.RecordTypeCNAME,
endpoint.TTL(defaultTTL),
"target.typeswap.example.",
)

validateEndpoints(t, updatedRecords, []*endpoint.Endpoint{expectedRecord})

require.NotNil(t, createRecord.Labels)
assert.Equal(t, "owner-1", createRecord.Labels[endpoint.OwnerLabelKey])
}

func TestGoogleApplyChangesDryRun(t *testing.T) {
originalEndpoints := []*endpoint.Endpoint{
endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.8.8"),
Expand Down Expand Up @@ -633,6 +705,81 @@ func TestSoftErrListRecordsConflict(t *testing.T) {
require.Empty(t, records)
}

func TestGoogleExtractTypeSwapsIgnoreTXT(t *testing.T) {
gp := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{}, nil, nil)

name := "txt-swap-test.zone-1.ext-dns-test-2.gcp.zalan.do"

// Create A, delete TXT -> no swap
createA := endpoint.NewEndpoint(name, endpoint.RecordTypeA, "1.2.3.4")
deleteTXT := endpoint.NewEndpoint(name, endpoint.RecordTypeTXT, "txt-value")
swaps, remCreates, remDeletes := gp.extractTypeSwaps([]*endpoint.Endpoint{createA}, []*endpoint.Endpoint{deleteTXT})
require.Empty(t, swaps)
require.Len(t, remCreates, 1)
require.Len(t, remDeletes, 1)

// Create TXT, delete A -> no swap
createTXT := endpoint.NewEndpoint(name, endpoint.RecordTypeTXT, "txt-value")
deleteA := endpoint.NewEndpoint(name, endpoint.RecordTypeA, "1.2.3.4")
swaps, remCreates, remDeletes = gp.extractTypeSwaps([]*endpoint.Endpoint{createTXT}, []*endpoint.Endpoint{deleteA})
require.Empty(t, swaps)
require.Len(t, remCreates, 1)
require.Len(t, remDeletes, 1)
}

func TestGoogleExtractTypeSwapsConsumesSingleDelete(t *testing.T) {
gp := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{}, nil, nil)

name := "multi-delete-swap.zone-1.ext-dns-test-2.gcp.zalan.do"

delA := endpoint.NewEndpoint(name, endpoint.RecordTypeA, "192.0.2.1")
delA.Labels = map[string]string{endpoint.OwnerLabelKey: "owner-a"}
delAAAA := endpoint.NewEndpoint(name, endpoint.RecordTypeAAAA, "2001:db8::1")

createCNAME := endpoint.NewEndpoint(name, endpoint.RecordTypeCNAME, "target.example.")

swaps, remCreates, remDeletes := gp.extractTypeSwaps([]*endpoint.Endpoint{createCNAME}, []*endpoint.Endpoint{delA, delAAAA})

require.Len(t, swaps, 1)
assert.Equal(t, delA, swaps[0].old)
require.Empty(t, remCreates)
require.Len(t, remDeletes, 1)
assert.Equal(t, delAAAA, remDeletes[0])
assert.NotNil(t, swaps[0].new.Labels)
assert.Equal(t, "owner-a", swaps[0].new.Labels[endpoint.OwnerLabelKey])
}

func TestGoogleExtractTypeSwapsCaseInsensitiveName(t *testing.T) {
gp := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{}, nil, nil)

// Mixed case create vs lower-case delete
createName := "CaseSwap.ZONE-1.ext-dns-test-2.gcp.zalan.do"
deleteName := strings.ToLower(createName)

delA := endpoint.NewEndpoint(deleteName, endpoint.RecordTypeA, "198.51.100.10")
createCNAME := endpoint.NewEndpoint(createName, endpoint.RecordTypeCNAME, "target.caseswap.example.")

swaps, remCreates, remDeletes := gp.extractTypeSwaps([]*endpoint.Endpoint{createCNAME}, []*endpoint.Endpoint{delA})
require.Len(t, swaps, 1)
require.Empty(t, remCreates)
require.Empty(t, remDeletes)
assert.Equal(t, delA, swaps[0].old)
assert.Equal(t, createCNAME, swaps[0].new)
}

func TestGoogleExtractTypeSwapsSameTypeNoSwap(t *testing.T) {
gp := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{}, nil, nil)

name := "same-type.zone-1.ext-dns-test-2.gcp.zalan.do"
delA := endpoint.NewEndpoint(name, endpoint.RecordTypeA, "203.0.113.55")
createA := endpoint.NewEndpoint(name, endpoint.RecordTypeA, "203.0.113.56")

swaps, remCreates, remDeletes := gp.extractTypeSwaps([]*endpoint.Endpoint{createA}, []*endpoint.Endpoint{delA})
require.Empty(t, swaps)
require.Len(t, remCreates, 1)
require.Len(t, remDeletes, 1)
}

func sortChangesByName(cs *dns.Change) {
sort.SliceStable(cs.Additions, func(i, j int) bool {
return cs.Additions[i].Name < cs.Additions[j].Name
Expand Down