sigs.k8s.io/external-dns@v0.14.1/provider/google/google.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 google
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"sort"
    23  	"strings"
    24  	"time"
    25  
    26  	"cloud.google.com/go/compute/metadata"
    27  	"github.com/linki/instrumented_http"
    28  	log "github.com/sirupsen/logrus"
    29  	"golang.org/x/oauth2/google"
    30  	dns "google.golang.org/api/dns/v1"
    31  	googleapi "google.golang.org/api/googleapi"
    32  	"google.golang.org/api/option"
    33  
    34  	"sigs.k8s.io/external-dns/endpoint"
    35  	"sigs.k8s.io/external-dns/plan"
    36  	"sigs.k8s.io/external-dns/provider"
    37  )
    38  
    39  const (
    40  	googleRecordTTL = 300
    41  )
    42  
    43  type managedZonesCreateCallInterface interface {
    44  	Do(opts ...googleapi.CallOption) (*dns.ManagedZone, error)
    45  }
    46  
    47  type managedZonesListCallInterface interface {
    48  	Pages(ctx context.Context, f func(*dns.ManagedZonesListResponse) error) error
    49  }
    50  
    51  type managedZonesServiceInterface interface {
    52  	Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface
    53  	List(project string) managedZonesListCallInterface
    54  }
    55  
    56  type resourceRecordSetsListCallInterface interface {
    57  	Pages(ctx context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error
    58  }
    59  
    60  type resourceRecordSetsClientInterface interface {
    61  	List(project string, managedZone string) resourceRecordSetsListCallInterface
    62  }
    63  
    64  type changesCreateCallInterface interface {
    65  	Do(opts ...googleapi.CallOption) (*dns.Change, error)
    66  }
    67  
    68  type changesServiceInterface interface {
    69  	Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface
    70  }
    71  
    72  type resourceRecordSetsService struct {
    73  	service *dns.ResourceRecordSetsService
    74  }
    75  
    76  func (r resourceRecordSetsService) List(project string, managedZone string) resourceRecordSetsListCallInterface {
    77  	return r.service.List(project, managedZone)
    78  }
    79  
    80  type managedZonesService struct {
    81  	service *dns.ManagedZonesService
    82  }
    83  
    84  func (m managedZonesService) Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface {
    85  	return m.service.Create(project, managedzone)
    86  }
    87  
    88  func (m managedZonesService) List(project string) managedZonesListCallInterface {
    89  	return m.service.List(project)
    90  }
    91  
    92  type changesService struct {
    93  	service *dns.ChangesService
    94  }
    95  
    96  func (c changesService) Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface {
    97  	return c.service.Create(project, managedZone, change)
    98  }
    99  
   100  // GoogleProvider is an implementation of Provider for Google CloudDNS.
   101  type GoogleProvider struct {
   102  	provider.BaseProvider
   103  	// The Google project to work in
   104  	project string
   105  	// Enabled dry-run will print any modifying actions rather than execute them.
   106  	dryRun bool
   107  	// Max batch size to submit to Google Cloud DNS per transaction.
   108  	batchChangeSize int
   109  	// Interval between batch updates.
   110  	batchChangeInterval time.Duration
   111  	// only consider hosted zones managing domains ending in this suffix
   112  	domainFilter endpoint.DomainFilter
   113  	// filter for zones based on visibility
   114  	zoneTypeFilter provider.ZoneTypeFilter
   115  	// only consider hosted zones ending with this zone id
   116  	zoneIDFilter provider.ZoneIDFilter
   117  	// A client for managing resource record sets
   118  	resourceRecordSetsClient resourceRecordSetsClientInterface
   119  	// A client for managing hosted zones
   120  	managedZonesClient managedZonesServiceInterface
   121  	// A client for managing change sets
   122  	changesClient changesServiceInterface
   123  	// The context parameter to be passed for gcloud API calls.
   124  	ctx context.Context
   125  }
   126  
   127  // NewGoogleProvider initializes a new Google CloudDNS based Provider.
   128  func NewGoogleProvider(ctx context.Context, project string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, batchChangeSize int, batchChangeInterval time.Duration, zoneVisibility string, dryRun bool) (*GoogleProvider, error) {
   129  	gcloud, err := google.DefaultClient(ctx, dns.NdevClouddnsReadwriteScope)
   130  	if err != nil {
   131  		return nil, err
   132  	}
   133  
   134  	gcloud = instrumented_http.NewClient(gcloud, &instrumented_http.Callbacks{
   135  		PathProcessor: func(path string) string {
   136  			parts := strings.Split(path, "/")
   137  			return parts[len(parts)-1]
   138  		},
   139  	})
   140  
   141  	dnsClient, err := dns.NewService(ctx, option.WithHTTPClient(gcloud))
   142  	if err != nil {
   143  		return nil, err
   144  	}
   145  
   146  	if project == "" {
   147  		mProject, mErr := metadata.ProjectID()
   148  		if mErr != nil {
   149  			return nil, fmt.Errorf("failed to auto-detect the project id: %w", mErr)
   150  		}
   151  		log.Infof("Google project auto-detected: %s", mProject)
   152  		project = mProject
   153  	}
   154  
   155  	zoneTypeFilter := provider.NewZoneTypeFilter(zoneVisibility)
   156  
   157  	provider := &GoogleProvider{
   158  		project:                  project,
   159  		dryRun:                   dryRun,
   160  		batchChangeSize:          batchChangeSize,
   161  		batchChangeInterval:      batchChangeInterval,
   162  		domainFilter:             domainFilter,
   163  		zoneTypeFilter:           zoneTypeFilter,
   164  		zoneIDFilter:             zoneIDFilter,
   165  		resourceRecordSetsClient: resourceRecordSetsService{dnsClient.ResourceRecordSets},
   166  		managedZonesClient:       managedZonesService{dnsClient.ManagedZones},
   167  		changesClient:            changesService{dnsClient.Changes},
   168  		ctx:                      ctx,
   169  	}
   170  
   171  	return provider, nil
   172  }
   173  
   174  // Zones returns the list of hosted zones.
   175  func (p *GoogleProvider) Zones(ctx context.Context) (map[string]*dns.ManagedZone, error) {
   176  	zones := make(map[string]*dns.ManagedZone)
   177  
   178  	f := func(resp *dns.ManagedZonesListResponse) error {
   179  		for _, zone := range resp.ManagedZones {
   180  			if zone.PeeringConfig == nil {
   181  				if p.domainFilter.Match(zone.DnsName) && p.zoneTypeFilter.Match(zone.Visibility) && (p.zoneIDFilter.Match(fmt.Sprintf("%v", zone.Id)) || p.zoneIDFilter.Match(fmt.Sprintf("%v", zone.Name))) {
   182  					zones[zone.Name] = zone
   183  					log.Debugf("Matched %s (zone: %s) (visibility: %s)", zone.DnsName, zone.Name, zone.Visibility)
   184  				} else {
   185  					log.Debugf("Filtered %s (zone: %s) (visibility: %s)", zone.DnsName, zone.Name, zone.Visibility)
   186  				}
   187  			} else {
   188  				log.Debugf("Filtered peering zone %s (zone: %s) (visibility: %s)", zone.DnsName, zone.Name, zone.Visibility)
   189  			}
   190  		}
   191  
   192  		return nil
   193  	}
   194  
   195  	log.Debugf("Matching zones against domain filters: %v", p.domainFilter)
   196  	if err := p.managedZonesClient.List(p.project).Pages(ctx, f); err != nil {
   197  		return nil, err
   198  	}
   199  
   200  	if len(zones) == 0 {
   201  		log.Warnf("No zones in the project, %s, match domain filters: %v", p.project, p.domainFilter)
   202  	}
   203  
   204  	for _, zone := range zones {
   205  		log.Debugf("Considering zone: %s (domain: %s)", zone.Name, zone.DnsName)
   206  	}
   207  
   208  	return zones, nil
   209  }
   210  
   211  // Records returns the list of records in all relevant zones.
   212  func (p *GoogleProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) {
   213  	zones, err := p.Zones(ctx)
   214  	if err != nil {
   215  		return nil, err
   216  	}
   217  
   218  	f := func(resp *dns.ResourceRecordSetsListResponse) error {
   219  		for _, r := range resp.Rrsets {
   220  			if !p.SupportedRecordType(r.Type) {
   221  				continue
   222  			}
   223  			endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.Ttl), r.Rrdatas...))
   224  		}
   225  
   226  		return nil
   227  	}
   228  
   229  	for _, z := range zones {
   230  		if err := p.resourceRecordSetsClient.List(p.project, z.Name).Pages(ctx, f); err != nil {
   231  			return nil, err
   232  		}
   233  	}
   234  
   235  	return endpoints, nil
   236  }
   237  
   238  // ApplyChanges applies a given set of changes in a given zone.
   239  func (p *GoogleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   240  	change := &dns.Change{}
   241  
   242  	change.Additions = append(change.Additions, p.newFilteredRecords(changes.Create)...)
   243  
   244  	change.Additions = append(change.Additions, p.newFilteredRecords(changes.UpdateNew)...)
   245  	change.Deletions = append(change.Deletions, p.newFilteredRecords(changes.UpdateOld)...)
   246  
   247  	change.Deletions = append(change.Deletions, p.newFilteredRecords(changes.Delete)...)
   248  
   249  	return p.submitChange(ctx, change)
   250  }
   251  
   252  // SupportedRecordType returns true if the record type is supported by the provider
   253  func (p *GoogleProvider) SupportedRecordType(recordType string) bool {
   254  	switch recordType {
   255  	case "MX":
   256  		return true
   257  	default:
   258  		return provider.SupportedRecordType(recordType)
   259  	}
   260  }
   261  
   262  // newFilteredRecords returns a collection of RecordSets based on the given endpoints and domainFilter.
   263  func (p *GoogleProvider) newFilteredRecords(endpoints []*endpoint.Endpoint) []*dns.ResourceRecordSet {
   264  	records := []*dns.ResourceRecordSet{}
   265  
   266  	for _, endpoint := range endpoints {
   267  		if p.domainFilter.Match(endpoint.DNSName) {
   268  			records = append(records, newRecord(endpoint))
   269  		}
   270  	}
   271  
   272  	return records
   273  }
   274  
   275  // submitChange takes a zone and a Change and sends it to Google.
   276  func (p *GoogleProvider) submitChange(ctx context.Context, change *dns.Change) error {
   277  	if len(change.Additions) == 0 && len(change.Deletions) == 0 {
   278  		log.Info("All records are already up to date")
   279  		return nil
   280  	}
   281  
   282  	zones, err := p.Zones(ctx)
   283  	if err != nil {
   284  		return err
   285  	}
   286  
   287  	// separate into per-zone change sets to be passed to the API.
   288  	changes := separateChange(zones, change)
   289  
   290  	for zone, change := range changes {
   291  		for batch, c := range batchChange(change, p.batchChangeSize) {
   292  			log.Infof("Change zone: %v batch #%d", zone, batch)
   293  			for _, del := range c.Deletions {
   294  				log.Infof("Del records: %s %s %s %d", del.Name, del.Type, del.Rrdatas, del.Ttl)
   295  			}
   296  			for _, add := range c.Additions {
   297  				log.Infof("Add records: %s %s %s %d", add.Name, add.Type, add.Rrdatas, add.Ttl)
   298  			}
   299  
   300  			if p.dryRun {
   301  				continue
   302  			}
   303  
   304  			if _, err := p.changesClient.Create(p.project, zone, c).Do(); err != nil {
   305  				return err
   306  			}
   307  
   308  			time.Sleep(p.batchChangeInterval)
   309  		}
   310  	}
   311  
   312  	return nil
   313  }
   314  
   315  // batchChange separates a zone in multiple transaction.
   316  func batchChange(change *dns.Change, batchSize int) []*dns.Change {
   317  	changes := []*dns.Change{}
   318  
   319  	if batchSize == 0 {
   320  		return append(changes, change)
   321  	}
   322  
   323  	type dnsChange struct {
   324  		additions []*dns.ResourceRecordSet
   325  		deletions []*dns.ResourceRecordSet
   326  	}
   327  
   328  	changesByName := map[string]*dnsChange{}
   329  
   330  	for _, a := range change.Additions {
   331  		change, ok := changesByName[a.Name]
   332  		if !ok {
   333  			change = &dnsChange{}
   334  			changesByName[a.Name] = change
   335  		}
   336  
   337  		change.additions = append(change.additions, a)
   338  	}
   339  
   340  	for _, a := range change.Deletions {
   341  		change, ok := changesByName[a.Name]
   342  		if !ok {
   343  			change = &dnsChange{}
   344  			changesByName[a.Name] = change
   345  		}
   346  
   347  		change.deletions = append(change.deletions, a)
   348  	}
   349  
   350  	names := make([]string, 0)
   351  	for v := range changesByName {
   352  		names = append(names, v)
   353  	}
   354  	sort.Strings(names)
   355  
   356  	currentChange := &dns.Change{}
   357  	var totalChanges int
   358  	for _, name := range names {
   359  		c := changesByName[name]
   360  
   361  		totalChangesByName := len(c.additions) + len(c.deletions)
   362  
   363  		if totalChangesByName > batchSize {
   364  			log.Warnf("Total changes for %s exceeds max batch size of %d, total changes: %d", name,
   365  				batchSize, totalChangesByName)
   366  			continue
   367  		}
   368  
   369  		if totalChanges+totalChangesByName > batchSize {
   370  			totalChanges = 0
   371  			changes = append(changes, currentChange)
   372  			currentChange = &dns.Change{}
   373  		}
   374  
   375  		currentChange.Additions = append(currentChange.Additions, c.additions...)
   376  		currentChange.Deletions = append(currentChange.Deletions, c.deletions...)
   377  
   378  		totalChanges += totalChangesByName
   379  	}
   380  
   381  	if totalChanges > 0 {
   382  		changes = append(changes, currentChange)
   383  	}
   384  
   385  	return changes
   386  }
   387  
   388  // separateChange separates a multi-zone change into a single change per zone.
   389  func separateChange(zones map[string]*dns.ManagedZone, change *dns.Change) map[string]*dns.Change {
   390  	changes := make(map[string]*dns.Change)
   391  	zoneNameIDMapper := provider.ZoneIDName{}
   392  	for _, z := range zones {
   393  		zoneNameIDMapper[z.Name] = z.DnsName
   394  		changes[z.Name] = &dns.Change{
   395  			Additions: []*dns.ResourceRecordSet{},
   396  			Deletions: []*dns.ResourceRecordSet{},
   397  		}
   398  	}
   399  	for _, a := range change.Additions {
   400  		if zoneName, _ := zoneNameIDMapper.FindZone(provider.EnsureTrailingDot(a.Name)); zoneName != "" {
   401  			changes[zoneName].Additions = append(changes[zoneName].Additions, a)
   402  		} else {
   403  			log.Warnf("No matching zone for record addition: %s %s %s %d", a.Name, a.Type, a.Rrdatas, a.Ttl)
   404  		}
   405  	}
   406  
   407  	for _, d := range change.Deletions {
   408  		if zoneName, _ := zoneNameIDMapper.FindZone(provider.EnsureTrailingDot(d.Name)); zoneName != "" {
   409  			changes[zoneName].Deletions = append(changes[zoneName].Deletions, d)
   410  		} else {
   411  			log.Warnf("No matching zone for record deletion: %s %s %s %d", d.Name, d.Type, d.Rrdatas, d.Ttl)
   412  		}
   413  	}
   414  
   415  	// separating a change could lead to empty sub changes, remove them here.
   416  	for zone, change := range changes {
   417  		if len(change.Additions) == 0 && len(change.Deletions) == 0 {
   418  			delete(changes, zone)
   419  		}
   420  	}
   421  
   422  	return changes
   423  }
   424  
   425  // newRecord returns a RecordSet based on the given endpoint.
   426  func newRecord(ep *endpoint.Endpoint) *dns.ResourceRecordSet {
   427  	// TODO(linki): works around appending a trailing dot to TXT records. I think
   428  	// we should go back to storing DNS names with a trailing dot internally. This
   429  	// way we can use it has is here and trim it off if it exists when necessary.
   430  	targets := make([]string, len(ep.Targets))
   431  	copy(targets, []string(ep.Targets))
   432  	if ep.RecordType == endpoint.RecordTypeCNAME {
   433  		targets[0] = provider.EnsureTrailingDot(targets[0])
   434  	}
   435  
   436  	if ep.RecordType == endpoint.RecordTypeMX {
   437  		for i, mxRecord := range ep.Targets {
   438  			targets[i] = provider.EnsureTrailingDot(mxRecord)
   439  		}
   440  	}
   441  
   442  	if ep.RecordType == endpoint.RecordTypeSRV {
   443  		for i, srvRecord := range ep.Targets {
   444  			targets[i] = provider.EnsureTrailingDot(srvRecord)
   445  		}
   446  	}
   447  
   448  	// no annotation results in a Ttl of 0, default to 300 for backwards-compatibility
   449  	var ttl int64 = googleRecordTTL
   450  	if ep.RecordTTL.IsConfigured() {
   451  		ttl = int64(ep.RecordTTL)
   452  	}
   453  
   454  	return &dns.ResourceRecordSet{
   455  		Name:    provider.EnsureTrailingDot(ep.DNSName),
   456  		Rrdatas: targets,
   457  		Ttl:     ttl,
   458  		Type:    ep.RecordType,
   459  	}
   460  }