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  }