sigs.k8s.io/external-dns@v0.14.1/provider/cloudflare/cloudflare_test.go (about) 1 /* 2 Copyright 2017 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package cloudflare 18 19 import ( 20 "context" 21 "errors" 22 "fmt" 23 "os" 24 "sort" 25 "strings" 26 "testing" 27 28 cloudflare "github.com/cloudflare/cloudflare-go" 29 "github.com/stretchr/testify/assert" 30 31 "github.com/maxatome/go-testdeep/td" 32 "sigs.k8s.io/external-dns/endpoint" 33 "sigs.k8s.io/external-dns/plan" 34 "sigs.k8s.io/external-dns/provider" 35 ) 36 37 type MockAction struct { 38 Name string 39 ZoneId string 40 RecordId string 41 RecordData cloudflare.DNSRecord 42 } 43 44 type mockCloudFlareClient struct { 45 User cloudflare.User 46 Zones map[string]string 47 Records map[string]map[string]cloudflare.DNSRecord 48 Actions []MockAction 49 listZonesError error 50 dnsRecordsError error 51 } 52 53 var ExampleDomain = []cloudflare.DNSRecord{ 54 { 55 ID: "1234567890", 56 ZoneID: "001", 57 Name: "foobar.bar.com", 58 Type: endpoint.RecordTypeA, 59 TTL: 120, 60 Content: "1.2.3.4", 61 Proxied: proxyDisabled, 62 }, 63 { 64 ID: "2345678901", 65 ZoneID: "001", 66 Name: "foobar.bar.com", 67 Type: endpoint.RecordTypeA, 68 TTL: 120, 69 Content: "3.4.5.6", 70 Proxied: proxyDisabled, 71 }, 72 { 73 ID: "1231231233", 74 ZoneID: "002", 75 Name: "bar.foo.com", 76 Type: endpoint.RecordTypeA, 77 TTL: 1, 78 Content: "2.3.4.5", 79 Proxied: proxyDisabled, 80 }, 81 } 82 83 func NewMockCloudFlareClient() *mockCloudFlareClient { 84 return &mockCloudFlareClient{ 85 User: cloudflare.User{ID: "xxxxxxxxxxxxxxxxxxx"}, 86 Zones: map[string]string{ 87 "001": "bar.com", 88 "002": "foo.com", 89 }, 90 Records: map[string]map[string]cloudflare.DNSRecord{ 91 "001": {}, 92 "002": {}, 93 }, 94 } 95 } 96 97 func NewMockCloudFlareClientWithRecords(records map[string][]cloudflare.DNSRecord) *mockCloudFlareClient { 98 m := NewMockCloudFlareClient() 99 100 for zoneID, zoneRecords := range records { 101 if zone, ok := m.Records[zoneID]; ok { 102 for _, record := range zoneRecords { 103 zone[record.ID] = record 104 } 105 } 106 } 107 108 return m 109 } 110 111 func getDNSRecordFromRecordParams(rp any) cloudflare.DNSRecord { 112 switch params := rp.(type) { 113 case cloudflare.CreateDNSRecordParams: 114 return cloudflare.DNSRecord{ 115 Name: params.Name, 116 TTL: params.TTL, 117 Proxied: params.Proxied, 118 Type: params.Type, 119 Content: params.Content, 120 } 121 case cloudflare.UpdateDNSRecordParams: 122 return cloudflare.DNSRecord{ 123 Name: params.Name, 124 TTL: params.TTL, 125 Proxied: params.Proxied, 126 Type: params.Type, 127 Content: params.Content, 128 } 129 default: 130 return cloudflare.DNSRecord{} 131 } 132 } 133 134 func (m *mockCloudFlareClient) CreateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) { 135 recordData := getDNSRecordFromRecordParams(rp) 136 m.Actions = append(m.Actions, MockAction{ 137 Name: "Create", 138 ZoneId: rc.Identifier, 139 RecordId: rp.ID, 140 RecordData: recordData, 141 }) 142 if zone, ok := m.Records[rc.Identifier]; ok { 143 zone[rp.ID] = recordData 144 } 145 146 if recordData.Name == "newerror.bar.com" { 147 return cloudflare.DNSRecord{}, fmt.Errorf("failed to create record") 148 } 149 return cloudflare.DNSRecord{}, nil 150 } 151 152 func (m *mockCloudFlareClient) ListDNSRecords(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDNSRecordsParams) ([]cloudflare.DNSRecord, *cloudflare.ResultInfo, error) { 153 if m.dnsRecordsError != nil { 154 return nil, &cloudflare.ResultInfo{}, m.dnsRecordsError 155 } 156 result := []cloudflare.DNSRecord{} 157 if zone, ok := m.Records[rc.Identifier]; ok { 158 for _, record := range zone { 159 result = append(result, record) 160 } 161 } 162 163 if len(result) == 0 || rp.PerPage == 0 { 164 return result, &cloudflare.ResultInfo{Page: 1, TotalPages: 1, Count: 0, Total: 0}, nil 165 } 166 167 // if not pagination options were passed in, return the result as is 168 if rp.Page == 0 { 169 return result, &cloudflare.ResultInfo{Page: 1, TotalPages: 1, Count: len(result), Total: len(result)}, nil 170 } 171 172 // otherwise, split the result into chunks of size rp.PerPage to simulate the pagination from the API 173 chunks := [][]cloudflare.DNSRecord{} 174 175 // to ensure consistency in the multiple calls to this function, sort the result slice 176 sort.Slice(result, func(i, j int) bool { return strings.Compare(result[i].ID, result[j].ID) > 0 }) 177 for rp.PerPage < len(result) { 178 result, chunks = result[rp.PerPage:], append(chunks, result[0:rp.PerPage]) 179 } 180 chunks = append(chunks, result) 181 182 // return the requested page 183 partialResult := chunks[rp.Page-1] 184 return partialResult, &cloudflare.ResultInfo{ 185 PerPage: rp.PerPage, 186 Page: rp.Page, 187 TotalPages: len(chunks), 188 Count: len(partialResult), 189 Total: len(result), 190 }, nil 191 } 192 193 func (m *mockCloudFlareClient) UpdateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDNSRecordParams) error { 194 recordData := getDNSRecordFromRecordParams(rp) 195 m.Actions = append(m.Actions, MockAction{ 196 Name: "Update", 197 ZoneId: rc.Identifier, 198 RecordId: rp.ID, 199 RecordData: recordData, 200 }) 201 if zone, ok := m.Records[rc.Identifier]; ok { 202 if _, ok := zone[rp.ID]; ok { 203 zone[rp.ID] = recordData 204 } 205 } 206 return nil 207 } 208 209 func (m *mockCloudFlareClient) DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error { 210 m.Actions = append(m.Actions, MockAction{ 211 Name: "Delete", 212 ZoneId: rc.Identifier, 213 RecordId: recordID, 214 }) 215 if zone, ok := m.Records[rc.Identifier]; ok { 216 if _, ok := zone[recordID]; ok { 217 delete(zone, recordID) 218 return nil 219 } 220 } 221 return nil 222 } 223 224 func (m *mockCloudFlareClient) UserDetails(ctx context.Context) (cloudflare.User, error) { 225 return m.User, nil 226 } 227 228 func (m *mockCloudFlareClient) ZoneIDByName(zoneName string) (string, error) { 229 for id, name := range m.Zones { 230 if name == zoneName { 231 return id, nil 232 } 233 } 234 235 return "", errors.New("Unknown zone: " + zoneName) 236 } 237 238 func (m *mockCloudFlareClient) ListZones(ctx context.Context, zoneID ...string) ([]cloudflare.Zone, error) { 239 if m.listZonesError != nil { 240 return nil, m.listZonesError 241 } 242 243 result := []cloudflare.Zone{} 244 245 for zoneID, zoneName := range m.Zones { 246 result = append(result, cloudflare.Zone{ 247 ID: zoneID, 248 Name: zoneName, 249 }) 250 } 251 252 return result, nil 253 } 254 255 func (m *mockCloudFlareClient) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { 256 if m.listZonesError != nil { 257 return cloudflare.ZonesResponse{}, m.listZonesError 258 } 259 260 result := []cloudflare.Zone{} 261 262 for zoneId, zoneName := range m.Zones { 263 result = append(result, cloudflare.Zone{ 264 ID: zoneId, 265 Name: zoneName, 266 }) 267 } 268 269 return cloudflare.ZonesResponse{ 270 Result: result, 271 ResultInfo: cloudflare.ResultInfo{ 272 Page: 1, 273 TotalPages: 1, 274 }, 275 }, nil 276 } 277 278 func (m *mockCloudFlareClient) ZoneDetails(ctx context.Context, zoneID string) (cloudflare.Zone, error) { 279 for id, zoneName := range m.Zones { 280 if zoneID == id { 281 return cloudflare.Zone{ 282 ID: zoneID, 283 Name: zoneName, 284 }, nil 285 } 286 } 287 288 return cloudflare.Zone{}, errors.New("Unknown zoneID: " + zoneID) 289 } 290 291 func AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endpoint.Endpoint, actions []MockAction, managedRecords []string, args ...interface{}) { 292 t.Helper() 293 294 var client *mockCloudFlareClient 295 296 if provider.Client == nil { 297 client = NewMockCloudFlareClient() 298 provider.Client = client 299 } else { 300 client = provider.Client.(*mockCloudFlareClient) 301 } 302 303 ctx := context.Background() 304 305 records, err := provider.Records(ctx) 306 if err != nil { 307 t.Fatalf("cannot fetch records, %s", err) 308 } 309 310 endpoints, err = provider.AdjustEndpoints(endpoints) 311 assert.NoError(t, err) 312 domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) 313 plan := &plan.Plan{ 314 Current: records, 315 Desired: endpoints, 316 DomainFilter: endpoint.MatchAllDomainFilters{&domainFilter}, 317 ManagedRecords: managedRecords, 318 } 319 320 changes := plan.Calculate().Changes 321 322 // Records other than A, CNAME and NS are not supported by planner, just create them 323 for _, endpoint := range endpoints { 324 if endpoint.RecordType != "A" && endpoint.RecordType != "CNAME" && endpoint.RecordType != "NS" { 325 changes.Create = append(changes.Create, endpoint) 326 } 327 } 328 329 err = provider.ApplyChanges(context.Background(), changes) 330 331 if err != nil { 332 t.Fatalf("cannot apply changes, %s", err) 333 } 334 335 td.Cmp(t, client.Actions, actions, args...) 336 } 337 338 func TestCloudflareA(t *testing.T) { 339 endpoints := []*endpoint.Endpoint{ 340 { 341 RecordType: "A", 342 DNSName: "bar.com", 343 Targets: endpoint.Targets{"127.0.0.1", "127.0.0.2"}, 344 }, 345 } 346 347 AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ 348 { 349 Name: "Create", 350 ZoneId: "001", 351 RecordData: cloudflare.DNSRecord{ 352 Type: "A", 353 Name: "bar.com", 354 Content: "127.0.0.1", 355 TTL: 1, 356 Proxied: proxyDisabled, 357 }, 358 }, 359 { 360 Name: "Create", 361 ZoneId: "001", 362 RecordData: cloudflare.DNSRecord{ 363 Type: "A", 364 Name: "bar.com", 365 Content: "127.0.0.2", 366 TTL: 1, 367 Proxied: proxyDisabled, 368 }, 369 }, 370 }, 371 []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 372 ) 373 } 374 375 func TestCloudflareCname(t *testing.T) { 376 endpoints := []*endpoint.Endpoint{ 377 { 378 RecordType: "CNAME", 379 DNSName: "cname.bar.com", 380 Targets: endpoint.Targets{"google.com", "facebook.com"}, 381 }, 382 } 383 384 AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ 385 { 386 Name: "Create", 387 ZoneId: "001", 388 RecordData: cloudflare.DNSRecord{ 389 Type: "CNAME", 390 Name: "cname.bar.com", 391 Content: "google.com", 392 TTL: 1, 393 Proxied: proxyDisabled, 394 }, 395 }, 396 { 397 Name: "Create", 398 ZoneId: "001", 399 RecordData: cloudflare.DNSRecord{ 400 Type: "CNAME", 401 Name: "cname.bar.com", 402 Content: "facebook.com", 403 TTL: 1, 404 Proxied: proxyDisabled, 405 }, 406 }, 407 }, 408 []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 409 ) 410 } 411 412 func TestCloudflareCustomTTL(t *testing.T) { 413 endpoints := []*endpoint.Endpoint{ 414 { 415 RecordType: "A", 416 DNSName: "ttl.bar.com", 417 Targets: endpoint.Targets{"127.0.0.1"}, 418 RecordTTL: 120, 419 }, 420 } 421 422 AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ 423 { 424 Name: "Create", 425 ZoneId: "001", 426 RecordData: cloudflare.DNSRecord{ 427 Type: "A", 428 Name: "ttl.bar.com", 429 Content: "127.0.0.1", 430 TTL: 120, 431 Proxied: proxyDisabled, 432 }, 433 }, 434 }, 435 []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 436 ) 437 } 438 439 func TestCloudflareProxiedDefault(t *testing.T) { 440 endpoints := []*endpoint.Endpoint{ 441 { 442 RecordType: "A", 443 DNSName: "bar.com", 444 Targets: endpoint.Targets{"127.0.0.1"}, 445 }, 446 } 447 448 AssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{ 449 { 450 Name: "Create", 451 ZoneId: "001", 452 RecordData: cloudflare.DNSRecord{ 453 Type: "A", 454 Name: "bar.com", 455 Content: "127.0.0.1", 456 TTL: 1, 457 Proxied: proxyEnabled, 458 }, 459 }, 460 }, 461 []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 462 ) 463 } 464 465 func TestCloudflareProxiedOverrideTrue(t *testing.T) { 466 endpoints := []*endpoint.Endpoint{ 467 { 468 RecordType: "A", 469 DNSName: "bar.com", 470 Targets: endpoint.Targets{"127.0.0.1"}, 471 ProviderSpecific: endpoint.ProviderSpecific{ 472 endpoint.ProviderSpecificProperty{ 473 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 474 Value: "true", 475 }, 476 }, 477 }, 478 } 479 480 AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ 481 { 482 Name: "Create", 483 ZoneId: "001", 484 RecordData: cloudflare.DNSRecord{ 485 Type: "A", 486 Name: "bar.com", 487 Content: "127.0.0.1", 488 TTL: 1, 489 Proxied: proxyEnabled, 490 }, 491 }, 492 }, 493 []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 494 ) 495 } 496 497 func TestCloudflareProxiedOverrideFalse(t *testing.T) { 498 endpoints := []*endpoint.Endpoint{ 499 { 500 RecordType: "A", 501 DNSName: "bar.com", 502 Targets: endpoint.Targets{"127.0.0.1"}, 503 ProviderSpecific: endpoint.ProviderSpecific{ 504 endpoint.ProviderSpecificProperty{ 505 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 506 Value: "false", 507 }, 508 }, 509 }, 510 } 511 512 AssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{ 513 { 514 Name: "Create", 515 ZoneId: "001", 516 RecordData: cloudflare.DNSRecord{ 517 Type: "A", 518 Name: "bar.com", 519 Content: "127.0.0.1", 520 TTL: 1, 521 Proxied: proxyDisabled, 522 }, 523 }, 524 }, 525 []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 526 ) 527 } 528 529 func TestCloudflareProxiedOverrideIllegal(t *testing.T) { 530 endpoints := []*endpoint.Endpoint{ 531 { 532 RecordType: "A", 533 DNSName: "bar.com", 534 Targets: endpoint.Targets{"127.0.0.1"}, 535 ProviderSpecific: endpoint.ProviderSpecific{ 536 endpoint.ProviderSpecificProperty{ 537 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 538 Value: "asfasdfa", 539 }, 540 }, 541 }, 542 } 543 544 AssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{ 545 { 546 Name: "Create", 547 ZoneId: "001", 548 RecordData: cloudflare.DNSRecord{ 549 Type: "A", 550 Name: "bar.com", 551 Content: "127.0.0.1", 552 TTL: 1, 553 Proxied: proxyEnabled, 554 }, 555 }, 556 }, 557 []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 558 ) 559 } 560 561 func TestCloudflareSetProxied(t *testing.T) { 562 var proxied *bool = proxyEnabled 563 var notProxied *bool = proxyDisabled 564 testCases := []struct { 565 recordType string 566 domain string 567 proxiable *bool 568 }{ 569 {"A", "bar.com", proxied}, 570 {"CNAME", "bar.com", proxied}, 571 {"TXT", "bar.com", notProxied}, 572 {"MX", "bar.com", notProxied}, 573 {"NS", "bar.com", notProxied}, 574 {"SPF", "bar.com", notProxied}, 575 {"SRV", "bar.com", notProxied}, 576 {"A", "*.bar.com", proxied}, 577 {"CNAME", "*.docs.bar.com", proxied}, 578 } 579 580 for _, testCase := range testCases { 581 endpoints := []*endpoint.Endpoint{ 582 { 583 RecordType: testCase.recordType, 584 DNSName: testCase.domain, 585 Targets: endpoint.Targets{"127.0.0.1"}, 586 ProviderSpecific: endpoint.ProviderSpecific{ 587 endpoint.ProviderSpecificProperty{ 588 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 589 Value: "true", 590 }, 591 }, 592 }, 593 } 594 595 AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ 596 { 597 Name: "Create", 598 ZoneId: "001", 599 RecordData: cloudflare.DNSRecord{ 600 Type: testCase.recordType, 601 Name: testCase.domain, 602 Content: "127.0.0.1", 603 TTL: 1, 604 Proxied: testCase.proxiable, 605 }, 606 }, 607 }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS}, testCase.recordType+" record on "+testCase.domain) 608 } 609 } 610 611 func TestCloudflareZones(t *testing.T) { 612 provider := &CloudFlareProvider{ 613 Client: NewMockCloudFlareClient(), 614 domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}), 615 zoneIDFilter: provider.NewZoneIDFilter([]string{""}), 616 } 617 618 zones, err := provider.Zones(context.Background()) 619 if err != nil { 620 t.Fatal(err) 621 } 622 623 assert.Equal(t, 1, len(zones)) 624 assert.Equal(t, "bar.com", zones[0].Name) 625 } 626 627 func TestCloudFlareZonesWithIDFilter(t *testing.T) { 628 client := NewMockCloudFlareClient() 629 client.listZonesError = errors.New("shouldn't need to list zones when ZoneIDFilter in use") 630 provider := &CloudFlareProvider{ 631 Client: client, 632 domainFilter: endpoint.NewDomainFilter([]string{"bar.com", "foo.com"}), 633 zoneIDFilter: provider.NewZoneIDFilter([]string{"001"}), 634 } 635 636 zones, err := provider.Zones(context.Background()) 637 if err != nil { 638 t.Fatal(err) 639 } 640 641 // foo.com should *not* be returned as it doesn't match ZoneID filter 642 assert.Equal(t, 1, len(zones)) 643 assert.Equal(t, "bar.com", zones[0].Name) 644 } 645 646 func TestCloudflareRecords(t *testing.T) { 647 client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{ 648 "001": ExampleDomain, 649 }) 650 651 // Set DNSRecordsPerPage to 1 test the pagination behaviour 652 provider := &CloudFlareProvider{ 653 Client: client, 654 DNSRecordsPerPage: 1, 655 } 656 ctx := context.Background() 657 658 records, err := provider.Records(ctx) 659 if err != nil { 660 t.Errorf("should not fail, %s", err) 661 } 662 663 assert.Equal(t, 2, len(records)) 664 client.dnsRecordsError = errors.New("failed to list dns records") 665 _, err = provider.Records(ctx) 666 if err == nil { 667 t.Errorf("expected to fail") 668 } 669 client.dnsRecordsError = nil 670 client.listZonesError = errors.New("failed to list zones") 671 _, err = provider.Records(ctx) 672 if err == nil { 673 t.Errorf("expected to fail") 674 } 675 } 676 677 func TestCloudflareProvider(t *testing.T) { 678 _ = os.Setenv("CF_API_TOKEN", "abc123def") 679 _, err := NewCloudFlareProvider( 680 endpoint.NewDomainFilter([]string{"bar.com"}), 681 provider.NewZoneIDFilter([]string{""}), 682 false, 683 true, 684 5000) 685 if err != nil { 686 t.Errorf("should not fail, %s", err) 687 } 688 689 _ = os.Unsetenv("CF_API_TOKEN") 690 tokenFile := "/tmp/cf_api_token" 691 if err := os.WriteFile(tokenFile, []byte("abc123def"), 0o644); err != nil { 692 t.Errorf("failed to write token file, %s", err) 693 } 694 _ = os.Setenv("CF_API_TOKEN", tokenFile) 695 _, err = NewCloudFlareProvider( 696 endpoint.NewDomainFilter([]string{"bar.com"}), 697 provider.NewZoneIDFilter([]string{""}), 698 false, 699 true, 700 5000) 701 if err != nil { 702 t.Errorf("should not fail, %s", err) 703 } 704 705 _ = os.Unsetenv("CF_API_TOKEN") 706 _ = os.Setenv("CF_API_KEY", "xxxxxxxxxxxxxxxxx") 707 _ = os.Setenv("CF_API_EMAIL", "test@test.com") 708 _, err = NewCloudFlareProvider( 709 endpoint.NewDomainFilter([]string{"bar.com"}), 710 provider.NewZoneIDFilter([]string{""}), 711 false, 712 true, 713 5000) 714 if err != nil { 715 t.Errorf("should not fail, %s", err) 716 } 717 718 _ = os.Unsetenv("CF_API_KEY") 719 _ = os.Unsetenv("CF_API_EMAIL") 720 _, err = NewCloudFlareProvider( 721 endpoint.NewDomainFilter([]string{"bar.com"}), 722 provider.NewZoneIDFilter([]string{""}), 723 false, 724 true, 725 5000) 726 if err == nil { 727 t.Errorf("expected to fail") 728 } 729 } 730 731 func TestCloudflareApplyChanges(t *testing.T) { 732 changes := &plan.Changes{} 733 client := NewMockCloudFlareClient() 734 provider := &CloudFlareProvider{ 735 Client: client, 736 } 737 changes.Create = []*endpoint.Endpoint{{ 738 DNSName: "new.bar.com", 739 Targets: endpoint.Targets{"target"}, 740 }, { 741 DNSName: "new.ext-dns-test.unrelated.to", 742 Targets: endpoint.Targets{"target"}, 743 }} 744 changes.Delete = []*endpoint.Endpoint{{ 745 DNSName: "foobar.bar.com", 746 Targets: endpoint.Targets{"target"}, 747 }} 748 changes.UpdateOld = []*endpoint.Endpoint{{ 749 DNSName: "foobar.bar.com", 750 Targets: endpoint.Targets{"target-old"}, 751 }} 752 changes.UpdateNew = []*endpoint.Endpoint{{ 753 DNSName: "foobar.bar.com", 754 Targets: endpoint.Targets{"target-new"}, 755 }} 756 err := provider.ApplyChanges(context.Background(), changes) 757 if err != nil { 758 t.Errorf("should not fail, %s", err) 759 } 760 761 td.Cmp(t, client.Actions, []MockAction{ 762 { 763 Name: "Create", 764 ZoneId: "001", 765 RecordData: cloudflare.DNSRecord{ 766 Name: "new.bar.com", 767 Content: "target", 768 TTL: 1, 769 Proxied: proxyDisabled, 770 }, 771 }, 772 { 773 Name: "Create", 774 ZoneId: "001", 775 RecordData: cloudflare.DNSRecord{ 776 Name: "foobar.bar.com", 777 Content: "target-new", 778 TTL: 1, 779 Proxied: proxyDisabled, 780 }, 781 }, 782 }) 783 784 // empty changes 785 changes.Create = []*endpoint.Endpoint{} 786 changes.Delete = []*endpoint.Endpoint{} 787 changes.UpdateOld = []*endpoint.Endpoint{} 788 changes.UpdateNew = []*endpoint.Endpoint{} 789 790 err = provider.ApplyChanges(context.Background(), changes) 791 if err != nil { 792 t.Errorf("should not fail, %s", err) 793 } 794 } 795 796 func TestCloudflareApplyChangesError(t *testing.T) { 797 changes := &plan.Changes{} 798 client := NewMockCloudFlareClient() 799 provider := &CloudFlareProvider{ 800 Client: client, 801 } 802 changes.Create = []*endpoint.Endpoint{{ 803 DNSName: "newerror.bar.com", 804 Targets: endpoint.Targets{"target"}, 805 }} 806 err := provider.ApplyChanges(context.Background(), changes) 807 if err == nil { 808 t.Errorf("should fail, %s", err) 809 } 810 } 811 812 func TestCloudflareGetRecordID(t *testing.T) { 813 p := &CloudFlareProvider{} 814 records := []cloudflare.DNSRecord{ 815 { 816 Name: "foo.com", 817 Type: endpoint.RecordTypeCNAME, 818 Content: "foobar", 819 ID: "1", 820 }, 821 { 822 Name: "bar.de", 823 Type: endpoint.RecordTypeA, 824 ID: "2", 825 }, 826 { 827 Name: "bar.de", 828 Type: endpoint.RecordTypeA, 829 Content: "1.2.3.4", 830 ID: "2", 831 }, 832 } 833 834 assert.Equal(t, "", p.getRecordID(records, cloudflare.DNSRecord{ 835 Name: "foo.com", 836 Type: endpoint.RecordTypeA, 837 Content: "foobar", 838 })) 839 840 assert.Equal(t, "", p.getRecordID(records, cloudflare.DNSRecord{ 841 Name: "foo.com", 842 Type: endpoint.RecordTypeCNAME, 843 Content: "fizfuz", 844 })) 845 846 assert.Equal(t, "1", p.getRecordID(records, cloudflare.DNSRecord{ 847 Name: "foo.com", 848 Type: endpoint.RecordTypeCNAME, 849 Content: "foobar", 850 })) 851 assert.Equal(t, "", p.getRecordID(records, cloudflare.DNSRecord{ 852 Name: "bar.de", 853 Type: endpoint.RecordTypeA, 854 Content: "2.3.4.5", 855 })) 856 assert.Equal(t, "2", p.getRecordID(records, cloudflare.DNSRecord{ 857 Name: "bar.de", 858 Type: endpoint.RecordTypeA, 859 Content: "1.2.3.4", 860 })) 861 } 862 863 func TestCloudflareGroupByNameAndType(t *testing.T) { 864 testCases := []struct { 865 Name string 866 Records []cloudflare.DNSRecord 867 ExpectedEndpoints []*endpoint.Endpoint 868 }{ 869 { 870 Name: "empty", 871 Records: []cloudflare.DNSRecord{}, 872 ExpectedEndpoints: []*endpoint.Endpoint{}, 873 }, 874 { 875 Name: "single record - single target", 876 Records: []cloudflare.DNSRecord{ 877 { 878 Name: "foo.com", 879 Type: endpoint.RecordTypeA, 880 Content: "10.10.10.1", 881 TTL: defaultCloudFlareRecordTTL, 882 Proxied: proxyDisabled, 883 }, 884 }, 885 ExpectedEndpoints: []*endpoint.Endpoint{ 886 { 887 DNSName: "foo.com", 888 Targets: endpoint.Targets{"10.10.10.1"}, 889 RecordType: endpoint.RecordTypeA, 890 RecordTTL: endpoint.TTL(defaultCloudFlareRecordTTL), 891 Labels: endpoint.Labels{}, 892 ProviderSpecific: endpoint.ProviderSpecific{ 893 { 894 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 895 Value: "false", 896 }, 897 }, 898 }, 899 }, 900 }, 901 { 902 Name: "single record - multiple targets", 903 Records: []cloudflare.DNSRecord{ 904 { 905 Name: "foo.com", 906 Type: endpoint.RecordTypeA, 907 Content: "10.10.10.1", 908 TTL: defaultCloudFlareRecordTTL, 909 Proxied: proxyDisabled, 910 }, 911 { 912 Name: "foo.com", 913 Type: endpoint.RecordTypeA, 914 Content: "10.10.10.2", 915 TTL: defaultCloudFlareRecordTTL, 916 Proxied: proxyDisabled, 917 }, 918 }, 919 ExpectedEndpoints: []*endpoint.Endpoint{ 920 { 921 DNSName: "foo.com", 922 Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, 923 RecordType: endpoint.RecordTypeA, 924 RecordTTL: endpoint.TTL(defaultCloudFlareRecordTTL), 925 Labels: endpoint.Labels{}, 926 ProviderSpecific: endpoint.ProviderSpecific{ 927 { 928 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 929 Value: "false", 930 }, 931 }, 932 }, 933 }, 934 }, 935 { 936 Name: "multiple record - multiple targets", 937 Records: []cloudflare.DNSRecord{ 938 { 939 Name: "foo.com", 940 Type: endpoint.RecordTypeA, 941 Content: "10.10.10.1", 942 TTL: defaultCloudFlareRecordTTL, 943 Proxied: proxyDisabled, 944 }, 945 { 946 Name: "foo.com", 947 Type: endpoint.RecordTypeA, 948 Content: "10.10.10.2", 949 TTL: defaultCloudFlareRecordTTL, 950 Proxied: proxyDisabled, 951 }, 952 { 953 Name: "bar.de", 954 Type: endpoint.RecordTypeA, 955 Content: "10.10.10.1", 956 TTL: defaultCloudFlareRecordTTL, 957 Proxied: proxyDisabled, 958 }, 959 { 960 Name: "bar.de", 961 Type: endpoint.RecordTypeA, 962 Content: "10.10.10.2", 963 TTL: defaultCloudFlareRecordTTL, 964 Proxied: proxyDisabled, 965 }, 966 }, 967 ExpectedEndpoints: []*endpoint.Endpoint{ 968 { 969 DNSName: "foo.com", 970 Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, 971 RecordType: endpoint.RecordTypeA, 972 RecordTTL: endpoint.TTL(defaultCloudFlareRecordTTL), 973 Labels: endpoint.Labels{}, 974 ProviderSpecific: endpoint.ProviderSpecific{ 975 { 976 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 977 Value: "false", 978 }, 979 }, 980 }, 981 { 982 DNSName: "bar.de", 983 Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, 984 RecordType: endpoint.RecordTypeA, 985 RecordTTL: endpoint.TTL(defaultCloudFlareRecordTTL), 986 Labels: endpoint.Labels{}, 987 ProviderSpecific: endpoint.ProviderSpecific{ 988 { 989 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 990 Value: "false", 991 }, 992 }, 993 }, 994 }, 995 }, 996 { 997 Name: "multiple record - mixed single/multiple targets", 998 Records: []cloudflare.DNSRecord{ 999 { 1000 Name: "foo.com", 1001 Type: endpoint.RecordTypeA, 1002 Content: "10.10.10.1", 1003 TTL: defaultCloudFlareRecordTTL, 1004 Proxied: proxyDisabled, 1005 }, 1006 { 1007 Name: "foo.com", 1008 Type: endpoint.RecordTypeA, 1009 Content: "10.10.10.2", 1010 TTL: defaultCloudFlareRecordTTL, 1011 Proxied: proxyDisabled, 1012 }, 1013 { 1014 Name: "bar.de", 1015 Type: endpoint.RecordTypeA, 1016 Content: "10.10.10.1", 1017 TTL: defaultCloudFlareRecordTTL, 1018 Proxied: proxyDisabled, 1019 }, 1020 }, 1021 ExpectedEndpoints: []*endpoint.Endpoint{ 1022 { 1023 DNSName: "foo.com", 1024 Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, 1025 RecordType: endpoint.RecordTypeA, 1026 RecordTTL: endpoint.TTL(defaultCloudFlareRecordTTL), 1027 Labels: endpoint.Labels{}, 1028 ProviderSpecific: endpoint.ProviderSpecific{ 1029 { 1030 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 1031 Value: "false", 1032 }, 1033 }, 1034 }, 1035 { 1036 DNSName: "bar.de", 1037 Targets: endpoint.Targets{"10.10.10.1"}, 1038 RecordType: endpoint.RecordTypeA, 1039 RecordTTL: endpoint.TTL(defaultCloudFlareRecordTTL), 1040 Labels: endpoint.Labels{}, 1041 ProviderSpecific: endpoint.ProviderSpecific{ 1042 { 1043 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 1044 Value: "false", 1045 }, 1046 }, 1047 }, 1048 }, 1049 }, 1050 { 1051 Name: "unsupported record type", 1052 Records: []cloudflare.DNSRecord{ 1053 { 1054 Name: "foo.com", 1055 Type: endpoint.RecordTypeA, 1056 Content: "10.10.10.1", 1057 TTL: defaultCloudFlareRecordTTL, 1058 Proxied: proxyDisabled, 1059 }, 1060 { 1061 Name: "foo.com", 1062 Type: endpoint.RecordTypeA, 1063 Content: "10.10.10.2", 1064 TTL: defaultCloudFlareRecordTTL, 1065 Proxied: proxyDisabled, 1066 }, 1067 { 1068 Name: "bar.de", 1069 Type: "NOT SUPPORTED", 1070 Content: "10.10.10.1", 1071 TTL: defaultCloudFlareRecordTTL, 1072 Proxied: proxyDisabled, 1073 }, 1074 }, 1075 ExpectedEndpoints: []*endpoint.Endpoint{ 1076 { 1077 DNSName: "foo.com", 1078 Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, 1079 RecordType: endpoint.RecordTypeA, 1080 RecordTTL: endpoint.TTL(defaultCloudFlareRecordTTL), 1081 Labels: endpoint.Labels{}, 1082 ProviderSpecific: endpoint.ProviderSpecific{ 1083 { 1084 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 1085 Value: "false", 1086 }, 1087 }, 1088 }, 1089 }, 1090 }, 1091 } 1092 1093 for _, tc := range testCases { 1094 assert.ElementsMatch(t, groupByNameAndType(tc.Records), tc.ExpectedEndpoints) 1095 } 1096 } 1097 1098 func TestProviderPropertiesIdempotency(t *testing.T) { 1099 testCases := []struct { 1100 Name string 1101 ProviderProxiedByDefault bool 1102 RecordsAreProxied *bool 1103 ShouldBeUpdated bool 1104 }{ 1105 { 1106 Name: "ProxyDefault: false, ShouldBeProxied: false, ExpectUpdates: false", 1107 ProviderProxiedByDefault: false, 1108 RecordsAreProxied: proxyDisabled, 1109 ShouldBeUpdated: false, 1110 }, 1111 { 1112 Name: "ProxyDefault: true, ShouldBeProxied: true, ExpectUpdates: false", 1113 ProviderProxiedByDefault: true, 1114 RecordsAreProxied: proxyEnabled, 1115 ShouldBeUpdated: false, 1116 }, 1117 { 1118 Name: "ProxyDefault: true, ShouldBeProxied: false, ExpectUpdates: true", 1119 ProviderProxiedByDefault: true, 1120 RecordsAreProxied: proxyDisabled, 1121 ShouldBeUpdated: true, 1122 }, 1123 { 1124 Name: "ProxyDefault: false, ShouldBeProxied: true, ExpectUpdates: true", 1125 ProviderProxiedByDefault: false, 1126 RecordsAreProxied: proxyEnabled, 1127 ShouldBeUpdated: true, 1128 }, 1129 } 1130 1131 for _, test := range testCases { 1132 t.Run(test.Name, func(t *testing.T) { 1133 client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{ 1134 "001": { 1135 { 1136 ID: "1234567890", 1137 ZoneID: "001", 1138 Name: "foobar.bar.com", 1139 Type: endpoint.RecordTypeA, 1140 TTL: 120, 1141 Content: "1.2.3.4", 1142 Proxied: test.RecordsAreProxied, 1143 }, 1144 }, 1145 }) 1146 1147 provider := &CloudFlareProvider{ 1148 Client: client, 1149 proxiedByDefault: test.ProviderProxiedByDefault, 1150 } 1151 ctx := context.Background() 1152 1153 current, err := provider.Records(ctx) 1154 if err != nil { 1155 t.Errorf("should not fail, %s", err) 1156 } 1157 assert.Equal(t, 1, len(current)) 1158 1159 desired := []*endpoint.Endpoint{} 1160 for _, c := range current { 1161 // Copy all except ProviderSpecific fields 1162 desired = append(desired, &endpoint.Endpoint{ 1163 DNSName: c.DNSName, 1164 Targets: c.Targets, 1165 RecordType: c.RecordType, 1166 SetIdentifier: c.SetIdentifier, 1167 RecordTTL: c.RecordTTL, 1168 Labels: c.Labels, 1169 }) 1170 } 1171 1172 desired, err = provider.AdjustEndpoints(desired) 1173 assert.NoError(t, err) 1174 1175 plan := plan.Plan{ 1176 Current: current, 1177 Desired: desired, 1178 ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 1179 } 1180 1181 plan = *plan.Calculate() 1182 assert.NotNil(t, plan.Changes, "should have plan") 1183 if plan.Changes == nil { 1184 return 1185 } 1186 assert.Equal(t, 0, len(plan.Changes.Create), "should not have creates") 1187 assert.Equal(t, 0, len(plan.Changes.Delete), "should not have deletes") 1188 1189 if test.ShouldBeUpdated { 1190 assert.Equal(t, 1, len(plan.Changes.UpdateNew), "should not have new updates") 1191 assert.Equal(t, 1, len(plan.Changes.UpdateOld), "should not have old updates") 1192 } else { 1193 assert.Equal(t, 0, len(plan.Changes.UpdateNew), "should not have new updates") 1194 assert.Equal(t, 0, len(plan.Changes.UpdateOld), "should not have old updates") 1195 } 1196 }) 1197 } 1198 } 1199 1200 func TestCloudflareComplexUpdate(t *testing.T) { 1201 client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{ 1202 "001": ExampleDomain, 1203 }) 1204 1205 provider := &CloudFlareProvider{ 1206 Client: client, 1207 } 1208 ctx := context.Background() 1209 1210 records, err := provider.Records(ctx) 1211 if err != nil { 1212 t.Errorf("should not fail, %s", err) 1213 } 1214 1215 domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) 1216 endpoints, err := provider.AdjustEndpoints([]*endpoint.Endpoint{ 1217 { 1218 DNSName: "foobar.bar.com", 1219 Targets: endpoint.Targets{"1.2.3.4", "2.3.4.5"}, 1220 RecordType: endpoint.RecordTypeA, 1221 RecordTTL: endpoint.TTL(defaultCloudFlareRecordTTL), 1222 Labels: endpoint.Labels{}, 1223 ProviderSpecific: endpoint.ProviderSpecific{ 1224 { 1225 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 1226 Value: "true", 1227 }, 1228 }, 1229 }, 1230 }) 1231 assert.NoError(t, err) 1232 plan := &plan.Plan{ 1233 Current: records, 1234 Desired: endpoints, 1235 DomainFilter: endpoint.MatchAllDomainFilters{&domainFilter}, 1236 ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 1237 } 1238 1239 planned := plan.Calculate() 1240 1241 err = provider.ApplyChanges(context.Background(), planned.Changes) 1242 1243 if err != nil { 1244 t.Errorf("should not fail, %s", err) 1245 } 1246 1247 td.CmpDeeply(t, client.Actions, []MockAction{ 1248 { 1249 Name: "Delete", 1250 ZoneId: "001", 1251 RecordId: "2345678901", 1252 }, 1253 { 1254 Name: "Create", 1255 ZoneId: "001", 1256 RecordData: cloudflare.DNSRecord{ 1257 Name: "foobar.bar.com", 1258 Type: "A", 1259 Content: "2.3.4.5", 1260 TTL: 1, 1261 Proxied: proxyEnabled, 1262 }, 1263 }, 1264 { 1265 Name: "Update", 1266 ZoneId: "001", 1267 RecordId: "1234567890", 1268 RecordData: cloudflare.DNSRecord{ 1269 Name: "foobar.bar.com", 1270 Type: "A", 1271 Content: "1.2.3.4", 1272 TTL: 1, 1273 Proxied: proxyEnabled, 1274 }, 1275 }, 1276 }) 1277 } 1278 1279 func TestCustomTTLWithEnabledProxyNotChanged(t *testing.T) { 1280 client := NewMockCloudFlareClientWithRecords(map[string][]cloudflare.DNSRecord{ 1281 "001": { 1282 { 1283 ID: "1234567890", 1284 ZoneID: "001", 1285 Name: "foobar.bar.com", 1286 Type: endpoint.RecordTypeA, 1287 TTL: 1, 1288 Content: "1.2.3.4", 1289 Proxied: proxyEnabled, 1290 }, 1291 }, 1292 }) 1293 1294 provider := &CloudFlareProvider{ 1295 Client: client, 1296 } 1297 1298 records, err := provider.Records(context.Background()) 1299 if err != nil { 1300 t.Errorf("should not fail, %s", err) 1301 } 1302 1303 endpoints := []*endpoint.Endpoint{ 1304 { 1305 DNSName: "foobar.bar.com", 1306 Targets: endpoint.Targets{"1.2.3.4"}, 1307 RecordType: endpoint.RecordTypeA, 1308 RecordTTL: 300, 1309 Labels: endpoint.Labels{}, 1310 ProviderSpecific: endpoint.ProviderSpecific{ 1311 { 1312 Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", 1313 Value: "true", 1314 }, 1315 }, 1316 }, 1317 } 1318 1319 provider.AdjustEndpoints(endpoints) 1320 1321 domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) 1322 plan := &plan.Plan{ 1323 Current: records, 1324 Desired: endpoints, 1325 DomainFilter: endpoint.MatchAllDomainFilters{&domainFilter}, 1326 ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, 1327 } 1328 1329 planned := plan.Calculate() 1330 1331 assert.Equal(t, 0, len(planned.Changes.Create), "no new changes should be here") 1332 assert.Equal(t, 0, len(planned.Changes.UpdateNew), "no new changes should be here") 1333 assert.Equal(t, 0, len(planned.Changes.UpdateOld), "no new changes should be here") 1334 assert.Equal(t, 0, len(planned.Changes.Delete), "no new changes should be here") 1335 }