diff --git a/provider/google/google.go b/provider/google/google.go index fd521fc0ef..190a0a3d5b 100644 --- a/provider/google/google.go +++ b/provider/google/google.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "sort" + "strings" "time" "cloud.google.com/go/compute/metadata" @@ -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 { diff --git a/provider/google/google_test.go b/provider/google/google_test.go index 2596325446..79fc8b7b23 100644 --- a/provider/google/google_test.go +++ b/provider/google/google_test.go @@ -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"), @@ -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