sigs.k8s.io/external-dns@v0.14.1/provider/dnsimple/dnsimple.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 dnsimple
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"os"
    23  	"strconv"
    24  	"strings"
    25  
    26  	"github.com/dnsimple/dnsimple-go/dnsimple"
    27  	log "github.com/sirupsen/logrus"
    28  	"golang.org/x/oauth2"
    29  
    30  	"sigs.k8s.io/external-dns/endpoint"
    31  	"sigs.k8s.io/external-dns/pkg/apis/externaldns"
    32  	"sigs.k8s.io/external-dns/plan"
    33  	"sigs.k8s.io/external-dns/provider"
    34  )
    35  
    36  const dnsimpleRecordTTL = 3600 // Default TTL of 1 hour if not set (DNSimple's default)
    37  
    38  type dnsimpleIdentityService struct {
    39  	service *dnsimple.IdentityService
    40  }
    41  
    42  func (i dnsimpleIdentityService) Whoami(ctx context.Context) (*dnsimple.WhoamiResponse, error) {
    43  	return i.service.Whoami(ctx)
    44  }
    45  
    46  // dnsimpleZoneServiceInterface is an interface that contains all necessary zone services from DNSimple
    47  type dnsimpleZoneServiceInterface interface {
    48  	ListZones(ctx context.Context, accountID string, options *dnsimple.ZoneListOptions) (*dnsimple.ZonesResponse, error)
    49  	ListRecords(ctx context.Context, accountID string, zoneID string, options *dnsimple.ZoneRecordListOptions) (*dnsimple.ZoneRecordsResponse, error)
    50  	CreateRecord(ctx context.Context, accountID string, zoneID string, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error)
    51  	DeleteRecord(ctx context.Context, accountID string, zoneID string, recordID int64) (*dnsimple.ZoneRecordResponse, error)
    52  	UpdateRecord(ctx context.Context, accountID string, zoneID string, recordID int64, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error)
    53  }
    54  
    55  type dnsimpleZoneService struct {
    56  	service *dnsimple.ZonesService
    57  }
    58  
    59  func (z dnsimpleZoneService) ListZones(ctx context.Context, accountID string, options *dnsimple.ZoneListOptions) (*dnsimple.ZonesResponse, error) {
    60  	return z.service.ListZones(ctx, accountID, options)
    61  }
    62  
    63  func (z dnsimpleZoneService) ListRecords(ctx context.Context, accountID string, zoneID string, options *dnsimple.ZoneRecordListOptions) (*dnsimple.ZoneRecordsResponse, error) {
    64  	return z.service.ListRecords(ctx, accountID, zoneID, options)
    65  }
    66  
    67  func (z dnsimpleZoneService) CreateRecord(ctx context.Context, accountID string, zoneID string, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) {
    68  	return z.service.CreateRecord(ctx, accountID, zoneID, recordAttributes)
    69  }
    70  
    71  func (z dnsimpleZoneService) DeleteRecord(ctx context.Context, accountID string, zoneID string, recordID int64) (*dnsimple.ZoneRecordResponse, error) {
    72  	return z.service.DeleteRecord(ctx, accountID, zoneID, recordID)
    73  }
    74  
    75  func (z dnsimpleZoneService) UpdateRecord(ctx context.Context, accountID string, zoneID string, recordID int64, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) {
    76  	return z.service.UpdateRecord(ctx, accountID, zoneID, recordID, recordAttributes)
    77  }
    78  
    79  type dnsimpleProvider struct {
    80  	provider.BaseProvider
    81  	client       dnsimpleZoneServiceInterface
    82  	identity     dnsimpleIdentityService
    83  	accountID    string
    84  	domainFilter endpoint.DomainFilter
    85  	zoneIDFilter provider.ZoneIDFilter
    86  	dryRun       bool
    87  }
    88  
    89  type dnsimpleChange struct {
    90  	Action            string
    91  	ResourceRecordSet dnsimple.ZoneRecord
    92  }
    93  
    94  const (
    95  	dnsimpleCreate = "CREATE"
    96  	dnsimpleDelete = "DELETE"
    97  	dnsimpleUpdate = "UPDATE"
    98  )
    99  
   100  // NewDnsimpleProvider initializes a new Dnsimple based provider
   101  func NewDnsimpleProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool) (provider.Provider, error) {
   102  	oauthToken := os.Getenv("DNSIMPLE_OAUTH")
   103  	if len(oauthToken) == 0 {
   104  		return nil, fmt.Errorf("no dnsimple oauth token provided")
   105  	}
   106  
   107  	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: oauthToken})
   108  	tc := oauth2.NewClient(context.Background(), ts)
   109  
   110  	client := dnsimple.NewClient(tc)
   111  	client.SetUserAgent(fmt.Sprintf("Kubernetes ExternalDNS/%s", externaldns.Version))
   112  
   113  	provider := &dnsimpleProvider{
   114  		client:       dnsimpleZoneService{service: client.Zones},
   115  		identity:     dnsimpleIdentityService{service: client.Identity},
   116  		domainFilter: domainFilter,
   117  		zoneIDFilter: zoneIDFilter,
   118  		dryRun:       dryRun,
   119  	}
   120  
   121  	whoamiResponse, err := provider.identity.Whoami(context.Background())
   122  	if err != nil {
   123  		return nil, err
   124  	}
   125  	provider.accountID = int64ToString(whoamiResponse.Data.Account.ID)
   126  	return provider, nil
   127  }
   128  
   129  // GetAccountID returns the account ID given DNSimple credentials.
   130  func (p *dnsimpleProvider) GetAccountID(ctx context.Context) (accountID string, err error) {
   131  	// get DNSimple client accountID
   132  	whoamiResponse, err := p.identity.Whoami(ctx)
   133  	if err != nil {
   134  		return "", err
   135  	}
   136  	return int64ToString(whoamiResponse.Data.Account.ID), nil
   137  }
   138  
   139  // Returns a list of filtered Zones
   140  func (p *dnsimpleProvider) Zones(ctx context.Context) (map[string]dnsimple.Zone, error) {
   141  	zones := make(map[string]dnsimple.Zone)
   142  	page := 1
   143  	listOptions := &dnsimple.ZoneListOptions{}
   144  	for {
   145  		listOptions.Page = &page
   146  		zonesResponse, err := p.client.ListZones(ctx, p.accountID, listOptions)
   147  		if err != nil {
   148  			return nil, err
   149  		}
   150  		for _, zone := range zonesResponse.Data {
   151  			if !p.domainFilter.Match(zone.Name) {
   152  				continue
   153  			}
   154  
   155  			if !p.zoneIDFilter.Match(int64ToString(zone.ID)) {
   156  				continue
   157  			}
   158  
   159  			zones[int64ToString(zone.ID)] = zone
   160  		}
   161  
   162  		page++
   163  		if page > zonesResponse.Pagination.TotalPages {
   164  			break
   165  		}
   166  	}
   167  	return zones, nil
   168  }
   169  
   170  // Records returns a list of endpoints in a given zone
   171  func (p *dnsimpleProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) {
   172  	zones, err := p.Zones(ctx)
   173  	if err != nil {
   174  		return nil, err
   175  	}
   176  	for _, zone := range zones {
   177  		page := 1
   178  		listOptions := &dnsimple.ZoneRecordListOptions{}
   179  		for {
   180  			listOptions.Page = &page
   181  			records, err := p.client.ListRecords(ctx, p.accountID, zone.Name, listOptions)
   182  			if err != nil {
   183  				return nil, err
   184  			}
   185  			for _, record := range records.Data {
   186  				switch record.Type {
   187  				case "A", "CNAME", "TXT":
   188  					break
   189  				default:
   190  					continue
   191  				}
   192  				// Apex records have an empty string for their name.
   193  				// Consider this when creating the endpoint dnsName
   194  				dnsName := fmt.Sprintf("%s.%s", record.Name, record.ZoneID)
   195  				if record.Name == "" {
   196  					dnsName = record.ZoneID
   197  				}
   198  				endpoints = append(endpoints, endpoint.NewEndpointWithTTL(dnsName, record.Type, endpoint.TTL(record.TTL), record.Content))
   199  			}
   200  			page++
   201  			if page > records.Pagination.TotalPages {
   202  				break
   203  			}
   204  		}
   205  	}
   206  	return endpoints, nil
   207  }
   208  
   209  // newDnsimpleChange initializes a new change to dns records
   210  func newDnsimpleChange(action string, e *endpoint.Endpoint) *dnsimpleChange {
   211  	ttl := dnsimpleRecordTTL
   212  	if e.RecordTTL.IsConfigured() {
   213  		ttl = int(e.RecordTTL)
   214  	}
   215  
   216  	change := &dnsimpleChange{
   217  		Action: action,
   218  		ResourceRecordSet: dnsimple.ZoneRecord{
   219  			Name:    e.DNSName,
   220  			Type:    e.RecordType,
   221  			Content: e.Targets[0],
   222  			TTL:     ttl,
   223  		},
   224  	}
   225  	return change
   226  }
   227  
   228  // newDnsimpleChanges returns a slice of changes based on given action and record
   229  func newDnsimpleChanges(action string, endpoints []*endpoint.Endpoint) []*dnsimpleChange {
   230  	changes := make([]*dnsimpleChange, 0, len(endpoints))
   231  	for _, e := range endpoints {
   232  		changes = append(changes, newDnsimpleChange(action, e))
   233  	}
   234  	return changes
   235  }
   236  
   237  // submitChanges takes a zone and a collection of changes and makes all changes from the collection
   238  func (p *dnsimpleProvider) submitChanges(ctx context.Context, changes []*dnsimpleChange) error {
   239  	if len(changes) == 0 {
   240  		log.Infof("All records are already up to date")
   241  		return nil
   242  	}
   243  	zones, err := p.Zones(ctx)
   244  	if err != nil {
   245  		return err
   246  	}
   247  	for _, change := range changes {
   248  		zone := dnsimpleSuitableZone(change.ResourceRecordSet.Name, zones)
   249  		if zone == nil {
   250  			log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", change.ResourceRecordSet.Name)
   251  			continue
   252  		}
   253  
   254  		log.Infof("Changing records: %s %v in zone: %s", change.Action, change.ResourceRecordSet, zone.Name)
   255  
   256  		if change.ResourceRecordSet.Name == zone.Name {
   257  			change.ResourceRecordSet.Name = "" // Apex records have an empty name
   258  		} else {
   259  			change.ResourceRecordSet.Name = strings.TrimSuffix(change.ResourceRecordSet.Name, fmt.Sprintf(".%s", zone.Name))
   260  		}
   261  
   262  		recordAttributes := dnsimple.ZoneRecordAttributes{
   263  			Name:    &change.ResourceRecordSet.Name,
   264  			Type:    change.ResourceRecordSet.Type,
   265  			Content: change.ResourceRecordSet.Content,
   266  			TTL:     change.ResourceRecordSet.TTL,
   267  		}
   268  
   269  		if !p.dryRun {
   270  			switch change.Action {
   271  			case dnsimpleCreate:
   272  				_, err := p.client.CreateRecord(ctx, p.accountID, zone.Name, recordAttributes)
   273  				if err != nil {
   274  					return err
   275  				}
   276  			case dnsimpleDelete:
   277  				recordID, err := p.GetRecordID(ctx, zone.Name, *recordAttributes.Name)
   278  				if err != nil {
   279  					return err
   280  				}
   281  				_, err = p.client.DeleteRecord(ctx, p.accountID, zone.Name, recordID)
   282  				if err != nil {
   283  					return err
   284  				}
   285  			case dnsimpleUpdate:
   286  				recordID, err := p.GetRecordID(ctx, zone.Name, *recordAttributes.Name)
   287  				if err != nil {
   288  					return err
   289  				}
   290  				_, err = p.client.UpdateRecord(ctx, p.accountID, zone.Name, recordID, recordAttributes)
   291  				if err != nil {
   292  					return err
   293  				}
   294  			}
   295  		}
   296  	}
   297  	return nil
   298  }
   299  
   300  // GetRecordID returns the record ID for a given record name and zone.
   301  func (p *dnsimpleProvider) GetRecordID(ctx context.Context, zone string, recordName string) (recordID int64, err error) {
   302  	page := 1
   303  	listOptions := &dnsimple.ZoneRecordListOptions{Name: &recordName}
   304  	for {
   305  		listOptions.Page = &page
   306  		records, err := p.client.ListRecords(ctx, p.accountID, zone, listOptions)
   307  		if err != nil {
   308  			return 0, err
   309  		}
   310  
   311  		for _, record := range records.Data {
   312  			if record.Name == recordName {
   313  				return record.ID, nil
   314  			}
   315  		}
   316  
   317  		page++
   318  		if page > records.Pagination.TotalPages {
   319  			break
   320  		}
   321  	}
   322  	return 0, fmt.Errorf("no record id found")
   323  }
   324  
   325  // dnsimpleSuitableZone returns the most suitable zone for a given hostname and a set of zones.
   326  func dnsimpleSuitableZone(hostname string, zones map[string]dnsimple.Zone) *dnsimple.Zone {
   327  	var zone *dnsimple.Zone
   328  	for _, z := range zones {
   329  		if strings.HasSuffix(hostname, z.Name) {
   330  			if zone == nil || len(z.Name) > len(zone.Name) {
   331  				newZ := z
   332  				zone = &newZ
   333  			}
   334  		}
   335  	}
   336  	return zone
   337  }
   338  
   339  // ApplyChanges applies a given set of changes
   340  func (p *dnsimpleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   341  	combinedChanges := make([]*dnsimpleChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete))
   342  
   343  	combinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleCreate, changes.Create)...)
   344  	combinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleUpdate, changes.UpdateNew)...)
   345  	combinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleDelete, changes.Delete)...)
   346  
   347  	return p.submitChanges(ctx, combinedChanges)
   348  }
   349  
   350  func int64ToString(i int64) string {
   351  	return strconv.FormatInt(i, 10)
   352  }