sigs.k8s.io/external-dns@v0.14.1/provider/akamai/akamai.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 akamai
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"os"
    23  	"strconv"
    24  	"strings"
    25  
    26  	dns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2"
    27  	"github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid"
    28  	log "github.com/sirupsen/logrus"
    29  
    30  	"sigs.k8s.io/external-dns/endpoint"
    31  	"sigs.k8s.io/external-dns/plan"
    32  	"sigs.k8s.io/external-dns/provider"
    33  )
    34  
    35  const (
    36  	// Default Record TTL
    37  	edgeDNSRecordTTL = 600
    38  	maxUint          = ^uint(0)
    39  	maxInt           = int(maxUint >> 1)
    40  )
    41  
    42  // edgeDNSClient is a proxy interface of the Akamai edgegrid configdns-v2 package that can be stubbed for testing.
    43  type AkamaiDNSService interface {
    44  	ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error)
    45  	GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error)
    46  	GetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error)
    47  	DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error
    48  	UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error
    49  	CreateRecordsets(recordsets *dns.Recordsets, zone string, recLock bool) error
    50  }
    51  
    52  type AkamaiConfig struct {
    53  	DomainFilter          endpoint.DomainFilter
    54  	ZoneIDFilter          provider.ZoneIDFilter
    55  	ServiceConsumerDomain string
    56  	ClientToken           string
    57  	ClientSecret          string
    58  	AccessToken           string
    59  	EdgercPath            string
    60  	EdgercSection         string
    61  	MaxBody               int
    62  	AccountKey            string
    63  	DryRun                bool
    64  }
    65  
    66  // AkamaiProvider implements the DNS provider for Akamai.
    67  type AkamaiProvider struct {
    68  	provider.BaseProvider
    69  	// Edgedns zones to filter on
    70  	domainFilter endpoint.DomainFilter
    71  	// Contract Ids to filter on
    72  	zoneIDFilter provider.ZoneIDFilter
    73  	// Edgegrid library configuration
    74  	config *edgegrid.Config
    75  	dryRun bool
    76  	// Defines client. Allows for mocking.
    77  	client AkamaiDNSService
    78  }
    79  
    80  type akamaiZones struct {
    81  	Zones []akamaiZone `json:"zones"`
    82  }
    83  
    84  type akamaiZone struct {
    85  	ContractID string `json:"contractId"`
    86  	Zone       string `json:"zone"`
    87  }
    88  
    89  // NewAkamaiProvider initializes a new Akamai DNS based Provider.
    90  func NewAkamaiProvider(akamaiConfig AkamaiConfig, akaService AkamaiDNSService) (provider.Provider, error) {
    91  	var edgeGridConfig edgegrid.Config
    92  
    93  	/*
    94  		log.Debugf("Host: %s", akamaiConfig.ServiceConsumerDomain)
    95  		log.Debugf("ClientToken: %s", akamaiConfig.ClientToken)
    96  		log.Debugf("ClientSecret: %s", akamaiConfig.ClientSecret)
    97  		log.Debugf("AccessToken: %s", akamaiConfig.AccessToken)
    98  		log.Debugf("EdgePath: %s", akamaiConfig.EdgercPath)
    99  		log.Debugf("EdgeSection: %s", akamaiConfig.EdgercSection)
   100  	*/
   101  	// environment overrides edgerc file but config needs to be complete
   102  	if akamaiConfig.ServiceConsumerDomain == "" || akamaiConfig.ClientToken == "" || akamaiConfig.ClientSecret == "" || akamaiConfig.AccessToken == "" {
   103  		// Kubernetes config incomplete or non existent. Can't mix and match.
   104  		// Look for Akamai environment or .edgerd creds
   105  		var err error
   106  		edgeGridConfig, err = edgegrid.Init(akamaiConfig.EdgercPath, akamaiConfig.EdgercSection) // use default .edgerc location and section
   107  		if err != nil {
   108  			log.Errorf("Edgegrid Init Failed")
   109  			return &AkamaiProvider{}, err // return empty provider for backward compatibility
   110  		}
   111  		edgeGridConfig.HeaderToSign = append(edgeGridConfig.HeaderToSign, "X-External-DNS")
   112  	} else {
   113  		// Use external-dns config
   114  		edgeGridConfig = edgegrid.Config{
   115  			Host:         akamaiConfig.ServiceConsumerDomain,
   116  			ClientToken:  akamaiConfig.ClientToken,
   117  			ClientSecret: akamaiConfig.ClientSecret,
   118  			AccessToken:  akamaiConfig.AccessToken,
   119  			MaxBody:      131072, // same default val as used by Edgegrid
   120  			HeaderToSign: []string{
   121  				"X-External-DNS",
   122  			},
   123  			Debug: false,
   124  		}
   125  		// Check for edgegrid overrides
   126  		if envval, ok := os.LookupEnv("AKAMAI_MAX_BODY"); ok {
   127  			if i, err := strconv.Atoi(envval); err == nil {
   128  				edgeGridConfig.MaxBody = i
   129  				log.Debugf("Edgegrid maxbody set to %s", envval)
   130  			}
   131  		}
   132  		if envval, ok := os.LookupEnv("AKAMAI_ACCOUNT_KEY"); ok {
   133  			edgeGridConfig.AccountKey = envval
   134  			log.Debugf("Edgegrid applying account key %s", envval)
   135  		}
   136  		if envval, ok := os.LookupEnv("AKAMAI_DEBUG"); ok {
   137  			if dbgval, err := strconv.ParseBool(envval); err == nil {
   138  				edgeGridConfig.Debug = dbgval
   139  				log.Debugf("Edgegrid debug set to %s", envval)
   140  			}
   141  		}
   142  	}
   143  
   144  	provider := &AkamaiProvider{
   145  		domainFilter: akamaiConfig.DomainFilter,
   146  		zoneIDFilter: akamaiConfig.ZoneIDFilter,
   147  		config:       &edgeGridConfig,
   148  		dryRun:       akamaiConfig.DryRun,
   149  	}
   150  	if akaService != nil {
   151  		log.Debugf("Using STUB")
   152  		provider.client = akaService
   153  	} else {
   154  		provider.client = provider
   155  	}
   156  
   157  	// Init library for direct endpoint calls
   158  	dns.Init(edgeGridConfig)
   159  
   160  	return provider, nil
   161  }
   162  
   163  func (p AkamaiProvider) ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) {
   164  	return dns.ListZones(queryArgs)
   165  }
   166  
   167  func (p AkamaiProvider) GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) {
   168  	return dns.GetRecordsets(zone, queryArgs)
   169  }
   170  
   171  func (p AkamaiProvider) CreateRecordsets(recordsets *dns.Recordsets, zone string, reclock bool) error {
   172  	return recordsets.Save(zone, reclock)
   173  }
   174  
   175  func (p AkamaiProvider) GetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error) {
   176  	return dns.GetRecord(zone, name, recordtype)
   177  }
   178  
   179  func (p AkamaiProvider) DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error {
   180  	return record.Delete(zone, recLock)
   181  }
   182  
   183  func (p AkamaiProvider) UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error {
   184  	return record.Update(zone, recLock)
   185  }
   186  
   187  // Fetch zones using Edgegrid DNS v2 API
   188  func (p AkamaiProvider) fetchZones() (akamaiZones, error) {
   189  	filteredZones := akamaiZones{Zones: make([]akamaiZone, 0)}
   190  	queryArgs := dns.ZoneListQueryArgs{Types: "primary", ShowAll: true}
   191  	// filter based on contractIds
   192  	if len(p.zoneIDFilter.ZoneIDs) > 0 {
   193  		queryArgs.ContractIds = strings.Join(p.zoneIDFilter.ZoneIDs, ",")
   194  	}
   195  	resp, err := p.client.ListZones(queryArgs) // retrieve all primary zones filtered by contract ids
   196  	if err != nil {
   197  		log.Errorf("Failed to fetch zones from Akamai")
   198  		return filteredZones, err
   199  	}
   200  
   201  	for _, zone := range resp.Zones {
   202  		if p.domainFilter.Match(zone.Zone) {
   203  			filteredZones.Zones = append(filteredZones.Zones, akamaiZone{ContractID: zone.ContractId, Zone: zone.Zone})
   204  			log.Debugf("Fetched zone: '%s' (ZoneID: %s)", zone.Zone, zone.ContractId)
   205  		}
   206  	}
   207  	lenFilteredZones := len(filteredZones.Zones)
   208  	if lenFilteredZones == 0 {
   209  		log.Warnf("No zones could be fetched")
   210  	} else {
   211  		log.Debugf("Fetched '%d' zones from Akamai", lenFilteredZones)
   212  	}
   213  
   214  	return filteredZones, nil
   215  }
   216  
   217  // Records returns the list of records in a given zone.
   218  func (p AkamaiProvider) Records(context.Context) (endpoints []*endpoint.Endpoint, err error) {
   219  	zones, err := p.fetchZones() // returns a filtered set of zones
   220  	if err != nil {
   221  		log.Warnf("Failed to identify target zones! Error: %s", err.Error())
   222  		return endpoints, err
   223  	}
   224  	for _, zone := range zones.Zones {
   225  		recordsets, err := p.client.GetRecordsets(zone.Zone, dns.RecordsetQueryArgs{ShowAll: true})
   226  		if err != nil {
   227  			log.Errorf("Recordsets retrieval for zone: '%s' failed! %s", zone.Zone, err.Error())
   228  			continue
   229  		}
   230  		if len(recordsets.Recordsets) == 0 {
   231  			log.Warnf("Zone %s contains no recordsets", zone.Zone)
   232  		}
   233  
   234  		for _, recordset := range recordsets.Recordsets {
   235  			if !provider.SupportedRecordType(recordset.Type) {
   236  				log.Debugf("Skipping endpoint DNSName: '%s' RecordType: '%s'. Record type not supported.", recordset.Name, recordset.Type)
   237  				continue
   238  			}
   239  			if !p.domainFilter.Match(recordset.Name) {
   240  				log.Debugf("Skipping endpoint. Record name %s doesn't match containing zone %s.", recordset.Name, zone)
   241  				continue
   242  			}
   243  			var temp interface{} = int64(recordset.TTL)
   244  			ttl := endpoint.TTL(temp.(int64))
   245  			endpoints = append(endpoints, endpoint.NewEndpointWithTTL(recordset.Name,
   246  				recordset.Type,
   247  				ttl,
   248  				trimTxtRdata(recordset.Rdata, recordset.Type)...))
   249  			log.Debugf("Fetched endpoint DNSName: '%s' RecordType: '%s' Rdata: '%s')", recordset.Name, recordset.Type, recordset.Rdata)
   250  		}
   251  	}
   252  	lenEndpoints := len(endpoints)
   253  	if lenEndpoints == 0 {
   254  		log.Warnf("No endpoints could be fetched")
   255  	} else {
   256  		log.Debugf("Fetched '%d' endpoints from Akamai", lenEndpoints)
   257  		log.Debugf("Endpoints [%v]", endpoints)
   258  	}
   259  
   260  	return endpoints, nil
   261  }
   262  
   263  // ApplyChanges applies a given set of changes in a given zone.
   264  func (p AkamaiProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   265  	zoneNameIDMapper := provider.ZoneIDName{}
   266  	zones, err := p.fetchZones()
   267  	if err != nil {
   268  		log.Errorf("Failed to fetch zones from Akamai")
   269  		return err
   270  	}
   271  
   272  	for _, z := range zones.Zones {
   273  		zoneNameIDMapper[z.Zone] = z.Zone
   274  	}
   275  	log.Debugf("Processing zones: [%v]", zoneNameIDMapper)
   276  
   277  	// Create recordsets
   278  	log.Debugf("Create Changes requested [%v]", changes.Create)
   279  	if err := p.createRecordsets(zoneNameIDMapper, changes.Create); err != nil {
   280  		return err
   281  	}
   282  	// Delete recordsets
   283  	log.Debugf("Delete Changes requested [%v]", changes.Delete)
   284  	if err := p.deleteRecordsets(zoneNameIDMapper, changes.Delete); err != nil {
   285  		return err
   286  	}
   287  	// Update recordsets
   288  	log.Debugf("Update Changes requested [%v]", changes.UpdateNew)
   289  	if err := p.updateNewRecordsets(zoneNameIDMapper, changes.UpdateNew); err != nil {
   290  		return err
   291  	}
   292  	// Check that all old endpoints were accounted for
   293  	revRecs := changes.Delete
   294  	revRecs = append(revRecs, changes.UpdateNew...)
   295  	for _, rec := range changes.UpdateOld {
   296  		found := false
   297  		for _, r := range revRecs {
   298  			if rec.DNSName == r.DNSName {
   299  				found = true
   300  				break
   301  			}
   302  		}
   303  		if !found {
   304  			log.Warnf("UpdateOld endpoint '%s' is not accounted for in UpdateNew|Delete endpoint list", rec.DNSName)
   305  		}
   306  	}
   307  
   308  	return nil
   309  }
   310  
   311  // Create DNS Recordset
   312  func newAkamaiRecordset(dnsName, recordType string, ttl int, targets []string) dns.Recordset {
   313  	return dns.Recordset{
   314  		Name:  strings.TrimSuffix(dnsName, "."),
   315  		Rdata: targets,
   316  		Type:  recordType,
   317  		TTL:   ttl,
   318  	}
   319  }
   320  
   321  // cleanTargets preps recordset rdata if necessary for EdgeDNS
   322  func cleanTargets(rtype string, targets ...string) []string {
   323  	log.Debugf("Targets to clean: [%v]", targets)
   324  	if rtype == "CNAME" || rtype == "SRV" {
   325  		for idx, target := range targets {
   326  			targets[idx] = strings.TrimSuffix(target, ".")
   327  		}
   328  	} else if rtype == "TXT" {
   329  		for idx, target := range targets {
   330  			log.Debugf("TXT data to clean: [%s]", target)
   331  			// need to embed text data in quotes. Make sure not piling on
   332  			target = strings.Trim(target, "\"")
   333  			// bug in DNS API with embedded quotes.
   334  			if strings.Contains(target, "owner") && strings.Contains(target, "\"") {
   335  				target = strings.ReplaceAll(target, "\"", "`")
   336  			}
   337  			targets[idx] = "\"" + target + "\""
   338  		}
   339  	}
   340  	log.Debugf("Clean targets: [%v]", targets)
   341  
   342  	return targets
   343  }
   344  
   345  // trimTxtRdata removes surrounding quotes for received TXT rdata
   346  func trimTxtRdata(rdata []string, rtype string) []string {
   347  	if rtype == "TXT" {
   348  		for idx, d := range rdata {
   349  			if strings.Contains(d, "`") {
   350  				rdata[idx] = strings.ReplaceAll(d, "`", "\"")
   351  			}
   352  		}
   353  	}
   354  	log.Debugf("Trimmed data: [%v]", rdata)
   355  
   356  	return rdata
   357  }
   358  
   359  func ttlAsInt(src endpoint.TTL) int {
   360  	var temp interface{} = int64(src)
   361  	temp64 := temp.(int64)
   362  	var ttl int = edgeDNSRecordTTL
   363  	if temp64 > 0 && temp64 <= int64(maxInt) {
   364  		ttl = int(temp64)
   365  	}
   366  
   367  	return ttl
   368  }
   369  
   370  // Create Endpoint Recordsets
   371  func (p AkamaiProvider) createRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error {
   372  	if len(endpoints) == 0 {
   373  		log.Info("No endpoints to create")
   374  		return nil
   375  	}
   376  
   377  	endpointsByZone := edgeChangesByZone(zoneNameIDMapper, endpoints)
   378  
   379  	// create all recordsets by zone
   380  	for zone, endpoints := range endpointsByZone {
   381  		recordsets := &dns.Recordsets{Recordsets: make([]dns.Recordset, 0)}
   382  		for _, endpoint := range endpoints {
   383  			newrec := newAkamaiRecordset(endpoint.DNSName,
   384  				endpoint.RecordType,
   385  				ttlAsInt(endpoint.RecordTTL),
   386  				cleanTargets(endpoint.RecordType, endpoint.Targets...))
   387  			logfields := log.Fields{
   388  				"record": newrec.Name,
   389  				"type":   newrec.Type,
   390  				"ttl":    newrec.TTL,
   391  				"target": fmt.Sprintf("%v", newrec.Rdata),
   392  				"zone":   zone,
   393  			}
   394  			log.WithFields(logfields).Info("Creating recordsets")
   395  			recordsets.Recordsets = append(recordsets.Recordsets, newrec)
   396  		}
   397  
   398  		if p.dryRun {
   399  			continue
   400  		}
   401  		// Create recordsets all at once
   402  		err := p.client.CreateRecordsets(recordsets, zone, true)
   403  		if err != nil {
   404  			log.Errorf("Failed to create endpoints for DNS zone %s. Error: %s", zone, err.Error())
   405  			return err
   406  		}
   407  	}
   408  
   409  	return nil
   410  }
   411  
   412  func (p AkamaiProvider) deleteRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error {
   413  	for _, endpoint := range endpoints {
   414  		zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName)
   415  		if zoneName == "" {
   416  			log.Debugf("Skipping Akamai Edge DNS endpoint deletion: '%s' type: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType)
   417  			continue
   418  		}
   419  		log.Infof("Akamai Edge DNS recordset deletion- Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets)
   420  
   421  		if p.dryRun {
   422  			continue
   423  		}
   424  
   425  		recName := strings.TrimSuffix(endpoint.DNSName, ".")
   426  		rec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType)
   427  		if err != nil {
   428  			if _, ok := err.(*dns.RecordError); !ok {
   429  				return fmt.Errorf("endpoint deletion. record validation failed. error: %w", err)
   430  			}
   431  			log.Infof("Endpoint deletion. Record doesn't exist. Name: %s, Type: %s", recName, endpoint.RecordType)
   432  			continue
   433  		}
   434  		if err := p.client.DeleteRecord(rec, zoneName, true); err != nil {
   435  			log.Errorf("edge dns recordset deletion failed. error: %s", err.Error())
   436  			return err
   437  		}
   438  	}
   439  
   440  	return nil
   441  }
   442  
   443  // Update endpoint recordsets
   444  func (p AkamaiProvider) updateNewRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error {
   445  	for _, endpoint := range endpoints {
   446  		zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName)
   447  		if zoneName == "" {
   448  			log.Debugf("Skipping Akamai Edge DNS endpoint update: '%s' type: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType)
   449  			continue
   450  		}
   451  		log.Infof("Akamai Edge DNS recordset update - Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets)
   452  
   453  		if p.dryRun {
   454  			continue
   455  		}
   456  
   457  		recName := strings.TrimSuffix(endpoint.DNSName, ".")
   458  		rec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType)
   459  		if err != nil {
   460  			log.Errorf("Endpoint update. Record validation failed. Error: %s", err.Error())
   461  			return err
   462  		}
   463  		rec.TTL = ttlAsInt(endpoint.RecordTTL)
   464  		rec.Target = cleanTargets(endpoint.RecordType, endpoint.Targets...)
   465  		if err := p.client.UpdateRecord(rec, zoneName, true); err != nil {
   466  			log.Errorf("Akamai Edge DNS recordset update failed. Error: %s", err.Error())
   467  			return err
   468  		}
   469  	}
   470  
   471  	return nil
   472  }
   473  
   474  // edgeChangesByZone separates a multi-zone change into a single change per zone.
   475  func edgeChangesByZone(zoneMap provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint {
   476  	createsByZone := make(map[string][]*endpoint.Endpoint, len(zoneMap))
   477  	for _, z := range zoneMap {
   478  		createsByZone[z] = make([]*endpoint.Endpoint, 0)
   479  	}
   480  	for _, ep := range endpoints {
   481  		zone, _ := zoneMap.FindZone(ep.DNSName)
   482  		if zone != "" {
   483  			createsByZone[zone] = append(createsByZone[zone], ep)
   484  			continue
   485  		}
   486  		log.Debugf("Skipping Akamai Edge DNS creation of endpoint: '%s' type: '%s', it does not match against Domain filters", ep.DNSName, ep.RecordType)
   487  	}
   488  
   489  	return createsByZone
   490  }