sigs.k8s.io/external-dns@v0.14.1/registry/dynamodb.go (about)

     1  /*
     2  Copyright 2023 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 registry
    18  
    19  import (
    20  	"context"
    21  	"errors"
    22  	"fmt"
    23  	"strings"
    24  	"time"
    25  
    26  	"github.com/aws/aws-sdk-go/aws"
    27  	"github.com/aws/aws-sdk-go/aws/request"
    28  	"github.com/aws/aws-sdk-go/service/dynamodb"
    29  	log "github.com/sirupsen/logrus"
    30  	"k8s.io/apimachinery/pkg/util/sets"
    31  
    32  	"sigs.k8s.io/external-dns/endpoint"
    33  	"sigs.k8s.io/external-dns/plan"
    34  	"sigs.k8s.io/external-dns/provider"
    35  )
    36  
    37  // DynamoDBAPI is the subset of the AWS Route53 API that we actually use.  Add methods as required. Signatures must match exactly.
    38  type DynamoDBAPI interface {
    39  	DescribeTableWithContext(ctx aws.Context, input *dynamodb.DescribeTableInput, opts ...request.Option) (*dynamodb.DescribeTableOutput, error)
    40  	ScanPagesWithContext(ctx aws.Context, input *dynamodb.ScanInput, fn func(*dynamodb.ScanOutput, bool) bool, opts ...request.Option) error
    41  	BatchExecuteStatementWithContext(aws.Context, *dynamodb.BatchExecuteStatementInput, ...request.Option) (*dynamodb.BatchExecuteStatementOutput, error)
    42  }
    43  
    44  // DynamoDBRegistry implements registry interface with ownership implemented via an AWS DynamoDB table.
    45  type DynamoDBRegistry struct {
    46  	provider provider.Provider
    47  	ownerID  string // refers to the owner id of the current instance
    48  
    49  	dynamodbAPI DynamoDBAPI
    50  	table       string
    51  
    52  	// For migration from TXT registry
    53  	mapper              nameMapper
    54  	wildcardReplacement string
    55  	managedRecordTypes  []string
    56  	excludeRecordTypes  []string
    57  	txtEncryptAESKey    []byte
    58  
    59  	// cache the dynamodb records owned by us.
    60  	labels         map[endpoint.EndpointKey]endpoint.Labels
    61  	orphanedLabels sets.Set[endpoint.EndpointKey]
    62  
    63  	// cache the records in memory and update on an interval instead.
    64  	recordsCache            []*endpoint.Endpoint
    65  	recordsCacheRefreshTime time.Time
    66  	cacheInterval           time.Duration
    67  }
    68  
    69  const dynamodbAttributeMigrate = "dynamodb/needs-migration"
    70  
    71  // DynamoDB allows a maximum batch size of 25 items.
    72  var dynamodbMaxBatchSize uint8 = 25
    73  
    74  // NewDynamoDBRegistry returns a new DynamoDBRegistry object.
    75  func NewDynamoDBRegistry(provider provider.Provider, ownerID string, dynamodbAPI DynamoDBAPI, table string, txtPrefix, txtSuffix, txtWildcardReplacement string, managedRecordTypes, excludeRecordTypes []string, txtEncryptAESKey []byte, cacheInterval time.Duration) (*DynamoDBRegistry, error) {
    76  	if ownerID == "" {
    77  		return nil, errors.New("owner id cannot be empty")
    78  	}
    79  	if table == "" {
    80  		return nil, errors.New("table cannot be empty")
    81  	}
    82  
    83  	if len(txtEncryptAESKey) == 0 {
    84  		txtEncryptAESKey = nil
    85  	} else if len(txtEncryptAESKey) != 32 {
    86  		return nil, errors.New("the AES Encryption key must have a length of 32 bytes")
    87  	}
    88  	if len(txtPrefix) > 0 && len(txtSuffix) > 0 {
    89  		return nil, errors.New("txt-prefix and txt-suffix are mutually exclusive")
    90  	}
    91  
    92  	mapper := newaffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement)
    93  
    94  	return &DynamoDBRegistry{
    95  		provider:            provider,
    96  		ownerID:             ownerID,
    97  		dynamodbAPI:         dynamodbAPI,
    98  		table:               table,
    99  		mapper:              mapper,
   100  		wildcardReplacement: txtWildcardReplacement,
   101  		managedRecordTypes:  managedRecordTypes,
   102  		excludeRecordTypes:  excludeRecordTypes,
   103  		txtEncryptAESKey:    txtEncryptAESKey,
   104  		cacheInterval:       cacheInterval,
   105  	}, nil
   106  }
   107  
   108  func (im *DynamoDBRegistry) GetDomainFilter() endpoint.DomainFilter {
   109  	return im.provider.GetDomainFilter()
   110  }
   111  
   112  func (im *DynamoDBRegistry) OwnerID() string {
   113  	return im.ownerID
   114  }
   115  
   116  // Records returns the current records from the registry.
   117  func (im *DynamoDBRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
   118  	// If we have the zones cached AND we have refreshed the cache since the
   119  	// last given interval, then just use the cached results.
   120  	if im.recordsCache != nil && time.Since(im.recordsCacheRefreshTime) < im.cacheInterval {
   121  		log.Debug("Using cached records.")
   122  		return im.recordsCache, nil
   123  	}
   124  
   125  	if im.labels == nil {
   126  		if err := im.readLabels(ctx); err != nil {
   127  			return nil, err
   128  		}
   129  	}
   130  
   131  	records, err := im.provider.Records(ctx)
   132  	if err != nil {
   133  		return nil, err
   134  	}
   135  
   136  	orphanedLabels := sets.KeySet(im.labels)
   137  	endpoints := make([]*endpoint.Endpoint, 0, len(records))
   138  	labelMap := map[endpoint.EndpointKey]endpoint.Labels{}
   139  	txtRecordsMap := map[endpoint.EndpointKey]*endpoint.Endpoint{}
   140  	for _, record := range records {
   141  		key := record.Key()
   142  		if labels := im.labels[key]; labels != nil {
   143  			record.Labels = labels
   144  			orphanedLabels.Delete(key)
   145  		} else {
   146  			record.Labels = endpoint.NewLabels()
   147  
   148  			if record.RecordType == endpoint.RecordTypeTXT {
   149  				// We simply assume that TXT records for the TXT registry will always have only one target.
   150  				if labels, err := endpoint.NewLabelsFromString(record.Targets[0], im.txtEncryptAESKey); err == nil {
   151  					endpointName, recordType := im.mapper.toEndpointName(record.DNSName)
   152  					key := endpoint.EndpointKey{
   153  						DNSName:       endpointName,
   154  						SetIdentifier: record.SetIdentifier,
   155  					}
   156  					if recordType == endpoint.RecordTypeAAAA {
   157  						key.RecordType = recordType
   158  					}
   159  					labelMap[key] = labels
   160  					txtRecordsMap[key] = record
   161  					continue
   162  				}
   163  			}
   164  		}
   165  
   166  		endpoints = append(endpoints, record)
   167  	}
   168  
   169  	im.orphanedLabels = orphanedLabels
   170  
   171  	// Migrate label data from TXT registry.
   172  	if len(labelMap) > 0 {
   173  		for _, ep := range endpoints {
   174  			if _, ok := im.labels[ep.Key()]; ok {
   175  				continue
   176  			}
   177  
   178  			dnsNameSplit := strings.Split(ep.DNSName, ".")
   179  			// If specified, replace a leading asterisk in the generated txt record name with some other string
   180  			if im.wildcardReplacement != "" && dnsNameSplit[0] == "*" {
   181  				dnsNameSplit[0] = im.wildcardReplacement
   182  			}
   183  			dnsName := strings.Join(dnsNameSplit, ".")
   184  			key := endpoint.EndpointKey{
   185  				DNSName:       dnsName,
   186  				SetIdentifier: ep.SetIdentifier,
   187  			}
   188  			if ep.RecordType == endpoint.RecordTypeAAAA {
   189  				key.RecordType = ep.RecordType
   190  			}
   191  			if labels, ok := labelMap[key]; ok {
   192  				for k, v := range labels {
   193  					ep.Labels[k] = v
   194  				}
   195  				ep.SetProviderSpecificProperty(dynamodbAttributeMigrate, "true")
   196  				delete(txtRecordsMap, key)
   197  			}
   198  		}
   199  	}
   200  
   201  	// Remove any unused TXT ownership records owned by us
   202  	if len(txtRecordsMap) > 0 && !plan.IsManagedRecord(endpoint.RecordTypeTXT, im.managedRecordTypes, im.excludeRecordTypes) {
   203  		log.Infof("Old TXT ownership records will not be deleted because \"TXT\" is not in the set of managed record types.")
   204  	}
   205  	for _, record := range txtRecordsMap {
   206  		record.Labels[endpoint.OwnerLabelKey] = im.ownerID
   207  		endpoints = append(endpoints, record)
   208  	}
   209  
   210  	// Update the cache.
   211  	if im.cacheInterval > 0 {
   212  		im.recordsCache = endpoints
   213  		im.recordsCacheRefreshTime = time.Now()
   214  	}
   215  
   216  	return endpoints, nil
   217  }
   218  
   219  // ApplyChanges updates the DNS provider and DynamoDB table with the changes.
   220  func (im *DynamoDBRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   221  	filteredChanges := &plan.Changes{
   222  		Create:    changes.Create,
   223  		UpdateNew: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateNew),
   224  		UpdateOld: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateOld),
   225  		Delete:    endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.Delete),
   226  	}
   227  
   228  	statements := make([]*dynamodb.BatchStatementRequest, 0, len(filteredChanges.Create)+len(filteredChanges.UpdateNew))
   229  	for _, r := range filteredChanges.Create {
   230  		if r.Labels == nil {
   231  			r.Labels = make(map[string]string)
   232  		}
   233  		r.Labels[endpoint.OwnerLabelKey] = im.ownerID
   234  
   235  		key := r.Key()
   236  		oldLabels := im.labels[key]
   237  		if oldLabels == nil {
   238  			statements = im.appendInsert(statements, key, r.Labels)
   239  		} else {
   240  			im.orphanedLabels.Delete(key)
   241  			statements = im.appendUpdate(statements, key, oldLabels, r.Labels)
   242  		}
   243  
   244  		im.labels[key] = r.Labels
   245  		if im.cacheInterval > 0 {
   246  			im.addToCache(r)
   247  		}
   248  	}
   249  
   250  	for _, r := range filteredChanges.Delete {
   251  		delete(im.labels, r.Key())
   252  		if im.cacheInterval > 0 {
   253  			im.removeFromCache(r)
   254  		}
   255  	}
   256  
   257  	oldLabels := make(map[endpoint.EndpointKey]endpoint.Labels, len(filteredChanges.UpdateOld))
   258  	needMigration := map[endpoint.EndpointKey]bool{}
   259  	for _, r := range filteredChanges.UpdateOld {
   260  		oldLabels[r.Key()] = r.Labels
   261  
   262  		if _, ok := r.GetProviderSpecificProperty(dynamodbAttributeMigrate); ok {
   263  			needMigration[r.Key()] = true
   264  		}
   265  
   266  		// remove old version of record from cache
   267  		if im.cacheInterval > 0 {
   268  			im.removeFromCache(r)
   269  		}
   270  	}
   271  
   272  	for _, r := range filteredChanges.UpdateNew {
   273  		key := r.Key()
   274  		if needMigration[key] {
   275  			statements = im.appendInsert(statements, key, r.Labels)
   276  			// Invalidate the records cache so the next sync deletes the TXT ownership record
   277  			im.recordsCache = nil
   278  		} else {
   279  			statements = im.appendUpdate(statements, key, oldLabels[key], r.Labels)
   280  		}
   281  
   282  		// add new version of record to caches
   283  		im.labels[key] = r.Labels
   284  		if im.cacheInterval > 0 {
   285  			im.addToCache(r)
   286  		}
   287  	}
   288  
   289  	err := im.executeStatements(ctx, statements, func(request *dynamodb.BatchStatementRequest, response *dynamodb.BatchStatementResponse) error {
   290  		var context string
   291  		if strings.HasPrefix(*request.Statement, "INSERT") {
   292  			if aws.StringValue(response.Error.Code) == "DuplicateItem" {
   293  				// We lost a race with a different owner or another owner has an orphaned ownership record.
   294  				key := fromDynamoKey(request.Parameters[0])
   295  				for i, endpoint := range filteredChanges.Create {
   296  					if endpoint.Key() == key {
   297  						log.Infof("Skipping endpoint %v because owner does not match", endpoint)
   298  						filteredChanges.Create = append(filteredChanges.Create[:i], filteredChanges.Create[i+1:]...)
   299  						// The dynamodb insertion failed; remove from our cache.
   300  						im.removeFromCache(endpoint)
   301  						delete(im.labels, key)
   302  						return nil
   303  					}
   304  				}
   305  			}
   306  			context = fmt.Sprintf("inserting dynamodb record %q", aws.StringValue(request.Parameters[0].S))
   307  		} else {
   308  			context = fmt.Sprintf("updating dynamodb record %q", aws.StringValue(request.Parameters[1].S))
   309  		}
   310  		return fmt.Errorf("%s: %s: %s", context, aws.StringValue(response.Error.Code), aws.StringValue(response.Error.Message))
   311  	})
   312  	if err != nil {
   313  		im.recordsCache = nil
   314  		im.labels = nil
   315  		return err
   316  	}
   317  
   318  	// When caching is enabled, disable the provider from using the cache.
   319  	if im.cacheInterval > 0 {
   320  		ctx = context.WithValue(ctx, provider.RecordsContextKey, nil)
   321  	}
   322  	err = im.provider.ApplyChanges(ctx, filteredChanges)
   323  	if err != nil {
   324  		im.recordsCache = nil
   325  		im.labels = nil
   326  		return err
   327  	}
   328  
   329  	statements = make([]*dynamodb.BatchStatementRequest, 0, len(filteredChanges.Delete)+len(im.orphanedLabels))
   330  	for _, r := range filteredChanges.Delete {
   331  		statements = im.appendDelete(statements, r.Key())
   332  	}
   333  	for r := range im.orphanedLabels {
   334  		statements = im.appendDelete(statements, r)
   335  		delete(im.labels, r)
   336  	}
   337  	im.orphanedLabels = nil
   338  	return im.executeStatements(ctx, statements, func(request *dynamodb.BatchStatementRequest, response *dynamodb.BatchStatementResponse) error {
   339  		im.labels = nil
   340  		return fmt.Errorf("deleting dynamodb record %q: %s: %s", aws.StringValue(request.Parameters[0].S), aws.StringValue(response.Error.Code), aws.StringValue(response.Error.Message))
   341  	})
   342  }
   343  
   344  // AdjustEndpoints modifies the endpoints as needed by the specific provider.
   345  func (im *DynamoDBRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
   346  	return im.provider.AdjustEndpoints(endpoints)
   347  }
   348  
   349  func (im *DynamoDBRegistry) readLabels(ctx context.Context) error {
   350  	table, err := im.dynamodbAPI.DescribeTableWithContext(ctx, &dynamodb.DescribeTableInput{
   351  		TableName: aws.String(im.table),
   352  	})
   353  	if err != nil {
   354  		return fmt.Errorf("describing table %q: %w", im.table, err)
   355  	}
   356  
   357  	foundKey := false
   358  	for _, def := range table.Table.AttributeDefinitions {
   359  		if aws.StringValue(def.AttributeName) == "k" {
   360  			if aws.StringValue(def.AttributeType) != "S" {
   361  				return fmt.Errorf("table %q attribute \"k\" must have type \"S\"", im.table)
   362  			}
   363  			foundKey = true
   364  		}
   365  	}
   366  	if !foundKey {
   367  		return fmt.Errorf("table %q must have attribute \"k\" of type \"S\"", im.table)
   368  	}
   369  
   370  	if aws.StringValue(table.Table.KeySchema[0].AttributeName) != "k" {
   371  		return fmt.Errorf("table %q must have hash key \"k\"", im.table)
   372  	}
   373  	if len(table.Table.KeySchema) > 1 {
   374  		return fmt.Errorf("table %q must not have a range key", im.table)
   375  	}
   376  
   377  	labels := map[endpoint.EndpointKey]endpoint.Labels{}
   378  	err = im.dynamodbAPI.ScanPagesWithContext(ctx, &dynamodb.ScanInput{
   379  		TableName:        aws.String(im.table),
   380  		FilterExpression: aws.String("o = :ownerval"),
   381  		ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
   382  			":ownerval": {S: aws.String(im.ownerID)},
   383  		},
   384  		ProjectionExpression: aws.String("k,l"),
   385  		ConsistentRead:       aws.Bool(true),
   386  	}, func(output *dynamodb.ScanOutput, last bool) bool {
   387  		for _, item := range output.Items {
   388  			labels[fromDynamoKey(item["k"])] = fromDynamoLabels(item["l"], im.ownerID)
   389  		}
   390  		return true
   391  	})
   392  	if err != nil {
   393  		return fmt.Errorf("querying dynamodb: %w", err)
   394  	}
   395  
   396  	im.labels = labels
   397  	return nil
   398  }
   399  
   400  func fromDynamoKey(key *dynamodb.AttributeValue) endpoint.EndpointKey {
   401  	split := strings.SplitN(aws.StringValue(key.S), "#", 3)
   402  	return endpoint.EndpointKey{
   403  		DNSName:       split[0],
   404  		RecordType:    split[1],
   405  		SetIdentifier: split[2],
   406  	}
   407  }
   408  
   409  func toDynamoKey(key endpoint.EndpointKey) *dynamodb.AttributeValue {
   410  	return &dynamodb.AttributeValue{
   411  		S: aws.String(fmt.Sprintf("%s#%s#%s", key.DNSName, key.RecordType, key.SetIdentifier)),
   412  	}
   413  }
   414  
   415  func fromDynamoLabels(label *dynamodb.AttributeValue, owner string) endpoint.Labels {
   416  	labels := endpoint.NewLabels()
   417  	for k, v := range label.M {
   418  		labels[k] = aws.StringValue(v.S)
   419  	}
   420  	labels[endpoint.OwnerLabelKey] = owner
   421  	return labels
   422  }
   423  
   424  func toDynamoLabels(labels endpoint.Labels) *dynamodb.AttributeValue {
   425  	labelMap := make(map[string]*dynamodb.AttributeValue, len(labels))
   426  	for k, v := range labels {
   427  		if k == endpoint.OwnerLabelKey {
   428  			continue
   429  		}
   430  		labelMap[k] = &dynamodb.AttributeValue{S: aws.String(v)}
   431  	}
   432  	return &dynamodb.AttributeValue{M: labelMap}
   433  }
   434  
   435  func (im *DynamoDBRegistry) appendInsert(statements []*dynamodb.BatchStatementRequest, key endpoint.EndpointKey, new endpoint.Labels) []*dynamodb.BatchStatementRequest {
   436  	return append(statements, &dynamodb.BatchStatementRequest{
   437  		Statement: aws.String(fmt.Sprintf("INSERT INTO %q VALUE {'k':?, 'o':?, 'l':?}", im.table)),
   438  		Parameters: []*dynamodb.AttributeValue{
   439  			toDynamoKey(key),
   440  			{S: aws.String(im.ownerID)},
   441  			toDynamoLabels(new),
   442  		},
   443  		ConsistentRead: aws.Bool(true),
   444  	})
   445  }
   446  
   447  func (im *DynamoDBRegistry) appendUpdate(statements []*dynamodb.BatchStatementRequest, key endpoint.EndpointKey, old endpoint.Labels, new endpoint.Labels) []*dynamodb.BatchStatementRequest {
   448  	if len(old) == len(new) {
   449  		equal := true
   450  		for k, v := range old {
   451  			if newV, exists := new[k]; !exists || v != newV {
   452  				equal = false
   453  				break
   454  			}
   455  		}
   456  		if equal {
   457  			return statements
   458  		}
   459  	}
   460  
   461  	return append(statements, &dynamodb.BatchStatementRequest{
   462  		Statement: aws.String(fmt.Sprintf("UPDATE %q SET \"l\"=? WHERE \"k\"=?", im.table)),
   463  		Parameters: []*dynamodb.AttributeValue{
   464  			toDynamoLabels(new),
   465  			toDynamoKey(key),
   466  		},
   467  	})
   468  }
   469  
   470  func (im *DynamoDBRegistry) appendDelete(statements []*dynamodb.BatchStatementRequest, key endpoint.EndpointKey) []*dynamodb.BatchStatementRequest {
   471  	return append(statements, &dynamodb.BatchStatementRequest{
   472  		Statement: aws.String(fmt.Sprintf("DELETE FROM %q WHERE \"k\"=? AND \"o\"=?", im.table)),
   473  		Parameters: []*dynamodb.AttributeValue{
   474  			toDynamoKey(key),
   475  			{S: aws.String(im.ownerID)},
   476  		},
   477  	})
   478  }
   479  
   480  func (im *DynamoDBRegistry) executeStatements(ctx context.Context, statements []*dynamodb.BatchStatementRequest, handleErr func(request *dynamodb.BatchStatementRequest, response *dynamodb.BatchStatementResponse) error) error {
   481  	for len(statements) > 0 {
   482  		var chunk []*dynamodb.BatchStatementRequest
   483  		if len(statements) > int(dynamodbMaxBatchSize) {
   484  			chunk = statements[:dynamodbMaxBatchSize]
   485  			statements = statements[dynamodbMaxBatchSize:]
   486  		} else {
   487  			chunk = statements
   488  			statements = nil
   489  		}
   490  
   491  		output, err := im.dynamodbAPI.BatchExecuteStatementWithContext(ctx, &dynamodb.BatchExecuteStatementInput{
   492  			Statements: chunk,
   493  		})
   494  		if err != nil {
   495  			return err
   496  		}
   497  
   498  		for i, response := range output.Responses {
   499  			request := chunk[i]
   500  			if response.Error == nil {
   501  				op, _, _ := strings.Cut(*request.Statement, " ")
   502  				var key string
   503  				if op == "UPDATE" {
   504  					key = *request.Parameters[1].S
   505  				} else {
   506  					key = *request.Parameters[0].S
   507  				}
   508  				log.Infof("%s dynamodb record %q", op, key)
   509  			} else {
   510  				if err := handleErr(request, response); err != nil {
   511  					return err
   512  				}
   513  			}
   514  		}
   515  	}
   516  	return nil
   517  }
   518  
   519  func (im *DynamoDBRegistry) addToCache(ep *endpoint.Endpoint) {
   520  	if im.recordsCache != nil {
   521  		im.recordsCache = append(im.recordsCache, ep)
   522  	}
   523  }
   524  
   525  func (im *DynamoDBRegistry) removeFromCache(ep *endpoint.Endpoint) {
   526  	if im.recordsCache == nil || ep == nil {
   527  		return
   528  	}
   529  
   530  	for i, e := range im.recordsCache {
   531  		if e.DNSName == ep.DNSName && e.RecordType == ep.RecordType && e.SetIdentifier == ep.SetIdentifier && e.Targets.Same(ep.Targets) {
   532  			// We found a match; delete the endpoint from the cache.
   533  			im.recordsCache = append(im.recordsCache[:i], im.recordsCache[i+1:]...)
   534  			return
   535  		}
   536  	}
   537  }