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