sigs.k8s.io/external-dns@v0.14.1/provider/exoscale/exoscale.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 exoscale
    18  
    19  import (
    20  	"context"
    21  	"strings"
    22  
    23  	egoscale "github.com/exoscale/egoscale/v2"
    24  	exoapi "github.com/exoscale/egoscale/v2/api"
    25  	log "github.com/sirupsen/logrus"
    26  
    27  	"sigs.k8s.io/external-dns/endpoint"
    28  	"sigs.k8s.io/external-dns/plan"
    29  	"sigs.k8s.io/external-dns/provider"
    30  )
    31  
    32  // EgoscaleClientI for replaceable implementation
    33  type EgoscaleClientI interface {
    34  	ListDNSDomainRecords(context.Context, string, string) ([]egoscale.DNSDomainRecord, error)
    35  	ListDNSDomains(context.Context, string) ([]egoscale.DNSDomain, error)
    36  	CreateDNSDomainRecord(context.Context, string, string, *egoscale.DNSDomainRecord) (*egoscale.DNSDomainRecord, error)
    37  	DeleteDNSDomainRecord(context.Context, string, string, *egoscale.DNSDomainRecord) error
    38  	UpdateDNSDomainRecord(context.Context, string, string, *egoscale.DNSDomainRecord) error
    39  }
    40  
    41  // ExoscaleProvider initialized as dns provider with no records
    42  type ExoscaleProvider struct {
    43  	provider.BaseProvider
    44  	domain         endpoint.DomainFilter
    45  	client         EgoscaleClientI
    46  	apiEnv         string
    47  	apiZone        string
    48  	filter         *zoneFilter
    49  	OnApplyChanges func(changes *plan.Changes)
    50  	dryRun         bool
    51  }
    52  
    53  // ExoscaleOption for Provider options
    54  type ExoscaleOption func(*ExoscaleProvider)
    55  
    56  // NewExoscaleProvider returns ExoscaleProvider DNS provider interface implementation
    57  func NewExoscaleProvider(env, zone, key, secret string, dryRun bool, opts ...ExoscaleOption) (*ExoscaleProvider, error) {
    58  	client, err := egoscale.NewClient(
    59  		key,
    60  		secret,
    61  	)
    62  	if err != nil {
    63  		return nil, err
    64  	}
    65  
    66  	return NewExoscaleProviderWithClient(client, env, zone, dryRun, opts...), nil
    67  }
    68  
    69  // NewExoscaleProviderWithClient returns ExoscaleProvider DNS provider interface implementation (Client provided)
    70  func NewExoscaleProviderWithClient(client EgoscaleClientI, env, zone string, dryRun bool, opts ...ExoscaleOption) *ExoscaleProvider {
    71  	ep := &ExoscaleProvider{
    72  		filter:         &zoneFilter{},
    73  		OnApplyChanges: func(changes *plan.Changes) {},
    74  		domain:         endpoint.NewDomainFilter([]string{""}),
    75  		client:         client,
    76  		apiEnv:         env,
    77  		apiZone:        zone,
    78  		dryRun:         dryRun,
    79  	}
    80  	for _, opt := range opts {
    81  		opt(ep)
    82  	}
    83  	return ep
    84  }
    85  
    86  func (ep *ExoscaleProvider) getZones(ctx context.Context) (map[string]string, error) {
    87  	ctx = exoapi.WithEndpoint(ctx, exoapi.NewReqEndpoint(ep.apiEnv, ep.apiZone))
    88  	domains, err := ep.client.ListDNSDomains(ctx, ep.apiZone)
    89  	if err != nil {
    90  		return nil, err
    91  	}
    92  
    93  	zones := map[string]string{}
    94  	for _, domain := range domains {
    95  		zones[*domain.ID] = *domain.UnicodeName
    96  	}
    97  
    98  	return zones, nil
    99  }
   100  
   101  // ApplyChanges simply modifies DNS via exoscale API
   102  func (ep *ExoscaleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   103  	ep.OnApplyChanges(changes)
   104  
   105  	if ep.dryRun {
   106  		log.Infof("Will NOT delete these records: %+v", changes.Delete)
   107  		log.Infof("Will NOT create these records: %+v", changes.Create)
   108  		log.Infof("Will NOT update these records: %+v", merge(changes.UpdateOld, changes.UpdateNew))
   109  		return nil
   110  	}
   111  
   112  	ctx = exoapi.WithEndpoint(ctx, exoapi.NewReqEndpoint(ep.apiEnv, ep.apiZone))
   113  
   114  	zones, err := ep.getZones(ctx)
   115  	if err != nil {
   116  		return err
   117  	}
   118  
   119  	for _, epoint := range changes.Create {
   120  		if !ep.domain.Match(epoint.DNSName) {
   121  			continue
   122  		}
   123  
   124  		zoneID, name := ep.filter.EndpointZoneID(epoint, zones)
   125  		if zoneID == "" {
   126  			continue
   127  		}
   128  
   129  		// API does not accept 0 as default TTL but wants nil pointer instead
   130  		var ttl *int64
   131  		if epoint.RecordTTL != 0 {
   132  			t := int64(epoint.RecordTTL)
   133  			ttl = &t
   134  		}
   135  		record := egoscale.DNSDomainRecord{
   136  			Name:    &name,
   137  			Type:    &epoint.RecordType,
   138  			TTL:     ttl,
   139  			Content: &epoint.Targets[0],
   140  		}
   141  		_, err := ep.client.CreateDNSDomainRecord(ctx, ep.apiZone, zoneID, &record)
   142  		if err != nil {
   143  			return err
   144  		}
   145  	}
   146  
   147  	for _, epoint := range changes.UpdateNew {
   148  		if !ep.domain.Match(epoint.DNSName) {
   149  			continue
   150  		}
   151  
   152  		zoneID, name := ep.filter.EndpointZoneID(epoint, zones)
   153  		if zoneID == "" {
   154  			continue
   155  		}
   156  
   157  		records, err := ep.client.ListDNSDomainRecords(ctx, ep.apiZone, zoneID)
   158  		if err != nil {
   159  			return err
   160  		}
   161  
   162  		for _, record := range records {
   163  			if *record.Name != name {
   164  				continue
   165  			}
   166  
   167  			record.Type = &epoint.RecordType
   168  			record.Content = &epoint.Targets[0]
   169  			if epoint.RecordTTL != 0 {
   170  				ttl := int64(epoint.RecordTTL)
   171  				record.TTL = &ttl
   172  			}
   173  
   174  			err = ep.client.UpdateDNSDomainRecord(ctx, ep.apiZone, zoneID, &record)
   175  			if err != nil {
   176  				return err
   177  			}
   178  
   179  			break
   180  		}
   181  	}
   182  
   183  	for _, epoint := range changes.UpdateOld {
   184  		// Since Exoscale "Patches", we ignore UpdateOld
   185  		// We leave this logging here for information
   186  		log.Debugf("UPDATE-OLD (ignored) for epoint: %+v", epoint)
   187  	}
   188  
   189  	for _, epoint := range changes.Delete {
   190  		if !ep.domain.Match(epoint.DNSName) {
   191  			continue
   192  		}
   193  
   194  		zoneID, name := ep.filter.EndpointZoneID(epoint, zones)
   195  		if zoneID == "" {
   196  			continue
   197  		}
   198  
   199  		records, err := ep.client.ListDNSDomainRecords(ctx, ep.apiZone, zoneID)
   200  		if err != nil {
   201  			return err
   202  		}
   203  
   204  		for _, record := range records {
   205  			if *record.Name != name {
   206  				continue
   207  			}
   208  
   209  			err = ep.client.DeleteDNSDomainRecord(ctx, ep.apiZone, zoneID, &egoscale.DNSDomainRecord{ID: record.ID})
   210  			if err != nil {
   211  				return err
   212  			}
   213  
   214  			break
   215  		}
   216  	}
   217  
   218  	return nil
   219  }
   220  
   221  // Records returns the list of endpoints
   222  func (ep *ExoscaleProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
   223  	ctx = exoapi.WithEndpoint(ctx, exoapi.NewReqEndpoint(ep.apiEnv, ep.apiZone))
   224  	endpoints := make([]*endpoint.Endpoint, 0)
   225  
   226  	domains, err := ep.client.ListDNSDomains(ctx, ep.apiZone)
   227  	if err != nil {
   228  		return nil, err
   229  	}
   230  
   231  	for _, domain := range domains {
   232  		records, err := ep.client.ListDNSDomainRecords(ctx, ep.apiZone, *domain.ID)
   233  		if err != nil {
   234  			return nil, err
   235  		}
   236  
   237  		for _, record := range records {
   238  			switch *record.Type {
   239  			case "A", "CNAME", "TXT":
   240  				break
   241  			default:
   242  				continue
   243  			}
   244  
   245  			e := endpoint.NewEndpointWithTTL((*record.Name)+"."+(*domain.UnicodeName), *record.Type, endpoint.TTL(*record.TTL), *record.Content)
   246  			endpoints = append(endpoints, e)
   247  		}
   248  	}
   249  
   250  	log.Infof("called Records() with %d items", len(endpoints))
   251  	return endpoints, nil
   252  }
   253  
   254  // ExoscaleWithDomain modifies the domain on which dns zones are filtered
   255  func ExoscaleWithDomain(domainFilter endpoint.DomainFilter) ExoscaleOption {
   256  	return func(p *ExoscaleProvider) {
   257  		p.domain = domainFilter
   258  	}
   259  }
   260  
   261  // ExoscaleWithLogging injects logging when ApplyChanges is called
   262  func ExoscaleWithLogging() ExoscaleOption {
   263  	return func(p *ExoscaleProvider) {
   264  		p.OnApplyChanges = func(changes *plan.Changes) {
   265  			for _, v := range changes.Create {
   266  				log.Infof("CREATE: %v", v)
   267  			}
   268  			for _, v := range changes.UpdateOld {
   269  				log.Infof("UPDATE (old): %v", v)
   270  			}
   271  			for _, v := range changes.UpdateNew {
   272  				log.Infof("UPDATE (new): %v", v)
   273  			}
   274  			for _, v := range changes.Delete {
   275  				log.Infof("DELETE: %v", v)
   276  			}
   277  		}
   278  	}
   279  }
   280  
   281  type zoneFilter struct {
   282  	domain string
   283  }
   284  
   285  // Zones filters map[zoneID]zoneName for names having f.domain as suffix
   286  func (f *zoneFilter) Zones(zones map[string]string) map[string]string {
   287  	result := map[string]string{}
   288  	for zoneID, zoneName := range zones {
   289  		if strings.HasSuffix(zoneName, f.domain) {
   290  			result[zoneID] = zoneName
   291  		}
   292  	}
   293  	return result
   294  }
   295  
   296  // EndpointZoneID determines zoneID for endpoint from map[zoneID]zoneName by taking longest suffix zoneName match in endpoint DNSName
   297  // returns empty string if no matches are found
   298  func (f *zoneFilter) EndpointZoneID(endpoint *endpoint.Endpoint, zones map[string]string) (zoneID string, name string) {
   299  	var matchZoneID string
   300  	var matchZoneName string
   301  	for zoneID, zoneName := range zones {
   302  		if strings.HasSuffix(endpoint.DNSName, "."+zoneName) && len(zoneName) > len(matchZoneName) {
   303  			matchZoneName = zoneName
   304  			matchZoneID = zoneID
   305  			name = strings.TrimSuffix(endpoint.DNSName, "."+zoneName)
   306  		}
   307  	}
   308  	return matchZoneID, name
   309  }
   310  
   311  func merge(updateOld, updateNew []*endpoint.Endpoint) []*endpoint.Endpoint {
   312  	findMatch := func(template *endpoint.Endpoint) *endpoint.Endpoint {
   313  		for _, new := range updateNew {
   314  			if template.DNSName == new.DNSName &&
   315  				template.RecordType == new.RecordType {
   316  				return new
   317  			}
   318  		}
   319  		return nil
   320  	}
   321  
   322  	var result []*endpoint.Endpoint
   323  	for _, old := range updateOld {
   324  		matchingNew := findMatch(old)
   325  		if matchingNew == nil {
   326  			// no match, shouldn't happen
   327  			continue
   328  		}
   329  
   330  		if !matchingNew.Targets.Same(old.Targets) {
   331  			// new target: always update, TTL will be overwritten too if necessary
   332  			result = append(result, matchingNew)
   333  			continue
   334  		}
   335  
   336  		if matchingNew.RecordTTL != 0 && matchingNew.RecordTTL != old.RecordTTL {
   337  			// same target, but new non-zero TTL set in k8s, must update
   338  			// probably would happen only if there is a bug in the code calling the provider
   339  			result = append(result, matchingNew)
   340  		}
   341  	}
   342  
   343  	return result
   344  }