sigs.k8s.io/external-dns@v0.14.1/provider/digitalocean/digital_ocean.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 digitalocean 18 19 import ( 20 "context" 21 "fmt" 22 "os" 23 "strings" 24 25 "github.com/digitalocean/godo" 26 log "github.com/sirupsen/logrus" 27 "golang.org/x/oauth2" 28 29 "sigs.k8s.io/external-dns/endpoint" 30 "sigs.k8s.io/external-dns/pkg/apis/externaldns" 31 "sigs.k8s.io/external-dns/plan" 32 "sigs.k8s.io/external-dns/provider" 33 ) 34 35 const ( 36 // digitalOceanRecordTTL is the default TTL value 37 digitalOceanRecordTTL = 300 38 ) 39 40 // DigitalOceanProvider is an implementation of Provider for Digital Ocean's DNS. 41 type DigitalOceanProvider struct { 42 provider.BaseProvider 43 Client godo.DomainsService 44 // only consider hosted zones managing domains ending in this suffix 45 domainFilter endpoint.DomainFilter 46 // page size when querying paginated APIs 47 apiPageSize int 48 DryRun bool 49 } 50 51 type digitalOceanChangeCreate struct { 52 Domain string 53 Options *godo.DomainRecordEditRequest 54 } 55 56 type digitalOceanChangeUpdate struct { 57 Domain string 58 DomainRecord godo.DomainRecord 59 Options *godo.DomainRecordEditRequest 60 } 61 62 type digitalOceanChangeDelete struct { 63 Domain string 64 RecordID int 65 } 66 67 // DigitalOceanChange contains all changes to apply to DNS 68 type digitalOceanChanges struct { 69 Creates []*digitalOceanChangeCreate 70 Updates []*digitalOceanChangeUpdate 71 Deletes []*digitalOceanChangeDelete 72 } 73 74 func (c *digitalOceanChanges) Empty() bool { 75 return len(c.Creates) == 0 && len(c.Updates) == 0 && len(c.Deletes) == 0 76 } 77 78 // NewDigitalOceanProvider initializes a new DigitalOcean DNS based Provider. 79 func NewDigitalOceanProvider(ctx context.Context, domainFilter endpoint.DomainFilter, dryRun bool, apiPageSize int) (*DigitalOceanProvider, error) { 80 token, ok := os.LookupEnv("DO_TOKEN") 81 if !ok { 82 return nil, fmt.Errorf("no token found") 83 } 84 oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{ 85 AccessToken: token, 86 })) 87 client, err := godo.New(oauthClient, godo.SetUserAgent("ExternalDNS/"+externaldns.Version)) 88 if err != nil { 89 return nil, err 90 } 91 92 p := &DigitalOceanProvider{ 93 Client: client.Domains, 94 domainFilter: domainFilter, 95 apiPageSize: apiPageSize, 96 DryRun: dryRun, 97 } 98 return p, nil 99 } 100 101 // Zones returns the list of hosted zones. 102 func (p *DigitalOceanProvider) Zones(ctx context.Context) ([]godo.Domain, error) { 103 result := []godo.Domain{} 104 105 zones, err := p.fetchZones(ctx) 106 if err != nil { 107 return nil, err 108 } 109 110 for _, zone := range zones { 111 if p.domainFilter.Match(zone.Name) { 112 result = append(result, zone) 113 } 114 } 115 116 return result, nil 117 } 118 119 // Merge Endpoints with the same Name and Type into a single endpoint with multiple Targets. 120 func mergeEndpointsByNameType(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint { 121 endpointsByNameType := map[string][]*endpoint.Endpoint{} 122 123 for _, e := range endpoints { 124 key := fmt.Sprintf("%s-%s", e.DNSName, e.RecordType) 125 endpointsByNameType[key] = append(endpointsByNameType[key], e) 126 } 127 128 // If no merge occurred, just return the existing endpoints. 129 if len(endpointsByNameType) == len(endpoints) { 130 return endpoints 131 } 132 133 // Otherwise, construct a new list of endpoints with the endpoints merged. 134 var result []*endpoint.Endpoint 135 for _, endpoints := range endpointsByNameType { 136 dnsName := endpoints[0].DNSName 137 recordType := endpoints[0].RecordType 138 139 targets := make([]string, len(endpoints)) 140 for i, e := range endpoints { 141 targets[i] = e.Targets[0] 142 } 143 144 e := endpoint.NewEndpoint(dnsName, recordType, targets...) 145 result = append(result, e) 146 } 147 148 return result 149 } 150 151 // Records returns the list of records in a given zone. 152 func (p *DigitalOceanProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { 153 zones, err := p.Zones(ctx) 154 if err != nil { 155 return nil, err 156 } 157 158 endpoints := []*endpoint.Endpoint{} 159 for _, zone := range zones { 160 records, err := p.fetchRecords(ctx, zone.Name) 161 if err != nil { 162 return nil, err 163 } 164 165 for _, r := range records { 166 if provider.SupportedRecordType(r.Type) { 167 name := r.Name + "." + zone.Name 168 169 // root name is identified by @ and should be 170 // translated to zone name for the endpoint entry. 171 if r.Name == "@" { 172 name = zone.Name 173 } 174 175 ep := endpoint.NewEndpointWithTTL(name, r.Type, endpoint.TTL(r.TTL), r.Data) 176 177 endpoints = append(endpoints, ep) 178 } 179 } 180 } 181 182 // Merge endpoints with the same name and type (e.g., multiple A records for a single 183 // DNS name) into one endpoint with multiple targets. 184 endpoints = mergeEndpointsByNameType(endpoints) 185 186 // Log the endpoints that were found. 187 log.WithFields(log.Fields{ 188 "endpoints": endpoints, 189 }).Debug("Endpoints generated from DigitalOcean DNS") 190 191 return endpoints, nil 192 } 193 194 func (p *DigitalOceanProvider) fetchRecords(ctx context.Context, zoneName string) ([]godo.DomainRecord, error) { 195 allRecords := []godo.DomainRecord{} 196 listOptions := &godo.ListOptions{PerPage: p.apiPageSize} 197 for { 198 records, resp, err := p.Client.Records(ctx, zoneName, listOptions) 199 if err != nil { 200 return nil, err 201 } 202 allRecords = append(allRecords, records...) 203 204 if resp == nil || resp.Links == nil || resp.Links.IsLastPage() { 205 break 206 } 207 208 page, err := resp.Links.CurrentPage() 209 if err != nil { 210 return nil, err 211 } 212 213 listOptions.Page = page + 1 214 } 215 216 return allRecords, nil 217 } 218 219 func (p *DigitalOceanProvider) fetchZones(ctx context.Context) ([]godo.Domain, error) { 220 allZones := []godo.Domain{} 221 listOptions := &godo.ListOptions{PerPage: p.apiPageSize} 222 for { 223 zones, resp, err := p.Client.List(ctx, listOptions) 224 if err != nil { 225 return nil, err 226 } 227 allZones = append(allZones, zones...) 228 229 if resp == nil || resp.Links == nil || resp.Links.IsLastPage() { 230 break 231 } 232 233 page, err := resp.Links.CurrentPage() 234 if err != nil { 235 return nil, err 236 } 237 238 listOptions.Page = page + 1 239 } 240 241 return allZones, nil 242 } 243 244 func (p *DigitalOceanProvider) getRecordsByDomain(ctx context.Context) (map[string][]godo.DomainRecord, provider.ZoneIDName, error) { 245 recordsByDomain := map[string][]godo.DomainRecord{} 246 247 zones, err := p.Zones(ctx) 248 if err != nil { 249 return nil, nil, err 250 } 251 252 zonesByDomain := make(map[string]godo.Domain) 253 zoneNameIDMapper := provider.ZoneIDName{} 254 for _, z := range zones { 255 zoneNameIDMapper.Add(z.Name, z.Name) 256 zonesByDomain[z.Name] = z 257 } 258 259 // Fetch records for each zone 260 for _, zone := range zones { 261 records, err := p.fetchRecords(ctx, zone.Name) 262 if err != nil { 263 return nil, nil, err 264 } 265 266 recordsByDomain[zone.Name] = append(recordsByDomain[zone.Name], records...) 267 } 268 269 return recordsByDomain, zoneNameIDMapper, nil 270 } 271 272 // Make a DomainRecordEditRequest that conforms to DigitalOcean API requirements: 273 // - Records at root of the zone have `@` as the name 274 // - CNAME records must end in a `.` 275 func makeDomainEditRequest(domain, name, recordType, data string, ttl int) *godo.DomainRecordEditRequest { 276 // Trim the domain off the name if present. 277 adjustedName := strings.TrimSuffix(name, "."+domain) 278 279 // Record at the root should be defined as @ instead of the full domain name. 280 if adjustedName == domain { 281 adjustedName = "@" 282 } 283 284 // For some reason the DO API requires the '.' at the end of "data" in case of CNAME request. 285 // Example: {"type":"CNAME","name":"hello","data":"www.example.com."} 286 if recordType == endpoint.RecordTypeCNAME && !strings.HasSuffix(data, ".") { 287 data += "." 288 } 289 290 return &godo.DomainRecordEditRequest{ 291 Name: adjustedName, 292 Type: recordType, 293 Data: data, 294 TTL: ttl, 295 } 296 } 297 298 // submitChanges applies an instance of `digitalOceanChanges` to the DigitalOcean API. 299 func (p *DigitalOceanProvider) submitChanges(ctx context.Context, changes *digitalOceanChanges) error { 300 // return early if there is nothing to change 301 if changes.Empty() { 302 return nil 303 } 304 305 for _, c := range changes.Creates { 306 log.WithFields(log.Fields{ 307 "domain": c.Domain, 308 "dnsName": c.Options.Name, 309 "recordType": c.Options.Type, 310 "data": c.Options.Data, 311 "ttl": c.Options.TTL, 312 }).Debug("Creating domain record") 313 314 if p.DryRun { 315 continue 316 } 317 318 _, _, err := p.Client.CreateRecord(ctx, c.Domain, c.Options) 319 if err != nil { 320 return err 321 } 322 } 323 324 for _, u := range changes.Updates { 325 log.WithFields(log.Fields{ 326 "domain": u.Domain, 327 "dnsName": u.Options.Name, 328 "recordType": u.Options.Type, 329 "data": u.Options.Data, 330 "ttl": u.Options.TTL, 331 }).Debug("Updating domain record") 332 333 if p.DryRun { 334 continue 335 } 336 337 _, _, err := p.Client.EditRecord(ctx, u.Domain, u.DomainRecord.ID, u.Options) 338 if err != nil { 339 return err 340 } 341 } 342 343 for _, d := range changes.Deletes { 344 log.WithFields(log.Fields{ 345 "domain": d.Domain, 346 "recordId": d.RecordID, 347 }).Debug("Deleting domain record") 348 349 if p.DryRun { 350 continue 351 } 352 353 _, err := p.Client.DeleteRecord(ctx, d.Domain, d.RecordID) 354 if err != nil { 355 return err 356 } 357 } 358 359 return nil 360 } 361 362 func getTTLFromEndpoint(ep *endpoint.Endpoint) int { 363 if ep.RecordTTL.IsConfigured() { 364 return int(ep.RecordTTL) 365 } 366 return digitalOceanRecordTTL 367 } 368 369 func endpointsByZone(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint { 370 endpointsByZone := make(map[string][]*endpoint.Endpoint) 371 372 for _, ep := range endpoints { 373 zoneID, _ := zoneNameIDMapper.FindZone(ep.DNSName) 374 if zoneID == "" { 375 log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", ep.DNSName) 376 continue 377 } 378 endpointsByZone[zoneID] = append(endpointsByZone[zoneID], ep) 379 } 380 381 return endpointsByZone 382 } 383 384 func getMatchingDomainRecords(records []godo.DomainRecord, domain string, ep *endpoint.Endpoint) []godo.DomainRecord { 385 var name string 386 if ep.DNSName != domain { 387 name = strings.TrimSuffix(ep.DNSName, "."+domain) 388 } else { 389 name = "@" 390 } 391 392 var result []godo.DomainRecord 393 for _, r := range records { 394 if r.Name == name && r.Type == ep.RecordType { 395 result = append(result, r) 396 } 397 } 398 return result 399 } 400 401 func processCreateActions( 402 recordsByDomain map[string][]godo.DomainRecord, 403 createsByDomain map[string][]*endpoint.Endpoint, 404 changes *digitalOceanChanges, 405 ) error { 406 // Process endpoints that need to be created. 407 for domain, endpoints := range createsByDomain { 408 if len(endpoints) == 0 { 409 log.WithFields(log.Fields{ 410 "domain": domain, 411 }).Debug("Skipping domain, no creates found.") 412 continue 413 } 414 415 records := recordsByDomain[domain] 416 417 for _, ep := range endpoints { 418 // Warn if there are existing records since we expect to create only new records. 419 matchingRecords := getMatchingDomainRecords(records, domain, ep) 420 if len(matchingRecords) > 0 { 421 log.WithFields(log.Fields{ 422 "domain": domain, 423 "dnsName": ep.DNSName, 424 "recordType": ep.RecordType, 425 }).Warn("Preexisting records exist which should not exist for creation actions.") 426 } 427 428 ttl := getTTLFromEndpoint(ep) 429 430 for _, target := range ep.Targets { 431 changes.Creates = append(changes.Creates, &digitalOceanChangeCreate{ 432 Domain: domain, 433 Options: makeDomainEditRequest(domain, ep.DNSName, ep.RecordType, target, ttl), 434 }) 435 } 436 } 437 } 438 439 return nil 440 } 441 442 func processUpdateActions( 443 recordsByDomain map[string][]godo.DomainRecord, 444 updatesByDomain map[string][]*endpoint.Endpoint, 445 changes *digitalOceanChanges, 446 ) error { 447 // Generate creates and updates based on existing 448 for domain, updates := range updatesByDomain { 449 if len(updates) == 0 { 450 log.WithFields(log.Fields{ 451 "domain": domain, 452 }).Debug("Skipping Zone, no updates found.") 453 continue 454 } 455 456 records := recordsByDomain[domain] 457 log.WithFields(log.Fields{ 458 "domain": domain, 459 "records": records, 460 }).Debug("Records for domain") 461 462 for _, ep := range updates { 463 matchingRecords := getMatchingDomainRecords(records, domain, ep) 464 465 log.WithFields(log.Fields{ 466 "endpoint": ep, 467 "matchingRecords": matchingRecords, 468 }).Debug("matching records") 469 470 if len(matchingRecords) == 0 { 471 log.WithFields(log.Fields{ 472 "domain": domain, 473 "dnsName": ep.DNSName, 474 "recordType": ep.RecordType, 475 }).Warn("Planning an update but no existing records found.") 476 } 477 478 matchingRecordsByTarget := map[string]godo.DomainRecord{} 479 for _, r := range matchingRecords { 480 matchingRecordsByTarget[r.Data] = r 481 } 482 483 ttl := getTTLFromEndpoint(ep) 484 485 // Generate create and delete actions based on existence of a record for each target. 486 for _, target := range ep.Targets { 487 if record, ok := matchingRecordsByTarget[target]; ok { 488 log.WithFields(log.Fields{ 489 "domain": domain, 490 "dnsName": ep.DNSName, 491 "recordType": ep.RecordType, 492 "target": target, 493 }).Warn("Updating existing target") 494 495 changes.Updates = append(changes.Updates, &digitalOceanChangeUpdate{ 496 Domain: domain, 497 DomainRecord: record, 498 Options: makeDomainEditRequest(domain, ep.DNSName, ep.RecordType, target, ttl), 499 }) 500 501 delete(matchingRecordsByTarget, target) 502 } else { 503 // Record did not previously exist, create new 'target' 504 log.WithFields(log.Fields{ 505 "domain": domain, 506 "dnsName": ep.DNSName, 507 "recordType": ep.RecordType, 508 "target": target, 509 }).Warn("Creating new target") 510 511 changes.Creates = append(changes.Creates, &digitalOceanChangeCreate{ 512 Domain: domain, 513 Options: makeDomainEditRequest(domain, ep.DNSName, ep.RecordType, target, ttl), 514 }) 515 } 516 } 517 518 // Any remaining records have been removed, delete them 519 for _, record := range matchingRecordsByTarget { 520 log.WithFields(log.Fields{ 521 "domain": domain, 522 "dnsName": ep.DNSName, 523 "recordType": ep.RecordType, 524 "target": record.Data, 525 }).Warn("Deleting target") 526 527 changes.Deletes = append(changes.Deletes, &digitalOceanChangeDelete{ 528 Domain: domain, 529 RecordID: record.ID, 530 }) 531 } 532 } 533 } 534 535 return nil 536 } 537 538 func processDeleteActions( 539 recordsByDomain map[string][]godo.DomainRecord, 540 deletesByDomain map[string][]*endpoint.Endpoint, 541 changes *digitalOceanChanges, 542 ) error { 543 // Generate delete actions for each deleted endpoint. 544 for domain, deletes := range deletesByDomain { 545 if len(deletes) == 0 { 546 log.WithFields(log.Fields{ 547 "domain": domain, 548 }).Debug("Skipping Zone, no deletes found.") 549 continue 550 } 551 552 records := recordsByDomain[domain] 553 554 for _, ep := range deletes { 555 matchingRecords := getMatchingDomainRecords(records, domain, ep) 556 557 if len(matchingRecords) == 0 { 558 log.WithFields(log.Fields{ 559 "domain": domain, 560 "dnsName": ep.DNSName, 561 "recordType": ep.RecordType, 562 }).Warn("Records to delete not found.") 563 } 564 565 for _, record := range matchingRecords { 566 doDelete := false 567 for _, t := range ep.Targets { 568 v1 := t 569 v2 := record.Data 570 if ep.RecordType == endpoint.RecordTypeCNAME { 571 v1 = strings.TrimSuffix(t, ".") 572 v2 = strings.TrimSuffix(t, ".") 573 } 574 if v1 == v2 { 575 doDelete = true 576 } 577 } 578 579 if doDelete { 580 changes.Deletes = append(changes.Deletes, &digitalOceanChangeDelete{ 581 Domain: domain, 582 RecordID: record.ID, 583 }) 584 } 585 } 586 } 587 } 588 589 return nil 590 } 591 592 // ApplyChanges applies the given set of generic changes to the provider. 593 func (p *DigitalOceanProvider) ApplyChanges(ctx context.Context, planChanges *plan.Changes) error { 594 // TODO: This should only retrieve zones affected by the given `planChanges`. 595 recordsByDomain, zoneNameIDMapper, err := p.getRecordsByDomain(ctx) 596 if err != nil { 597 return err 598 } 599 600 createsByDomain := endpointsByZone(zoneNameIDMapper, planChanges.Create) 601 updatesByDomain := endpointsByZone(zoneNameIDMapper, planChanges.UpdateNew) 602 deletesByDomain := endpointsByZone(zoneNameIDMapper, planChanges.Delete) 603 604 var changes digitalOceanChanges 605 606 if err := processCreateActions(recordsByDomain, createsByDomain, &changes); err != nil { 607 return err 608 } 609 610 if err := processUpdateActions(recordsByDomain, updatesByDomain, &changes); err != nil { 611 return err 612 } 613 614 if err := processDeleteActions(recordsByDomain, deletesByDomain, &changes); err != nil { 615 return err 616 } 617 618 return p.submitChanges(ctx, &changes) 619 }