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

     1  /*
     2  Copyright 2020 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 vultr
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"os"
    23  	"strings"
    24  
    25  	log "github.com/sirupsen/logrus"
    26  	"github.com/vultr/govultr/v2"
    27  	"golang.org/x/oauth2"
    28  
    29  	"sigs.k8s.io/external-dns/endpoint"
    30  	"sigs.k8s.io/external-dns/plan"
    31  	"sigs.k8s.io/external-dns/provider"
    32  )
    33  
    34  const (
    35  	vultrCreate = "CREATE"
    36  	vultrDelete = "DELETE"
    37  	vultrUpdate = "UPDATE"
    38  	vultrTTL    = 3600
    39  )
    40  
    41  // VultrProvider is an implementation of Provider for Vultr DNS.
    42  type VultrProvider struct {
    43  	provider.BaseProvider
    44  	client govultr.Client
    45  
    46  	domainFilter endpoint.DomainFilter
    47  	DryRun       bool
    48  }
    49  
    50  // VultrChanges differentiates between ChangActions.
    51  type VultrChanges struct {
    52  	Action string
    53  
    54  	ResourceRecordSet *govultr.DomainRecordReq
    55  }
    56  
    57  // NewVultrProvider initializes a new Vultr BNS based provider
    58  func NewVultrProvider(ctx context.Context, domainFilter endpoint.DomainFilter, dryRun bool) (*VultrProvider, error) {
    59  	apiKey, ok := os.LookupEnv("VULTR_API_KEY")
    60  	if !ok {
    61  		return nil, fmt.Errorf("no token found")
    62  	}
    63  
    64  	oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
    65  		AccessToken: apiKey,
    66  	}))
    67  	client := govultr.NewClient(oauthClient)
    68  	client.SetUserAgent(fmt.Sprintf("ExternalDNS/%s", client.UserAgent))
    69  
    70  	p := &VultrProvider{
    71  		client:       *client,
    72  		domainFilter: domainFilter,
    73  		DryRun:       dryRun,
    74  	}
    75  
    76  	return p, nil
    77  }
    78  
    79  // Zones returns list of hosted zones
    80  func (p *VultrProvider) Zones(ctx context.Context) ([]govultr.Domain, error) {
    81  	zones, err := p.fetchZones(ctx)
    82  	if err != nil {
    83  		return nil, err
    84  	}
    85  
    86  	return zones, nil
    87  }
    88  
    89  // Records returns the list of records.
    90  func (p *VultrProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
    91  	zones, err := p.Zones(ctx)
    92  	if err != nil {
    93  		return nil, err
    94  	}
    95  
    96  	var endpoints []*endpoint.Endpoint
    97  
    98  	for _, zone := range zones {
    99  		records, err := p.fetchRecords(ctx, zone.Domain)
   100  		if err != nil {
   101  			return nil, err
   102  		}
   103  
   104  		for _, r := range records {
   105  			if provider.SupportedRecordType(r.Type) {
   106  				name := fmt.Sprintf("%s.%s", r.Name, zone.Domain)
   107  
   108  				// root name is identified by the empty string and should be
   109  				// translated to zone name for the endpoint entry.
   110  				if r.Name == "" {
   111  					name = zone.Domain
   112  				}
   113  
   114  				endpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, r.Type, endpoint.TTL(r.TTL), r.Data))
   115  			}
   116  		}
   117  	}
   118  
   119  	return endpoints, nil
   120  }
   121  
   122  func (p *VultrProvider) fetchRecords(ctx context.Context, domain string) ([]govultr.DomainRecord, error) {
   123  	var allRecords []govultr.DomainRecord
   124  	listOptions := &govultr.ListOptions{}
   125  
   126  	for {
   127  		records, meta, err := p.client.DomainRecord.List(ctx, domain, listOptions)
   128  		if err != nil {
   129  			return nil, err
   130  		}
   131  
   132  		allRecords = append(allRecords, records...)
   133  
   134  		if meta.Links.Next == "" {
   135  			break
   136  		} else {
   137  			listOptions.Cursor = meta.Links.Next
   138  			continue
   139  		}
   140  	}
   141  
   142  	return allRecords, nil
   143  }
   144  
   145  func (p *VultrProvider) fetchZones(ctx context.Context) ([]govultr.Domain, error) {
   146  	var zones []govultr.Domain
   147  	listOptions := &govultr.ListOptions{}
   148  
   149  	for {
   150  		allZones, meta, err := p.client.Domain.List(ctx, listOptions)
   151  		if err != nil {
   152  			return nil, err
   153  		}
   154  
   155  		for _, zone := range allZones {
   156  			if p.domainFilter.Match(zone.Domain) {
   157  				zones = append(zones, zone)
   158  			}
   159  		}
   160  
   161  		if meta.Links.Next == "" {
   162  			break
   163  		} else {
   164  			listOptions.Cursor = meta.Links.Next
   165  			continue
   166  		}
   167  	}
   168  
   169  	return zones, nil
   170  }
   171  
   172  func (p *VultrProvider) submitChanges(ctx context.Context, changes []*VultrChanges) error {
   173  	if len(changes) == 0 {
   174  		log.Infof("All records are already up to date")
   175  		return nil
   176  	}
   177  
   178  	zones, err := p.Zones(ctx)
   179  	if err != nil {
   180  		return err
   181  	}
   182  
   183  	zoneChanges := separateChangesByZone(zones, changes)
   184  
   185  	for zoneName, changes := range zoneChanges {
   186  		for _, change := range changes {
   187  			log.WithFields(log.Fields{
   188  				"record": change.ResourceRecordSet.Name,
   189  				"type":   change.ResourceRecordSet.Type,
   190  				"ttl":    change.ResourceRecordSet.TTL,
   191  				"action": change.Action,
   192  				"zone":   zoneName,
   193  			}).Info("Changing record.")
   194  
   195  			switch change.Action {
   196  			case vultrCreate:
   197  				if _, err := p.client.DomainRecord.Create(ctx, zoneName, change.ResourceRecordSet); err != nil {
   198  					return err
   199  				}
   200  			case vultrDelete:
   201  				id, err := p.getRecordID(ctx, zoneName, change.ResourceRecordSet)
   202  				if err != nil {
   203  					return err
   204  				}
   205  
   206  				if err := p.client.DomainRecord.Delete(ctx, zoneName, id); err != nil {
   207  					return err
   208  				}
   209  			case vultrUpdate:
   210  				id, err := p.getRecordID(ctx, zoneName, change.ResourceRecordSet)
   211  				if err != nil {
   212  					return err
   213  				}
   214  				if err := p.client.DomainRecord.Update(ctx, zoneName, id, change.ResourceRecordSet); err != nil {
   215  					return err
   216  				}
   217  			}
   218  		}
   219  	}
   220  	return nil
   221  }
   222  
   223  // ApplyChanges applies a given set of changes in a given zone.
   224  func (p *VultrProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   225  	combinedChanges := make([]*VultrChanges, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete))
   226  
   227  	combinedChanges = append(combinedChanges, newVultrChanges(vultrCreate, changes.Create)...)
   228  	combinedChanges = append(combinedChanges, newVultrChanges(vultrUpdate, changes.UpdateNew)...)
   229  	combinedChanges = append(combinedChanges, newVultrChanges(vultrDelete, changes.Delete)...)
   230  
   231  	return p.submitChanges(ctx, combinedChanges)
   232  }
   233  
   234  func newVultrChanges(action string, endpoints []*endpoint.Endpoint) []*VultrChanges {
   235  	changes := make([]*VultrChanges, 0, len(endpoints))
   236  	ttl := vultrTTL
   237  	for _, e := range endpoints {
   238  		if e.RecordTTL.IsConfigured() {
   239  			ttl = int(e.RecordTTL)
   240  		}
   241  
   242  		change := &VultrChanges{
   243  			Action: action,
   244  			ResourceRecordSet: &govultr.DomainRecordReq{
   245  				Type: e.RecordType,
   246  				Name: e.DNSName,
   247  				Data: e.Targets[0],
   248  				TTL:  ttl,
   249  			},
   250  		}
   251  
   252  		changes = append(changes, change)
   253  	}
   254  	return changes
   255  }
   256  
   257  func separateChangesByZone(zones []govultr.Domain, changes []*VultrChanges) map[string][]*VultrChanges {
   258  	change := make(map[string][]*VultrChanges)
   259  	zoneNameID := provider.ZoneIDName{}
   260  
   261  	for _, z := range zones {
   262  		zoneNameID.Add(z.Domain, z.Domain)
   263  		change[z.Domain] = []*VultrChanges{}
   264  	}
   265  
   266  	for _, c := range changes {
   267  		zone, _ := zoneNameID.FindZone(c.ResourceRecordSet.Name)
   268  		if zone == "" {
   269  			log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", c.ResourceRecordSet.Name)
   270  			continue
   271  		}
   272  		change[zone] = append(change[zone], c)
   273  	}
   274  	return change
   275  }
   276  
   277  func (p *VultrProvider) getRecordID(ctx context.Context, zone string, record *govultr.DomainRecordReq) (recordID string, err error) {
   278  	listOptions := &govultr.ListOptions{}
   279  	for {
   280  		records, meta, err := p.client.DomainRecord.List(ctx, zone, listOptions)
   281  		if err != nil {
   282  			return "0", err
   283  		}
   284  
   285  		for _, r := range records {
   286  			strippedName := strings.TrimSuffix(record.Name, "."+zone)
   287  			if record.Name == zone {
   288  				strippedName = ""
   289  			}
   290  
   291  			if r.Name == strippedName && r.Type == record.Type {
   292  				return r.ID, nil
   293  			}
   294  		}
   295  		if meta.Links.Next == "" {
   296  			break
   297  		} else {
   298  			listOptions.Cursor = meta.Links.Next
   299  			continue
   300  		}
   301  	}
   302  
   303  	return "", fmt.Errorf("no record was found")
   304  }