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  }