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 }