sigs.k8s.io/external-dns@v0.14.1/provider/civo/civo.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 civo 18 19 import ( 20 "context" 21 "fmt" 22 "os" 23 "strings" 24 25 "github.com/civo/civogo" 26 log "github.com/sirupsen/logrus" 27 28 "sigs.k8s.io/external-dns/endpoint" 29 "sigs.k8s.io/external-dns/pkg/apis/externaldns" 30 "sigs.k8s.io/external-dns/plan" 31 "sigs.k8s.io/external-dns/provider" 32 ) 33 34 // CivoProvider is an implementation of Provider for Civo's DNS. 35 type CivoProvider struct { 36 provider.BaseProvider 37 Client civogo.Client 38 domainFilter endpoint.DomainFilter 39 DryRun bool 40 } 41 42 // CivoChanges All API calls calculated from the plan 43 type CivoChanges struct { 44 Creates []*CivoChangeCreate 45 Deletes []*CivoChangeDelete 46 Updates []*CivoChangeUpdate 47 } 48 49 // Empty returns true if there are no changes 50 func (c *CivoChanges) Empty() bool { 51 return len(c.Creates) == 0 && len(c.Updates) == 0 && len(c.Deletes) == 0 52 } 53 54 // CivoChangeCreate Civo Domain Record Creates 55 type CivoChangeCreate struct { 56 Domain civogo.DNSDomain 57 Options *civogo.DNSRecordConfig 58 } 59 60 // CivoChangeUpdate Civo Domain Record Updates 61 type CivoChangeUpdate struct { 62 Domain civogo.DNSDomain 63 DomainRecord civogo.DNSRecord 64 Options civogo.DNSRecordConfig 65 } 66 67 // CivoChangeDelete Civo Domain Record Deletes 68 type CivoChangeDelete struct { 69 Domain civogo.DNSDomain 70 DomainRecord civogo.DNSRecord 71 } 72 73 // NewCivoProvider initializes a new Civo DNS based Provider. 74 func NewCivoProvider(domainFilter endpoint.DomainFilter, dryRun bool) (*CivoProvider, error) { 75 token, ok := os.LookupEnv("CIVO_TOKEN") 76 if !ok { 77 return nil, fmt.Errorf("no token found") 78 } 79 80 // Declare a default region just for the client is not used for anything else 81 // as the DNS API is global and not region based 82 region := "LON1" 83 84 civoClient, err := civogo.NewClient(token, region) 85 if err != nil { 86 return nil, err 87 } 88 89 userAgent := &civogo.Component{ 90 Name: "external-dns", 91 Version: externaldns.Version, 92 } 93 civoClient.SetUserAgent(userAgent) 94 95 provider := &CivoProvider{ 96 Client: *civoClient, 97 domainFilter: domainFilter, 98 DryRun: dryRun, 99 } 100 return provider, nil 101 } 102 103 // Zones returns the list of hosted zones. 104 func (p *CivoProvider) Zones(ctx context.Context) ([]civogo.DNSDomain, error) { 105 zones, err := p.fetchZones(ctx) 106 if err != nil { 107 return nil, err 108 } 109 110 return zones, nil 111 } 112 113 // Records returns the list of records in a given zone. 114 func (p *CivoProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { 115 zones, err := p.Zones(ctx) 116 if err != nil { 117 return nil, err 118 } 119 120 var endpoints []*endpoint.Endpoint 121 122 for _, zone := range zones { 123 records, err := p.fetchRecords(ctx, zone.ID) 124 if err != nil { 125 return nil, err 126 } 127 128 for _, r := range records { 129 toUpper := strings.ToUpper(string(r.Type)) 130 if provider.SupportedRecordType(toUpper) { 131 name := fmt.Sprintf("%s.%s", r.Name, zone.Name) 132 133 // root name is identified by the empty string and should be 134 // translated to zone name for the endpoint entry. 135 if r.Name == "" { 136 name = zone.Name 137 } 138 139 endpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, toUpper, endpoint.TTL(r.TTL), r.Value)) 140 } 141 } 142 } 143 144 return endpoints, nil 145 } 146 147 func (p *CivoProvider) fetchRecords(ctx context.Context, domainID string) ([]civogo.DNSRecord, error) { 148 records, err := p.Client.ListDNSRecords(domainID) 149 if err != nil { 150 return nil, err 151 } 152 153 return records, nil 154 } 155 156 func (p *CivoProvider) fetchZones(ctx context.Context) ([]civogo.DNSDomain, error) { 157 var zones []civogo.DNSDomain 158 159 allZones, err := p.Client.ListDNSDomains() 160 if err != nil { 161 return nil, err 162 } 163 164 for _, zone := range allZones { 165 if !p.domainFilter.Match(zone.Name) { 166 continue 167 } 168 169 zones = append(zones, zone) 170 } 171 172 return zones, nil 173 } 174 175 // submitChanges takes a zone and a collection of Changes and sends them as a single transaction. 176 func (p *CivoProvider) submitChanges(ctx context.Context, changes CivoChanges) error { 177 if changes.Empty() { 178 log.Info("All records are already up to date") 179 return nil 180 } 181 182 for _, change := range changes.Creates { 183 logFields := log.Fields{ 184 "Type": change.Options.Type, 185 "Name": change.Options.Name, 186 "Value": change.Options.Value, 187 "Priority": change.Options.Priority, 188 "TTL": change.Options.TTL, 189 "action": "Create", 190 } 191 192 log.WithFields(logFields).Info("Creating record.") 193 194 if p.DryRun { 195 log.WithFields(logFields).Info("Would create record.") 196 } else if _, err := p.Client.CreateDNSRecord(change.Domain.ID, change.Options); err != nil { 197 log.WithFields(logFields).Errorf( 198 "Failed to Create record: %v", 199 err, 200 ) 201 } 202 } 203 204 for _, change := range changes.Deletes { 205 logFields := log.Fields{ 206 "Type": change.DomainRecord.Type, 207 "Name": change.DomainRecord.Name, 208 "Value": change.DomainRecord.Value, 209 "Priority": change.DomainRecord.Priority, 210 "TTL": change.DomainRecord.TTL, 211 "action": "Delete", 212 } 213 214 log.WithFields(logFields).Info("Deleting record.") 215 216 if p.DryRun { 217 log.WithFields(logFields).Info("Would delete record.") 218 } else if _, err := p.Client.DeleteDNSRecord(&change.DomainRecord); err != nil { 219 log.WithFields(logFields).Errorf( 220 "Failed to Delete record: %v", 221 err, 222 ) 223 } 224 } 225 226 for _, change := range changes.Updates { 227 logFields := log.Fields{ 228 "Type": change.DomainRecord.Type, 229 "Name": change.DomainRecord.Name, 230 "Value": change.DomainRecord.Value, 231 "Priority": change.DomainRecord.Priority, 232 "TTL": change.DomainRecord.TTL, 233 "action": "Update", 234 } 235 236 log.WithFields(logFields).Info("Updating record.") 237 238 if p.DryRun { 239 log.WithFields(logFields).Info("Would update record.") 240 } else if _, err := p.Client.UpdateDNSRecord(&change.DomainRecord, &change.Options); err != nil { 241 log.WithFields(logFields).Errorf( 242 "Failed to Update record: %v", 243 err, 244 ) 245 } 246 } 247 248 return nil 249 } 250 251 // processCreateActions return a list of changes to create records. 252 func processCreateActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, createsByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error { 253 for zoneID, creates := range createsByZone { 254 zone := zonesByID[zoneID] 255 256 if len(creates) == 0 { 257 log.WithFields(log.Fields{ 258 "zoneID": zoneID, 259 "zoneName": zone.Name, 260 }).Info("Skipping Zone, no creates found.") 261 continue 262 } 263 264 records := recordsByZoneID[zoneID] 265 266 // Generate Create 267 for _, ep := range creates { 268 matchedRecords := getRecordID(records, zone, *ep) 269 270 if len(matchedRecords) != 0 { 271 log.WithFields(log.Fields{ 272 "zoneID": zoneID, 273 "zoneName": zone.Name, 274 "dnsName": ep.DNSName, 275 "recordType": ep.RecordType, 276 }).Warn("Records found which should not exist") 277 } 278 279 recordType, err := convertRecordType(ep.RecordType) 280 if err != nil { 281 return err 282 } 283 284 for _, target := range ep.Targets { 285 civoChange.Creates = append(civoChange.Creates, &CivoChangeCreate{ 286 Domain: zone, 287 Options: &civogo.DNSRecordConfig{ 288 Value: target, 289 Name: getStrippedRecordName(zone, *ep), 290 Type: recordType, 291 Priority: 0, 292 TTL: int(ep.RecordTTL), 293 }, 294 }) 295 } 296 } 297 } 298 299 return nil 300 } 301 302 // processUpdateActions return a list of changes to update records. 303 func processUpdateActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, updatesByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error { 304 for zoneID, updates := range updatesByZone { 305 zone := zonesByID[zoneID] 306 307 if len(updates) == 0 { 308 log.WithFields(log.Fields{ 309 "zoneID": zoneID, 310 "zoneName": zone.Name, 311 }).Debug("Skipping Zone, no updates found.") 312 continue 313 } 314 315 records := recordsByZoneID[zoneID] 316 317 for _, ep := range updates { 318 matchedRecords := getRecordID(records, zone, *ep) 319 if len(matchedRecords) == 0 { 320 log.WithFields(log.Fields{ 321 "zoneID": zoneID, 322 "dnsName": ep.DNSName, 323 "zoneName": zone.Name, 324 "recordType": ep.RecordType, 325 }).Warn("Update Records not found.") 326 } 327 328 recordType, err := convertRecordType(ep.RecordType) 329 if err != nil { 330 return err 331 } 332 333 matchedRecordsByTarget := make(map[string]civogo.DNSRecord) 334 for _, record := range matchedRecords { 335 matchedRecordsByTarget[record.Value] = record 336 } 337 338 for _, target := range ep.Targets { 339 if record, ok := matchedRecordsByTarget[target]; ok { 340 log.WithFields(log.Fields{ 341 "zoneID": zoneID, 342 "dnsName": ep.DNSName, 343 "zoneName": zone.Name, 344 "recordType": ep.RecordType, 345 "target": target, 346 }).Warn("Updating Existing Target") 347 348 civoChange.Updates = append(civoChange.Updates, &CivoChangeUpdate{ 349 Domain: zone, 350 DomainRecord: record, 351 Options: civogo.DNSRecordConfig{ 352 Value: target, 353 Name: getStrippedRecordName(zone, *ep), 354 Type: recordType, 355 Priority: 0, 356 TTL: int(ep.RecordTTL), 357 }, 358 }) 359 360 delete(matchedRecordsByTarget, target) 361 } else { 362 // Record did not previously exist, create new 'target' 363 log.WithFields(log.Fields{ 364 "zoneID": zoneID, 365 "dnsName": ep.DNSName, 366 "zoneName": zone.Name, 367 "recordType": ep.RecordType, 368 "target": target, 369 }).Warn("Creating New Target") 370 371 civoChange.Creates = append(civoChange.Creates, &CivoChangeCreate{ 372 Domain: zone, 373 Options: &civogo.DNSRecordConfig{ 374 Value: target, 375 Name: getStrippedRecordName(zone, *ep), 376 Type: recordType, 377 Priority: 0, 378 TTL: int(ep.RecordTTL), 379 }, 380 }) 381 } 382 } 383 384 // Any remaining records have been removed, delete them 385 for _, record := range matchedRecordsByTarget { 386 log.WithFields(log.Fields{ 387 "zoneID": zoneID, 388 "dnsName": ep.DNSName, 389 "recordType": ep.RecordType, 390 "target": record.Value, 391 }).Warn("Deleting target") 392 393 civoChange.Deletes = append(civoChange.Deletes, &CivoChangeDelete{ 394 Domain: zone, 395 DomainRecord: record, 396 }) 397 } 398 } 399 } 400 401 return nil 402 } 403 404 // processDeleteActions return a list of changes to delete records. 405 func processDeleteActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, deletesByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error { 406 for zoneID, deletes := range deletesByZone { 407 zone := zonesByID[zoneID] 408 409 if len(deletes) == 0 { 410 log.WithFields(log.Fields{ 411 "zoneID": zoneID, 412 "zoneName": zone.Name, 413 }).Debug("Skipping Zone, no deletes found.") 414 continue 415 } 416 417 records := recordsByZoneID[zoneID] 418 419 for _, ep := range deletes { 420 matchedRecords := getRecordID(records, zone, *ep) 421 422 if len(matchedRecords) == 0 { 423 log.WithFields(log.Fields{ 424 "zoneID": zoneID, 425 "dnsName": ep.DNSName, 426 "zoneName": zone.Name, 427 "recordType": ep.RecordType, 428 }).Warn("Records to Delete not found.") 429 } 430 431 for _, record := range matchedRecords { 432 civoChange.Deletes = append(civoChange.Deletes, &CivoChangeDelete{ 433 Domain: zone, 434 DomainRecord: record, 435 }) 436 } 437 } 438 } 439 return nil 440 } 441 442 // ApplyChanges applies a given set of changes in a given zone. 443 func (p *CivoProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { 444 var civoChange CivoChanges 445 recordsByZoneID := make(map[string][]civogo.DNSRecord) 446 447 zones, err := p.fetchZones(ctx) 448 449 if err != nil { 450 return err 451 } 452 453 zonesByID := make(map[string]civogo.DNSDomain) 454 455 zoneNameIDMapper := provider.ZoneIDName{} 456 457 for _, z := range zones { 458 zoneNameIDMapper.Add(z.ID, z.Name) 459 zonesByID[z.ID] = z 460 } 461 462 // Fetch records for each zone 463 for _, zone := range zones { 464 records, err := p.fetchRecords(ctx, zone.ID) 465 466 if err != nil { 467 return err 468 } 469 470 recordsByZoneID[zone.ID] = append(recordsByZoneID[zone.ID], records...) 471 } 472 473 createsByZone := endpointsByZone(zoneNameIDMapper, changes.Create) 474 updatesByZone := endpointsByZone(zoneNameIDMapper, changes.UpdateNew) 475 deletesByZone := endpointsByZone(zoneNameIDMapper, changes.Delete) 476 477 // Generate Creates 478 err = processCreateActions(zonesByID, recordsByZoneID, createsByZone, &civoChange) 479 if err != nil { 480 return err 481 } 482 483 // Generate Updates 484 err = processUpdateActions(zonesByID, recordsByZoneID, updatesByZone, &civoChange) 485 if err != nil { 486 return err 487 } 488 489 // Generate Deletes 490 err = processDeleteActions(zonesByID, recordsByZoneID, deletesByZone, &civoChange) 491 if err != nil { 492 return err 493 } 494 495 return p.submitChanges(ctx, civoChange) 496 } 497 498 func endpointsByZone(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint { 499 endpointsByZone := make(map[string][]*endpoint.Endpoint) 500 501 for _, ep := range endpoints { 502 zoneID, _ := zoneNameIDMapper.FindZone(ep.DNSName) 503 if zoneID == "" { 504 log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", ep.DNSName) 505 continue 506 } 507 endpointsByZone[zoneID] = append(endpointsByZone[zoneID], ep) 508 } 509 510 return endpointsByZone 511 } 512 513 func convertRecordType(recordType string) (civogo.DNSRecordType, error) { 514 switch recordType { 515 case "A": 516 return civogo.DNSRecordTypeA, nil 517 case "CNAME": 518 return civogo.DNSRecordTypeCName, nil 519 case "TXT": 520 return civogo.DNSRecordTypeTXT, nil 521 case "SRV": 522 return civogo.DNSRecordTypeSRV, nil 523 default: 524 return "", fmt.Errorf("invalid Record Type: %s", recordType) 525 } 526 } 527 528 func getStrippedRecordName(zone civogo.DNSDomain, ep endpoint.Endpoint) string { 529 if ep.DNSName == zone.Name { 530 return "" 531 } 532 533 return strings.TrimSuffix(ep.DNSName, "."+zone.Name) 534 } 535 536 func getRecordID(records []civogo.DNSRecord, zone civogo.DNSDomain, ep endpoint.Endpoint) []civogo.DNSRecord { 537 var matchedRecords []civogo.DNSRecord 538 539 for _, record := range records { 540 stripedName := getStrippedRecordName(zone, ep) 541 toUpper := strings.ToUpper(string(record.Type)) 542 if record.Name == stripedName && toUpper == ep.RecordType { 543 matchedRecords = append(matchedRecords, record) 544 } 545 } 546 547 return matchedRecords 548 }