sigs.k8s.io/external-dns@v0.14.1/provider/designate/designate.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 designate
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"net"
    23  	"net/http"
    24  	"os"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/gophercloud/gophercloud"
    29  	"github.com/gophercloud/gophercloud/openstack"
    30  	"github.com/gophercloud/gophercloud/openstack/dns/v2/recordsets"
    31  	"github.com/gophercloud/gophercloud/openstack/dns/v2/zones"
    32  	"github.com/gophercloud/gophercloud/pagination"
    33  	log "github.com/sirupsen/logrus"
    34  
    35  	"sigs.k8s.io/external-dns/endpoint"
    36  	"sigs.k8s.io/external-dns/pkg/tlsutils"
    37  	"sigs.k8s.io/external-dns/plan"
    38  	"sigs.k8s.io/external-dns/provider"
    39  )
    40  
    41  const (
    42  	// ID of the RecordSet from which endpoint was created
    43  	designateRecordSetID = "designate-recordset-id"
    44  	// Zone ID of the RecordSet
    45  	designateZoneID = "designate-record-id"
    46  
    47  	// Initial records values of the RecordSet. This label is required in order not to loose records that haven't
    48  	// changed where there are several targets per domain and only some of them changed.
    49  	// Values are joined by zero-byte to in order to get a single string
    50  	designateOriginalRecords = "designate-original-records"
    51  )
    52  
    53  // interface between provider and OpenStack DNS API
    54  type designateClientInterface interface {
    55  	// ForEachZone calls handler for each zone managed by the Designate
    56  	ForEachZone(handler func(zone *zones.Zone) error) error
    57  
    58  	// ForEachRecordSet calls handler for each recordset in the given DNS zone
    59  	ForEachRecordSet(zoneID string, handler func(recordSet *recordsets.RecordSet) error) error
    60  
    61  	// CreateRecordSet creates recordset in the given DNS zone
    62  	CreateRecordSet(zoneID string, opts recordsets.CreateOpts) (string, error)
    63  
    64  	// UpdateRecordSet updates recordset in the given DNS zone
    65  	UpdateRecordSet(zoneID, recordSetID string, opts recordsets.UpdateOpts) error
    66  
    67  	// DeleteRecordSet deletes recordset in the given DNS zone
    68  	DeleteRecordSet(zoneID, recordSetID string) error
    69  }
    70  
    71  // implementation of the designateClientInterface
    72  type designateClient struct {
    73  	serviceClient *gophercloud.ServiceClient
    74  }
    75  
    76  // factory function for the designateClientInterface
    77  func newDesignateClient() (designateClientInterface, error) {
    78  	serviceClient, err := createDesignateServiceClient()
    79  	if err != nil {
    80  		return nil, err
    81  	}
    82  	return &designateClient{serviceClient}, nil
    83  }
    84  
    85  // copies environment variables to new names without overwriting existing values
    86  func remapEnv(mapping map[string]string) {
    87  	for k, v := range mapping {
    88  		currentVal := os.Getenv(k)
    89  		newVal := os.Getenv(v)
    90  		if currentVal == "" && newVal != "" {
    91  			os.Setenv(k, newVal)
    92  		}
    93  	}
    94  }
    95  
    96  // returns OpenStack Keystone authentication settings by obtaining values from standard environment variables.
    97  // also fixes incompatibilities between gophercloud implementation and *-stackrc files that can be downloaded
    98  // from OpenStack dashboard in latest versions
    99  func getAuthSettings() (gophercloud.AuthOptions, error) {
   100  	remapEnv(map[string]string{
   101  		"OS_TENANT_NAME": "OS_PROJECT_NAME",
   102  		"OS_TENANT_ID":   "OS_PROJECT_ID",
   103  		"OS_DOMAIN_NAME": "OS_USER_DOMAIN_NAME",
   104  		"OS_DOMAIN_ID":   "OS_USER_DOMAIN_ID",
   105  	})
   106  
   107  	opts, err := openstack.AuthOptionsFromEnv()
   108  	if err != nil {
   109  		return gophercloud.AuthOptions{}, err
   110  	}
   111  	opts.AllowReauth = true
   112  	if !strings.HasSuffix(opts.IdentityEndpoint, "/") {
   113  		opts.IdentityEndpoint += "/"
   114  	}
   115  	if !strings.HasSuffix(opts.IdentityEndpoint, "/v2.0/") && !strings.HasSuffix(opts.IdentityEndpoint, "/v3/") {
   116  		opts.IdentityEndpoint += "v2.0/"
   117  	}
   118  	return opts, nil
   119  }
   120  
   121  // authenticate in OpenStack and obtain Designate service endpoint
   122  func createDesignateServiceClient() (*gophercloud.ServiceClient, error) {
   123  	opts, err := getAuthSettings()
   124  	if err != nil {
   125  		return nil, err
   126  	}
   127  	log.Infof("Using OpenStack Keystone at %s", opts.IdentityEndpoint)
   128  	authProvider, err := openstack.NewClient(opts.IdentityEndpoint)
   129  	if err != nil {
   130  		return nil, err
   131  	}
   132  
   133  	tlsConfig, err := tlsutils.CreateTLSConfig("OPENSTACK")
   134  	if err != nil {
   135  		return nil, err
   136  	}
   137  
   138  	transport := &http.Transport{
   139  		Proxy: http.ProxyFromEnvironment,
   140  		DialContext: (&net.Dialer{
   141  			Timeout:   30 * time.Second,
   142  			KeepAlive: 30 * time.Second,
   143  		}).DialContext,
   144  		MaxIdleConns:          100,
   145  		IdleConnTimeout:       90 * time.Second,
   146  		TLSHandshakeTimeout:   10 * time.Second,
   147  		ExpectContinueTimeout: 1 * time.Second,
   148  		TLSClientConfig:       tlsConfig,
   149  	}
   150  	authProvider.HTTPClient.Transport = transport
   151  
   152  	if err = openstack.Authenticate(authProvider, opts); err != nil {
   153  		return nil, err
   154  	}
   155  
   156  	eo := gophercloud.EndpointOpts{
   157  		Region: os.Getenv("OS_REGION_NAME"),
   158  	}
   159  
   160  	client, err := openstack.NewDNSV2(authProvider, eo)
   161  	if err != nil {
   162  		return nil, err
   163  	}
   164  	log.Infof("Found OpenStack Designate service at %s", client.Endpoint)
   165  	return client, nil
   166  }
   167  
   168  // ForEachZone calls handler for each zone managed by the Designate
   169  func (c designateClient) ForEachZone(handler func(zone *zones.Zone) error) error {
   170  	pager := zones.List(c.serviceClient, zones.ListOpts{})
   171  	return pager.EachPage(
   172  		func(page pagination.Page) (bool, error) {
   173  			list, err := zones.ExtractZones(page)
   174  			if err != nil {
   175  				return false, err
   176  			}
   177  			for _, zone := range list {
   178  				err := handler(&zone)
   179  				if err != nil {
   180  					return false, err
   181  				}
   182  			}
   183  			return true, nil
   184  		},
   185  	)
   186  }
   187  
   188  // ForEachRecordSet calls handler for each recordset in the given DNS zone
   189  func (c designateClient) ForEachRecordSet(zoneID string, handler func(recordSet *recordsets.RecordSet) error) error {
   190  	pager := recordsets.ListByZone(c.serviceClient, zoneID, recordsets.ListOpts{})
   191  	return pager.EachPage(
   192  		func(page pagination.Page) (bool, error) {
   193  			list, err := recordsets.ExtractRecordSets(page)
   194  			if err != nil {
   195  				return false, err
   196  			}
   197  			for _, recordSet := range list {
   198  				err := handler(&recordSet)
   199  				if err != nil {
   200  					return false, err
   201  				}
   202  			}
   203  			return true, nil
   204  		},
   205  	)
   206  }
   207  
   208  // CreateRecordSet creates recordset in the given DNS zone
   209  func (c designateClient) CreateRecordSet(zoneID string, opts recordsets.CreateOpts) (string, error) {
   210  	r, err := recordsets.Create(c.serviceClient, zoneID, opts).Extract()
   211  	if err != nil {
   212  		return "", err
   213  	}
   214  	return r.ID, nil
   215  }
   216  
   217  // UpdateRecordSet updates recordset in the given DNS zone
   218  func (c designateClient) UpdateRecordSet(zoneID, recordSetID string, opts recordsets.UpdateOpts) error {
   219  	_, err := recordsets.Update(c.serviceClient, zoneID, recordSetID, opts).Extract()
   220  	return err
   221  }
   222  
   223  // DeleteRecordSet deletes recordset in the given DNS zone
   224  func (c designateClient) DeleteRecordSet(zoneID, recordSetID string) error {
   225  	return recordsets.Delete(c.serviceClient, zoneID, recordSetID).ExtractErr()
   226  }
   227  
   228  // designate provider type
   229  type designateProvider struct {
   230  	provider.BaseProvider
   231  	client designateClientInterface
   232  
   233  	// only consider hosted zones managing domains ending in this suffix
   234  	domainFilter endpoint.DomainFilter
   235  	dryRun       bool
   236  }
   237  
   238  // NewDesignateProvider is a factory function for OpenStack designate providers
   239  func NewDesignateProvider(domainFilter endpoint.DomainFilter, dryRun bool) (provider.Provider, error) {
   240  	client, err := newDesignateClient()
   241  	if err != nil {
   242  		return nil, err
   243  	}
   244  	return &designateProvider{
   245  		client:       client,
   246  		domainFilter: domainFilter,
   247  		dryRun:       dryRun,
   248  	}, nil
   249  }
   250  
   251  // converts domain names to FQDN
   252  func canonicalizeDomainNames(domains []string) []string {
   253  	var cDomains []string
   254  	for _, d := range domains {
   255  		if !strings.HasSuffix(d, ".") {
   256  			d += "."
   257  			cDomains = append(cDomains, strings.ToLower(d))
   258  		}
   259  	}
   260  	return cDomains
   261  }
   262  
   263  // converts domain name to FQDN
   264  func canonicalizeDomainName(d string) string {
   265  	if !strings.HasSuffix(d, ".") {
   266  		d += "."
   267  	}
   268  	return strings.ToLower(d)
   269  }
   270  
   271  // returns ZoneID -> ZoneName mapping for zones that are managed by the Designate and match domain filter
   272  func (p designateProvider) getZones() (map[string]string, error) {
   273  	result := map[string]string{}
   274  
   275  	err := p.client.ForEachZone(
   276  		func(zone *zones.Zone) error {
   277  			if zone.Type != "" && strings.ToUpper(zone.Type) != "PRIMARY" || zone.Status != "ACTIVE" {
   278  				return nil
   279  			}
   280  
   281  			zoneName := canonicalizeDomainName(zone.Name)
   282  			if !p.domainFilter.Match(zoneName) {
   283  				return nil
   284  			}
   285  			result[zone.ID] = zoneName
   286  			return nil
   287  		},
   288  	)
   289  
   290  	return result, err
   291  }
   292  
   293  // finds best suitable DNS zone for the hostname
   294  func (p designateProvider) getHostZoneID(hostname string, managedZones map[string]string) (string, error) {
   295  	longestZoneLength := 0
   296  	resultID := ""
   297  
   298  	for zoneID, zoneName := range managedZones {
   299  		if !strings.HasSuffix(hostname, zoneName) {
   300  			continue
   301  		}
   302  		ln := len(zoneName)
   303  		if ln > longestZoneLength {
   304  			resultID = zoneID
   305  			longestZoneLength = ln
   306  		}
   307  	}
   308  
   309  	return resultID, nil
   310  }
   311  
   312  // Records returns the list of records.
   313  func (p designateProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
   314  	var result []*endpoint.Endpoint
   315  	managedZones, err := p.getZones()
   316  	if err != nil {
   317  		return nil, err
   318  	}
   319  	for zoneID := range managedZones {
   320  		err = p.client.ForEachRecordSet(zoneID,
   321  			func(recordSet *recordsets.RecordSet) error {
   322  				if recordSet.Type != endpoint.RecordTypeA && recordSet.Type != endpoint.RecordTypeTXT && recordSet.Type != endpoint.RecordTypeCNAME {
   323  					return nil
   324  				}
   325  
   326  				ep := endpoint.NewEndpoint(recordSet.Name, recordSet.Type, recordSet.Records...)
   327  				ep.Labels[designateRecordSetID] = recordSet.ID
   328  				ep.Labels[designateZoneID] = recordSet.ZoneID
   329  				ep.Labels[designateOriginalRecords] = strings.Join(recordSet.Records, "\000")
   330  				result = append(result, ep)
   331  
   332  				return nil
   333  			},
   334  		)
   335  		if err != nil {
   336  			return nil, err
   337  		}
   338  	}
   339  
   340  	return result, nil
   341  }
   342  
   343  // temporary structure to hold recordset parameters so that we could aggregate endpoints into recordsets
   344  type recordSet struct {
   345  	dnsName     string
   346  	recordType  string
   347  	zoneID      string
   348  	recordSetID string
   349  	names       map[string]bool
   350  }
   351  
   352  // adds endpoint into recordset aggregation, loading original values from endpoint labels first
   353  func addEndpoint(ep *endpoint.Endpoint, recordSets map[string]*recordSet, oldEndpoints []*endpoint.Endpoint, delete bool) {
   354  	key := fmt.Sprintf("%s/%s", ep.DNSName, ep.RecordType)
   355  	rs := recordSets[key]
   356  	if rs == nil {
   357  		rs = &recordSet{
   358  			dnsName:    canonicalizeDomainName(ep.DNSName),
   359  			recordType: ep.RecordType,
   360  			names:      make(map[string]bool),
   361  		}
   362  	}
   363  
   364  	addDesignateIDLabelsFromExistingEndpoints(oldEndpoints, ep)
   365  
   366  	if rs.zoneID == "" {
   367  		rs.zoneID = ep.Labels[designateZoneID]
   368  	}
   369  	if rs.recordSetID == "" {
   370  		rs.recordSetID = ep.Labels[designateRecordSetID]
   371  	}
   372  	for _, rec := range strings.Split(ep.Labels[designateOriginalRecords], "\000") {
   373  		if _, ok := rs.names[rec]; !ok && rec != "" {
   374  			rs.names[rec] = true
   375  		}
   376  	}
   377  	targets := ep.Targets
   378  	if ep.RecordType == endpoint.RecordTypeCNAME {
   379  		targets = canonicalizeDomainNames(targets)
   380  	}
   381  	for _, t := range targets {
   382  		rs.names[t] = !delete
   383  	}
   384  	recordSets[key] = rs
   385  }
   386  
   387  // addDesignateIDLabelsFromExistingEndpoints adds the labels identified by the constants designateZoneID and designateRecordSetID
   388  // to an Endpoint. Therefore, it searches all given existing endpoints for an endpoint with the same record type and record
   389  // value. If the given Endpoint already has the labels set, they are left untouched. This fixes an issue with the
   390  // TXTRegistry which generates new TXT entries instead of updating the old ones.
   391  func addDesignateIDLabelsFromExistingEndpoints(existingEndpoints []*endpoint.Endpoint, ep *endpoint.Endpoint) {
   392  	_, hasZoneIDLabel := ep.Labels[designateZoneID]
   393  	_, hasRecordSetIDLabel := ep.Labels[designateRecordSetID]
   394  	if hasZoneIDLabel && hasRecordSetIDLabel {
   395  		return
   396  	}
   397  	for _, oep := range existingEndpoints {
   398  		if ep.RecordType == oep.RecordType && ep.DNSName == oep.DNSName {
   399  			if !hasZoneIDLabel {
   400  				ep.Labels[designateZoneID] = oep.Labels[designateZoneID]
   401  			}
   402  			if !hasRecordSetIDLabel {
   403  				ep.Labels[designateRecordSetID] = oep.Labels[designateRecordSetID]
   404  			}
   405  			return
   406  		}
   407  	}
   408  }
   409  
   410  // ApplyChanges applies a given set of changes in a given zone.
   411  func (p designateProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   412  	managedZones, err := p.getZones()
   413  	if err != nil {
   414  		return err
   415  	}
   416  
   417  	endpoints, err := p.Records(ctx)
   418  	if err != nil {
   419  		return fmt.Errorf("failed to fetch active records: %w", err)
   420  	}
   421  
   422  	recordSets := map[string]*recordSet{}
   423  	for _, ep := range changes.Create {
   424  		addEndpoint(ep, recordSets, endpoints, false)
   425  	}
   426  	for _, ep := range changes.UpdateOld {
   427  		addEndpoint(ep, recordSets, endpoints, true)
   428  	}
   429  	for _, ep := range changes.UpdateNew {
   430  		addEndpoint(ep, recordSets, endpoints, false)
   431  	}
   432  	for _, ep := range changes.Delete {
   433  		addEndpoint(ep, recordSets, endpoints, true)
   434  	}
   435  
   436  	for _, rs := range recordSets {
   437  		if err2 := p.upsertRecordSet(rs, managedZones); err == nil {
   438  			err = err2
   439  		}
   440  	}
   441  	return err
   442  }
   443  
   444  // apply recordset changes by inserting/updating/deleting recordsets
   445  func (p designateProvider) upsertRecordSet(rs *recordSet, managedZones map[string]string) error {
   446  	if rs.zoneID == "" {
   447  		var err error
   448  		rs.zoneID, err = p.getHostZoneID(rs.dnsName, managedZones)
   449  		if err != nil {
   450  			return err
   451  		}
   452  		if rs.zoneID == "" {
   453  			log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", rs.dnsName)
   454  			return nil
   455  		}
   456  	}
   457  	var records []string
   458  	for rec, v := range rs.names {
   459  		if v {
   460  			records = append(records, rec)
   461  		}
   462  	}
   463  	if rs.recordSetID == "" && records == nil {
   464  		return nil
   465  	}
   466  	if rs.recordSetID == "" {
   467  		opts := recordsets.CreateOpts{
   468  			Name:    rs.dnsName,
   469  			Type:    rs.recordType,
   470  			Records: records,
   471  		}
   472  		log.Infof("Creating records: %s/%s: %s", rs.dnsName, rs.recordType, strings.Join(records, ","))
   473  		if p.dryRun {
   474  			return nil
   475  		}
   476  		_, err := p.client.CreateRecordSet(rs.zoneID, opts)
   477  		return err
   478  	} else if len(records) == 0 {
   479  		log.Infof("Deleting records for %s/%s", rs.dnsName, rs.recordType)
   480  		if p.dryRun {
   481  			return nil
   482  		}
   483  		return p.client.DeleteRecordSet(rs.zoneID, rs.recordSetID)
   484  	} else {
   485  		ttl := 0
   486  		opts := recordsets.UpdateOpts{
   487  			Records: records,
   488  			TTL:     &ttl,
   489  		}
   490  		log.Infof("Updating records: %s/%s: %s", rs.dnsName, rs.recordType, strings.Join(records, ","))
   491  		if p.dryRun {
   492  			return nil
   493  		}
   494  		return p.client.UpdateRecordSet(rs.zoneID, rs.recordSetID, opts)
   495  	}
   496  }