sigs.k8s.io/external-dns@v0.14.1/provider/dyn/dyn.go (about)

     1  /*
     2  Copyright 2018 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 dyn
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"os"
    23  	"strconv"
    24  	"strings"
    25  	"time"
    26  
    27  	log "github.com/sirupsen/logrus"
    28  
    29  	"github.com/nesv/go-dynect/dynect"
    30  
    31  	"sigs.k8s.io/external-dns/endpoint"
    32  	"sigs.k8s.io/external-dns/plan"
    33  	"sigs.k8s.io/external-dns/provider"
    34  	dynsoap "sigs.k8s.io/external-dns/provider/dyn/soap"
    35  )
    36  
    37  const (
    38  	// 10 minutes default timeout if not configured using flags
    39  	dynDefaultTTL = 600
    40  
    41  	// when rate limit is hit retry up to 5 times after sleep 1m between retries
    42  	dynMaxRetriesOnErrRateLimited = 5
    43  
    44  	// two consecutive bad logins happen at least this many seconds apart
    45  	// While it is easy to get the username right, misconfiguring the password
    46  	// can get account blocked. Exit(1) is not a good solution
    47  	// as k8s will restart the pod and another login attempt will be made
    48  	badLoginMinIntervalSeconds = 30 * 60
    49  
    50  	// this prefix must be stripped from resource links before feeding them to dynect.Client.Do()
    51  	restAPIPrefix = "/REST/"
    52  )
    53  
    54  func unixNow() int64 {
    55  	return time.Now().Unix()
    56  }
    57  
    58  // DynConfig hold connection parameters to dyn.com and internal state
    59  type DynConfig struct {
    60  	DomainFilter  endpoint.DomainFilter
    61  	ZoneIDFilter  provider.ZoneIDFilter
    62  	DryRun        bool
    63  	CustomerName  string
    64  	Username      string
    65  	Password      string
    66  	MinTTLSeconds int
    67  	AppVersion    string
    68  	DynVersion    string
    69  }
    70  
    71  // ZoneSnapshot stores a single recordset for a zone for a single serial
    72  type ZoneSnapshot struct {
    73  	serials   map[string]int
    74  	endpoints map[string][]*endpoint.Endpoint
    75  }
    76  
    77  // GetRecordsForSerial retrieves from memory the last known recordset for the (zone, serial) tuple
    78  func (snap *ZoneSnapshot) GetRecordsForSerial(zone string, serial int) []*endpoint.Endpoint {
    79  	lastSerial, ok := snap.serials[zone]
    80  	if !ok {
    81  		// no mapping
    82  		return nil
    83  	}
    84  
    85  	if lastSerial != serial {
    86  		// outdated mapping
    87  		return nil
    88  	}
    89  
    90  	endpoints, ok := snap.endpoints[zone]
    91  	if !ok {
    92  		// probably a bug
    93  		return nil
    94  	}
    95  
    96  	return endpoints
    97  }
    98  
    99  // StoreRecordsForSerial associates a result set with a (zone, serial)
   100  func (snap *ZoneSnapshot) StoreRecordsForSerial(zone string, serial int, records []*endpoint.Endpoint) {
   101  	snap.serials[zone] = serial
   102  	snap.endpoints[zone] = records
   103  }
   104  
   105  // DynProvider is the actual interface impl.
   106  type dynProviderState struct {
   107  	provider.BaseProvider
   108  	DynConfig
   109  	LastLoginErrorTime int64
   110  
   111  	ZoneSnapshot *ZoneSnapshot
   112  }
   113  
   114  // ZoneChange is missing from dynect: https://help.dyn.com/get-zone-changeset-api/
   115  type ZoneChange struct {
   116  	ID     int              `json:"id"`
   117  	UserID int              `json:"user_id"`
   118  	Zone   string           `json:"zone"`
   119  	FQDN   string           `json:"FQDN"`
   120  	Serial int              `json:"serial"`
   121  	TTL    int              `json:"ttl"`
   122  	Type   string           `json:"rdata_type"`
   123  	RData  dynect.DataBlock `json:"rdata"`
   124  }
   125  
   126  // ZoneChangesResponse is missing from dynect: https://help.dyn.com/get-zone-changeset-api/
   127  type ZoneChangesResponse struct {
   128  	dynect.ResponseBlock
   129  	Data []ZoneChange `json:"data"`
   130  }
   131  
   132  // ZonePublishRequest is missing from dynect but the notes field is a nice place to let
   133  // external-dns report some internal info during commit
   134  type ZonePublishRequest struct {
   135  	Publish bool   `json:"publish"`
   136  	Notes   string `json:"notes"`
   137  }
   138  
   139  // ZonePublishResponse holds the status after publish
   140  type ZonePublishResponse struct {
   141  	dynect.ResponseBlock
   142  	Data map[string]interface{} `json:"data"`
   143  }
   144  
   145  // NewDynProvider initializes a new Dyn Provider.
   146  func NewDynProvider(config DynConfig) (provider.Provider, error) {
   147  	return &dynProviderState{
   148  		DynConfig: config,
   149  		ZoneSnapshot: &ZoneSnapshot{
   150  			endpoints: map[string][]*endpoint.Endpoint{},
   151  			serials:   map[string]int{},
   152  		},
   153  	}, nil
   154  }
   155  
   156  // filterAndFixLinks removes from `links` all the records we don't care about
   157  // and strops the /REST/ prefix
   158  func filterAndFixLinks(links []string, filter endpoint.DomainFilter) []string {
   159  	var result []string
   160  	for _, link := range links {
   161  		// link looks like /REST/CNAMERecord/acme.com/exchange.acme.com/349386875
   162  
   163  		// strip /REST/
   164  		link = strings.TrimPrefix(link, restAPIPrefix)
   165  
   166  		// simply ignore all record types we don't care about
   167  		if !strings.HasPrefix(link, endpoint.RecordTypeA) &&
   168  			!strings.HasPrefix(link, endpoint.RecordTypeCNAME) &&
   169  			!strings.HasPrefix(link, endpoint.RecordTypeTXT) {
   170  			continue
   171  		}
   172  
   173  		// strip ID suffix
   174  		domain := link[0:strings.LastIndexByte(link, '/')]
   175  		// strip zone prefix
   176  		domain = domain[strings.LastIndexByte(domain, '/')+1:]
   177  		if filter.Match(domain) {
   178  			result = append(result, link)
   179  		}
   180  	}
   181  
   182  	return result
   183  }
   184  
   185  func fixMissingTTL(ttl endpoint.TTL, minTTLSeconds int) string {
   186  	i := dynDefaultTTL
   187  	if ttl.IsConfigured() {
   188  		if int(ttl) < minTTLSeconds {
   189  			i = minTTLSeconds
   190  		} else {
   191  			i = int(ttl)
   192  		}
   193  	}
   194  
   195  	return strconv.Itoa(i)
   196  }
   197  
   198  // merge produces a single list of records that can be used as a replacement.
   199  // Dyn allows to replace all records with a single call
   200  // Invariant: the result contains only elements from the updateNew parameter
   201  func merge(updateOld, updateNew []*endpoint.Endpoint) []*endpoint.Endpoint {
   202  	findMatch := func(template *endpoint.Endpoint) *endpoint.Endpoint {
   203  		for _, new := range updateNew {
   204  			if template.DNSName == new.DNSName &&
   205  				template.RecordType == new.RecordType {
   206  				return new
   207  			}
   208  		}
   209  		return nil
   210  	}
   211  
   212  	var result []*endpoint.Endpoint
   213  	for _, old := range updateOld {
   214  		matchingNew := findMatch(old)
   215  		if matchingNew == nil {
   216  			// no match, shouldn't happen
   217  			continue
   218  		}
   219  
   220  		if !matchingNew.Targets.Same(old.Targets) {
   221  			// new target: always update, TTL will be overwritten too if necessary
   222  			result = append(result, matchingNew)
   223  			continue
   224  		}
   225  
   226  		if matchingNew.RecordTTL != 0 && matchingNew.RecordTTL != old.RecordTTL {
   227  			// same target, but new non-zero TTL set in k8s, must update
   228  			// probably would happen only if there is a bug in the code calling the provider
   229  			result = append(result, matchingNew)
   230  		}
   231  	}
   232  
   233  	return result
   234  }
   235  
   236  func apiRetryLoop(f func() error) error {
   237  	var err error
   238  	for i := 0; i < dynMaxRetriesOnErrRateLimited; i++ {
   239  		err = f()
   240  		if err == nil || err != dynect.ErrRateLimited {
   241  			// success or not retryable error
   242  			return err
   243  		}
   244  
   245  		// https://help.dyn.com/managed-dns-api-rate-limit/
   246  		log.Debugf("Rate limit has been hit, sleeping for 1m (%d/%d)", i, dynMaxRetriesOnErrRateLimited)
   247  		time.Sleep(1 * time.Minute)
   248  	}
   249  
   250  	return err
   251  }
   252  
   253  func (d *dynProviderState) allRecordsToEndpoints(records *dynsoap.GetAllRecordsResponseType) []*endpoint.Endpoint {
   254  	result := []*endpoint.Endpoint{}
   255  	// Convert each record to an endpoint
   256  
   257  	// Process A Records
   258  	for _, rec := range records.Data.A_records {
   259  		ep := &endpoint.Endpoint{
   260  			DNSName:    rec.Fqdn,
   261  			RecordTTL:  endpoint.TTL(rec.Ttl),
   262  			RecordType: rec.Record_type,
   263  			Targets:    endpoint.Targets{rec.Rdata.Address},
   264  		}
   265  		log.Debugf("A record: %v", *ep)
   266  		result = append(result, ep)
   267  	}
   268  
   269  	// Process CNAME Records
   270  	for _, rec := range records.Data.Cname_records {
   271  		ep := &endpoint.Endpoint{
   272  			DNSName:    rec.Fqdn,
   273  			RecordTTL:  endpoint.TTL(rec.Ttl),
   274  			RecordType: rec.Record_type,
   275  			Targets:    endpoint.Targets{strings.TrimSuffix(rec.Rdata.Cname, ".")},
   276  		}
   277  		log.Debugf("CNAME record: %v", *ep)
   278  		result = append(result, ep)
   279  	}
   280  
   281  	// Process TXT Records
   282  	for _, rec := range records.Data.Txt_records {
   283  		ep := &endpoint.Endpoint{
   284  			DNSName:    rec.Fqdn,
   285  			RecordTTL:  endpoint.TTL(rec.Ttl),
   286  			RecordType: rec.Record_type,
   287  			Targets:    endpoint.Targets{rec.Rdata.Txtdata},
   288  		}
   289  		log.Debugf("TXT record: %v", *ep)
   290  		result = append(result, ep)
   291  	}
   292  
   293  	return result
   294  }
   295  
   296  func errorOrValue(err error, value interface{}) interface{} {
   297  	if err == nil {
   298  		return value
   299  	}
   300  
   301  	return err
   302  }
   303  
   304  // endpointToRecord puts the Target of an Endpoint in the correct field of DataBlock.
   305  // See DataBlock comments for more info
   306  func endpointToRecord(ep *endpoint.Endpoint) *dynect.DataBlock {
   307  	result := dynect.DataBlock{}
   308  
   309  	if ep.RecordType == endpoint.RecordTypeA {
   310  		result.Address = ep.Targets[0]
   311  	} else if ep.RecordType == endpoint.RecordTypeCNAME {
   312  		result.CName = ep.Targets[0]
   313  	} else if ep.RecordType == endpoint.RecordTypeTXT {
   314  		result.TxtData = ep.Targets[0]
   315  	}
   316  
   317  	return &result
   318  }
   319  
   320  func (d *dynProviderState) fetchZoneSerial(client *dynect.Client, zone string) (int, error) {
   321  	var resp dynect.ZoneResponse
   322  
   323  	err := client.Do("GET", fmt.Sprintf("Zone/%s", zone), nil, &resp)
   324  	if err != nil {
   325  		return 0, err
   326  	}
   327  
   328  	return resp.Data.Serial, nil
   329  }
   330  
   331  // Use SOAP to fetch all records with a single call
   332  func (d *dynProviderState) fetchAllRecordsInZone(zone string) (*dynsoap.GetAllRecordsResponseType, error) {
   333  	var err error
   334  
   335  	service := dynsoap.NewDynectClient("https://api2.dynect.net/SOAP/")
   336  
   337  	sessionRequest := dynsoap.SessionLoginRequestType{
   338  		Customer_name:  d.CustomerName,
   339  		User_name:      d.Username,
   340  		Password:       d.Password,
   341  		Fault_incompat: 0,
   342  	}
   343  
   344  	var resp *dynsoap.SessionLoginResponseType
   345  
   346  	err = apiRetryLoop(func() error {
   347  		resp, err = service.SessionLogin(&sessionRequest)
   348  		return err
   349  	})
   350  
   351  	if err != nil {
   352  		return nil, err
   353  	}
   354  
   355  	token := resp.Data.Token
   356  
   357  	logoutRequest := &dynsoap.SessionLogoutRequestType{
   358  		Token:          token,
   359  		Fault_incompat: 0,
   360  	}
   361  
   362  	defer service.SessionLogout(logoutRequest)
   363  
   364  	req := dynsoap.GetAllRecordsRequestType{
   365  		Token:          token,
   366  		Zone:           zone,
   367  		Fault_incompat: 0,
   368  	}
   369  
   370  	records := &dynsoap.GetAllRecordsResponseType{}
   371  
   372  	err = apiRetryLoop(func() error {
   373  		records, err = service.GetAllRecords(&req)
   374  		return err
   375  	})
   376  
   377  	if err != nil {
   378  		return nil, err
   379  	}
   380  
   381  	log.Debugf("Got all Records, status is %s", records.Status)
   382  
   383  	if strings.ToLower(records.Status) == "incomplete" {
   384  		jobRequest := dynsoap.GetJobRequestType{
   385  			Token:          token,
   386  			Job_id:         records.Job_id,
   387  			Fault_incompat: 0,
   388  		}
   389  
   390  		jobResults := dynsoap.GetJobResponseType{}
   391  		err = apiRetryLoop(func() error {
   392  			jobResults, err := service.GetJob(&jobRequest)
   393  			if strings.ToLower(jobResults.Status) == "incomplete" {
   394  				return fmt.Errorf("job is incomplete")
   395  			}
   396  			return err
   397  		})
   398  
   399  		if err != nil {
   400  			return nil, err
   401  		}
   402  
   403  		return jobResults.Data.(*dynsoap.GetAllRecordsResponseType), nil
   404  	}
   405  
   406  	return records, nil
   407  }
   408  
   409  // buildLinkToRecord build a resource link. The symmetry of the dyn API is used to save
   410  // switch-case boilerplate.
   411  // Empty response means the endpoint is not mappable to a records link: either because the fqdn
   412  // is not matched by the domainFilter or it is in the wrong zone
   413  func (d *dynProviderState) buildLinkToRecord(ep *endpoint.Endpoint) string {
   414  	if ep == nil {
   415  		return ""
   416  	}
   417  	matchingZone := ""
   418  	for _, zone := range d.ZoneIDFilter.ZoneIDs {
   419  		if strings.HasSuffix(ep.DNSName, zone) {
   420  			matchingZone = zone
   421  			break
   422  		}
   423  	}
   424  
   425  	if matchingZone == "" {
   426  		// no matching zone, ignore
   427  		return ""
   428  	}
   429  
   430  	if !d.DomainFilter.Match(ep.DNSName) {
   431  		// no matching domain, ignore
   432  		return ""
   433  	}
   434  
   435  	return fmt.Sprintf("%sRecord/%s/%s/", ep.RecordType, matchingZone, ep.DNSName)
   436  }
   437  
   438  // create a dynect client and performs login. You need to clean it up.
   439  // This method also stores the DynAPI version.
   440  // Don't user the dynect.Client.Login()
   441  func (d *dynProviderState) login() (*dynect.Client, error) {
   442  	if d.LastLoginErrorTime != 0 {
   443  		secondsSinceLastError := unixNow() - d.LastLoginErrorTime
   444  		if secondsSinceLastError < badLoginMinIntervalSeconds {
   445  			return nil, fmt.Errorf("will not attempt an API call as the last login failure occurred just %ds ago", secondsSinceLastError)
   446  		}
   447  	}
   448  	client := dynect.NewClient(d.CustomerName)
   449  
   450  	req := dynect.LoginBlock{
   451  		Username:     d.Username,
   452  		Password:     d.Password,
   453  		CustomerName: d.CustomerName,
   454  	}
   455  
   456  	var resp dynect.LoginResponse
   457  
   458  	err := client.Do("POST", "Session", req, &resp)
   459  	if err != nil {
   460  		d.LastLoginErrorTime = unixNow()
   461  		return nil, err
   462  	}
   463  
   464  	d.LastLoginErrorTime = 0
   465  	client.Token = resp.Data.Token
   466  
   467  	// this is the only change from the original
   468  	d.DynVersion = resp.Data.Version
   469  	return client, nil
   470  }
   471  
   472  // the zones we are allowed to touch. Currently only exact matches are considered, not all
   473  // zones with the given suffix
   474  func (d *dynProviderState) zones(client *dynect.Client) []string {
   475  	return d.ZoneIDFilter.ZoneIDs
   476  }
   477  
   478  func (d *dynProviderState) buildRecordRequest(ep *endpoint.Endpoint) (string, *dynect.RecordRequest) {
   479  	link := d.buildLinkToRecord(ep)
   480  	if link == "" {
   481  		return "", nil
   482  	}
   483  
   484  	record := dynect.RecordRequest{
   485  		TTL:   fixMissingTTL(ep.RecordTTL, d.MinTTLSeconds),
   486  		RData: *endpointToRecord(ep),
   487  	}
   488  	return link, &record
   489  }
   490  
   491  // deleteRecord deletes all existing records (CNAME, TXT, A) for the given Endpoint.DNSName with 1 API call
   492  func (d *dynProviderState) deleteRecord(client *dynect.Client, ep *endpoint.Endpoint) error {
   493  	link := d.buildLinkToRecord(ep)
   494  	if link == "" {
   495  		return nil
   496  	}
   497  
   498  	response := dynect.RecordResponse{}
   499  
   500  	err := apiRetryLoop(func() error {
   501  		return client.Do("DELETE", link, nil, &response)
   502  	})
   503  
   504  	log.Debugf("Deleting record %s: %+v,", link, errorOrValue(err, &response))
   505  	return err
   506  }
   507  
   508  // replaceRecord replaces all existing records pf the given type for the Endpoint.DNSName with 1 API call
   509  func (d *dynProviderState) replaceRecord(client *dynect.Client, ep *endpoint.Endpoint) error {
   510  	link, record := d.buildRecordRequest(ep)
   511  	if link == "" {
   512  		return nil
   513  	}
   514  
   515  	response := dynect.RecordResponse{}
   516  	err := apiRetryLoop(func() error {
   517  		return client.Do("PUT", link, record, &response)
   518  	})
   519  
   520  	log.Debugf("Replacing record %s: %+v,", link, errorOrValue(err, &response))
   521  	return err
   522  }
   523  
   524  // createRecord creates a single record with 1 API call
   525  func (d *dynProviderState) createRecord(client *dynect.Client, ep *endpoint.Endpoint) error {
   526  	link, record := d.buildRecordRequest(ep)
   527  	if link == "" {
   528  		return nil
   529  	}
   530  
   531  	response := dynect.RecordResponse{}
   532  	err := apiRetryLoop(func() error {
   533  		return client.Do("POST", link, record, &response)
   534  	})
   535  
   536  	log.Debugf("Creating record %s: %+v,", link, errorOrValue(err, &response))
   537  	return err
   538  }
   539  
   540  // commit commits all pending changes. It will always attempt to commit, if there are no
   541  func (d *dynProviderState) commit(client *dynect.Client) error {
   542  	errs := []error{}
   543  
   544  	for _, zone := range d.zones(client) {
   545  		// extra call if in debug mode to fetch pending changes
   546  		if log.GetLevel() >= log.DebugLevel {
   547  			response := ZoneChangesResponse{}
   548  			err := client.Do("GET", fmt.Sprintf("ZoneChanges/%s/", zone), nil, &response)
   549  			log.Debugf("Pending changes for zone %s: %+v", zone, errorOrValue(err, &response))
   550  		}
   551  
   552  		h, err := os.Hostname()
   553  		if err != nil {
   554  			h = "unknown-host"
   555  		}
   556  		notes := fmt.Sprintf("Change by external-dns@%s, DynAPI@%s, %s on %s",
   557  			d.AppVersion,
   558  			d.DynVersion,
   559  			time.Now().Format(time.RFC3339),
   560  			h,
   561  		)
   562  
   563  		zonePublish := ZonePublishRequest{
   564  			Publish: true,
   565  			Notes:   notes,
   566  		}
   567  
   568  		response := ZonePublishResponse{}
   569  
   570  		// always retry the commit: don't waste the good work so far
   571  		err = apiRetryLoop(func() error {
   572  			return client.Do("PUT", fmt.Sprintf("Zone/%s/", zone), &zonePublish, &response)
   573  		})
   574  		log.Infof("Committing changes for zone %s: %+v", zone, errorOrValue(err, &response))
   575  	}
   576  
   577  	switch len(errs) {
   578  	case 0:
   579  		return nil
   580  	case 1:
   581  		return errs[0]
   582  	default:
   583  		return fmt.Errorf("multiple errors committing: %+v", errs)
   584  	}
   585  }
   586  
   587  // Records makes on average C + 2*Z  requests (Z = number of zones): 1 login + 1 fetchAllRecords
   588  // A cache is used to avoid querying for every single record found. C is proportional to the number
   589  // of expired/changed records
   590  func (d *dynProviderState) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
   591  	client, err := d.login()
   592  	if err != nil {
   593  		return nil, err
   594  	}
   595  	defer client.Logout()
   596  
   597  	log.Debugf("Using DynAPI@%s", d.DynVersion)
   598  
   599  	var result []*endpoint.Endpoint
   600  
   601  	zones := d.zones(client)
   602  	log.Infof("Configured zones: %+v", zones)
   603  	for _, zone := range zones {
   604  		serial, err := d.fetchZoneSerial(client, zone)
   605  		if err != nil {
   606  			if strings.Contains(err.Error(), "404 Not Found") {
   607  				log.Infof("Ignore zone %s as it does not exist", zone)
   608  				continue
   609  			}
   610  
   611  			return nil, err
   612  		}
   613  
   614  		relevantRecords := d.ZoneSnapshot.GetRecordsForSerial(zone, serial)
   615  		if relevantRecords != nil {
   616  			log.Infof("Using %d cached records for zone %s@%d", len(relevantRecords), zone, serial)
   617  			result = append(result, relevantRecords...)
   618  			continue
   619  		}
   620  
   621  		// Fetch All Records
   622  		records, err := d.fetchAllRecordsInZone(zone)
   623  		if err != nil {
   624  			return nil, err
   625  		}
   626  		relevantRecords = d.allRecordsToEndpoints(records)
   627  
   628  		log.Debugf("Relevant records %+v", relevantRecords)
   629  
   630  		d.ZoneSnapshot.StoreRecordsForSerial(zone, serial, relevantRecords)
   631  		log.Infof("Stored %d records for %s@%d", len(relevantRecords), zone, serial)
   632  		result = append(result, relevantRecords...)
   633  	}
   634  
   635  	return result, nil
   636  }
   637  
   638  // this method does C + 2*Z requests: C=total number of changes, Z = number of
   639  // affected zones (1 login + 1 commit)
   640  func (d *dynProviderState) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   641  	log.Debugf("Processing changes: %+v", changes)
   642  
   643  	if d.DryRun {
   644  		log.Infof("Will NOT delete these records: %+v", changes.Delete)
   645  		log.Infof("Will NOT create these records: %+v", changes.Create)
   646  		log.Infof("Will NOT update these records: %+v", merge(changes.UpdateOld, changes.UpdateNew))
   647  		return nil
   648  	}
   649  
   650  	client, err := d.login()
   651  	if err != nil {
   652  		return err
   653  	}
   654  	defer client.Logout()
   655  
   656  	var errs []error
   657  
   658  	needsCommit := false
   659  
   660  	for _, ep := range changes.Delete {
   661  		err := d.deleteRecord(client, ep)
   662  		if err != nil {
   663  			errs = append(errs, err)
   664  		} else {
   665  			needsCommit = true
   666  		}
   667  	}
   668  
   669  	for _, ep := range changes.Create {
   670  		err := d.createRecord(client, ep)
   671  		if err != nil {
   672  			errs = append(errs, err)
   673  		} else {
   674  			needsCommit = true
   675  		}
   676  	}
   677  
   678  	updates := merge(changes.UpdateOld, changes.UpdateNew)
   679  	log.Debugf("Updates after merging: %+v", updates)
   680  	for _, ep := range updates {
   681  		err := d.replaceRecord(client, ep)
   682  		if err != nil {
   683  			errs = append(errs, err)
   684  		} else {
   685  			needsCommit = true
   686  		}
   687  	}
   688  
   689  	switch len(errs) {
   690  	case 0:
   691  	case 1:
   692  		return errs[0]
   693  	default:
   694  		return fmt.Errorf("multiple errors committing: %+v", errs)
   695  	}
   696  
   697  	if needsCommit {
   698  		return d.commit(client)
   699  	}
   700  
   701  	return nil
   702  }