Merge pull request #3973 from c445/roehrijn/aws-profiles

feat(aws): use AWS profiles using .credentials file
This commit is contained in:
Kubernetes Prow Robot 2024-06-10 13:59:23 -07:00 committed by GitHub
commit 0ba14d8873
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 288 additions and 100 deletions

View File

@ -1,6 +1,6 @@
# Setting up ExternalDNS for Services on AWS
This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster on AWS. Make sure to use **>=0.11.0** version of ExternalDNS for this tutorial
This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster on AWS. Make sure to use **>=0.15.0** version of ExternalDNS for this tutorial
## IAM Policy
@ -232,6 +232,14 @@ kubectl create secret generic external-dns \
Follow the steps under [Deploy ExternalDNS](#deploy-externaldns) using either RBAC or non-RBAC. Make sure to uncomment the section that mounts volumes, so that the credentials can be mounted.
> [!TIP]
> By default ExternalDNS takes the profile named `default` from the credentials file. If you want to use a different
> profile, you can set the environment variable `EXTERNAL_DNS_AWS_PROFILE` to the desired profile name or use the
> `--aws-profile` command line argument. It is even possible to use more than one profile at ones, separated by space in
> the environment variable `EXTERNAL_DNS_AWS_PROFILE` or by using `--aws-profile` multiple times. In this case
> ExternalDNS looks for the hosted zones in all profiles and keeps maintaining a mapping table between zone and profile
> in order to be able to modify the zones in the correct profile.
### IAM Roles for Service Accounts
[IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) ([IAM roles for Service Accounts](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html)) allows cluster operators to map AWS IAM Roles to Kubernetes Service Accounts. This essentially allows only ExternalDNS pods to access Route53 without exposing any static credentials.

27
main.go
View File

@ -26,7 +26,6 @@ import (
"time"
awsSDK "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/route53"
sd "github.com/aws/aws-sdk-go/service/servicediscovery"
@ -194,20 +193,6 @@ func main() {
zoneTypeFilter := provider.NewZoneTypeFilter(cfg.AWSZoneType)
zoneTagFilter := provider.NewZoneTagFilter(cfg.AWSZoneTagFilter)
var awsSession *session.Session
if cfg.Provider == "aws" || cfg.Provider == "aws-sd" || cfg.Registry == "dynamodb" {
awsSession, err = aws.NewSession(
aws.AWSSessionConfig{
AssumeRole: cfg.AWSAssumeRole,
AssumeRoleExternalID: cfg.AWSAssumeRoleExternalID,
APIRetries: cfg.AWSAPIRetries,
},
)
if err != nil {
log.Fatal(err)
}
}
var p provider.Provider
switch cfg.Provider {
case "akamai":
@ -226,6 +211,12 @@ func main() {
case "alibabacloud":
p, err = alibabacloud.NewAlibabaCloudProvider(cfg.AlibabaCloudConfigFile, domainFilter, zoneIDFilter, cfg.AlibabaCloudZoneType, cfg.DryRun)
case "aws":
sessions := aws.CreateSessions(cfg)
clients := make(map[string]aws.Route53API, len(sessions))
for profile, session := range sessions {
clients[profile] = route53.New(session)
}
p, err = aws.NewAWSProvider(
aws.AWSConfig{
DomainFilter: domainFilter,
@ -242,7 +233,7 @@ func main() {
DryRun: cfg.DryRun,
ZoneCacheDuration: cfg.AWSZoneCacheDuration,
},
route53.New(awsSession),
clients,
)
case "aws-sd":
// Check that only compatible Registry is used with AWS-SD
@ -250,7 +241,7 @@ func main() {
log.Infof("Registry \"%s\" cannot be used with AWS Cloud Map. Switching to \"aws-sd\".", cfg.Registry)
cfg.Registry = "aws-sd"
}
p, err = awssd.NewAWSSDProvider(domainFilter, cfg.AWSZoneType, cfg.DryRun, cfg.AWSSDServiceCleanup, cfg.TXTOwnerID, sd.New(awsSession))
p, err = awssd.NewAWSSDProvider(domainFilter, cfg.AWSZoneType, cfg.DryRun, cfg.AWSSDServiceCleanup, cfg.TXTOwnerID, sd.New(aws.CreateDefaultSession(cfg)))
case "azure-dns", "azure":
p, err = azure.NewAzureProvider(cfg.AzureConfigFile, domainFilter, zoneNameFilter, zoneIDFilter, cfg.AzureSubscriptionID, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.AzureActiveDirectoryAuthorityHost, cfg.DryRun)
case "azure-private-dns":
@ -417,7 +408,7 @@ func main() {
if cfg.AWSDynamoDBRegion != "" {
config = config.WithRegion(cfg.AWSDynamoDBRegion)
}
r, err = registry.NewDynamoDBRegistry(p, cfg.TXTOwnerID, dynamodb.New(awsSession, config), cfg.AWSDynamoDBTable, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, []byte(cfg.TXTEncryptAESKey), cfg.TXTCacheInterval)
r, err = registry.NewDynamoDBRegistry(p, cfg.TXTOwnerID, dynamodb.New(aws.CreateDefaultSession(cfg), config), cfg.AWSDynamoDBTable, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, []byte(cfg.TXTEncryptAESKey), cfg.TXTCacheInterval)
case "noop":
r, err = registry.NewNoopRegistry(p)
case "txt":

View File

@ -84,6 +84,7 @@ type Config struct {
AWSZoneType string
AWSZoneTagFilter []string
AWSAssumeRole string
AWSProfiles []string
AWSAssumeRoleExternalID string
AWSBatchChangeSize int
AWSBatchChangeSizeBytes int
@ -468,6 +469,7 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("alibaba-cloud-zone-type", "When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AlibabaCloudZoneType).EnumVar(&cfg.AlibabaCloudZoneType, "", "public", "private")
app.Flag("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AWSZoneType).EnumVar(&cfg.AWSZoneType, "", "public", "private")
app.Flag("aws-zone-tags", "When using the AWS provider, filter for zones with these tags").Default("").StringsVar(&cfg.AWSZoneTagFilter)
app.Flag("aws-profile", "When using the AWS provider, name of the profile to use").Default("").StringsVar(&cfg.AWSProfiles)
app.Flag("aws-assume-role", "When using the AWS API, assume this IAM role. Useful for hosted zones in another AWS account. Specify the full ARN, e.g. `arn:aws:iam::123455567:role/external-dns` (optional)").Default(defaultConfig.AWSAssumeRole).StringVar(&cfg.AWSAssumeRole)
app.Flag("aws-assume-role-external-id", "When using the AWS API and assuming a role then specify this external ID` (optional)").Default(defaultConfig.AWSAssumeRoleExternalID).StringVar(&cfg.AWSAssumeRoleExternalID)
app.Flag("aws-batch-change-size", "When using the AWS provider, set the maximum number of changes that will be applied in each batch.").Default(strconv.Itoa(defaultConfig.AWSBatchChangeSize)).IntVar(&cfg.AWSBatchChangeSize)

View File

@ -65,6 +65,7 @@ var (
AWSEvaluateTargetHealth: true,
AWSAPIRetries: 3,
AWSPreferCNAME: false,
AWSProfiles: []string{""},
AWSZoneCacheDuration: 0 * time.Second,
AWSSDServiceCleanup: false,
AWSDynamoDBTable: "external-dns",
@ -171,6 +172,7 @@ var (
AWSEvaluateTargetHealth: false,
AWSAPIRetries: 13,
AWSPreferCNAME: true,
AWSProfiles: []string{"profile1", "profile2"},
AWSZoneCacheDuration: 10 * time.Second,
AWSSDServiceCleanup: true,
AWSDynamoDBTable: "custom-table",
@ -342,6 +344,8 @@ func TestParseFlags(t *testing.T) {
"--aws-batch-change-interval=2s",
"--aws-api-retries=13",
"--aws-prefer-cname",
"--aws-profile=profile1",
"--aws-profile=profile2",
"--aws-zones-cache-duration=10s",
"--aws-sd-service-cleanup",
"--no-aws-evaluate-target-health",
@ -460,6 +464,7 @@ func TestParseFlags(t *testing.T) {
"EXTERNAL_DNS_AWS_EVALUATE_TARGET_HEALTH": "0",
"EXTERNAL_DNS_AWS_API_RETRIES": "13",
"EXTERNAL_DNS_AWS_PREFER_CNAME": "true",
"EXTERNAL_DNS_AWS_PROFILE": "profile1\nprofile2",
"EXTERNAL_DNS_AWS_ZONES_CACHE_DURATION": "10s",
"EXTERNAL_DNS_AWS_SD_SERVICE_CLEANUP": "true",
"EXTERNAL_DNS_DYNAMODB_TABLE": "custom-table",

View File

@ -18,6 +18,7 @@ package aws
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
@ -25,6 +26,7 @@ import (
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/service/route53"
log "github.com/sirupsen/logrus"
@ -35,7 +37,8 @@ import (
)
const (
recordTTL = 300
defaultAWSProfile = "default"
recordTTL = 300
// From the experiments, it seems that the default MaxItems applied is 100,
// and that, on the server side, there is a hard limit of 300 elements per page.
// After a discussion with AWS representants, clients should accept
@ -213,6 +216,11 @@ type Route53Change struct {
type Route53Changes []*Route53Change
type profiledZone struct {
profile string
zone *route53.HostedZone
}
func (cs Route53Changes) Route53Changes() []*route53.Change {
ret := []*route53.Change{}
for _, c := range cs {
@ -224,13 +232,13 @@ func (cs Route53Changes) Route53Changes() []*route53.Change {
type zonesListCache struct {
age time.Time
duration time.Duration
zones map[string]*route53.HostedZone
zones map[string]*profiledZone
}
// AWSProvider is an implementation of Provider for AWS Route53.
type AWSProvider struct {
provider.BaseProvider
client Route53API
clients map[string]Route53API
dryRun bool
batchChangeSize int
batchChangeSizeBytes int
@ -271,9 +279,9 @@ type AWSConfig struct {
}
// NewAWSProvider initializes a new AWS Route53 based Provider.
func NewAWSProvider(awsConfig AWSConfig, client Route53API) (*AWSProvider, error) {
func NewAWSProvider(awsConfig AWSConfig, clients map[string]Route53API) (*AWSProvider, error) {
provider := &AWSProvider{
client: client,
clients: clients,
domainFilter: awsConfig.DomainFilter,
zoneIDFilter: awsConfig.ZoneIDFilter,
zoneTypeFilter: awsConfig.ZoneTypeFilter,
@ -295,14 +303,28 @@ func NewAWSProvider(awsConfig AWSConfig, client Route53API) (*AWSProvider, error
// Zones returns the list of hosted zones.
func (p *AWSProvider) Zones(ctx context.Context) (map[string]*route53.HostedZone, error) {
zones, err := p.zones(ctx)
if err != nil {
return nil, err
}
result := make(map[string]*route53.HostedZone, len(zones))
for id, zone := range zones {
result[id] = zone.zone
}
return result, nil
}
// zones returns the list of zones per AWS profile
func (p *AWSProvider) zones(ctx context.Context) (map[string]*profiledZone, error) {
if p.zonesCache.zones != nil && time.Since(p.zonesCache.age) < p.zonesCache.duration {
log.Debug("Using cached zones list")
return p.zonesCache.zones, nil
}
log.Debug("Refreshing zones list cache")
zones := make(map[string]*route53.HostedZone)
zones := make(map[string]*profiledZone)
var profile string
var tagErr error
f := func(resp *route53.ListHostedZonesOutput, lastPage bool) (shouldContinue bool) {
for _, zone := range resp.HostedZones {
@ -325,7 +347,7 @@ func (p *AWSProvider) Zones(ctx context.Context) (map[string]*route53.HostedZone
// Only fetch tags if a tag filter was specified
if !p.zoneTagFilter.IsEmpty() {
tags, err := p.tagsForZone(ctx, *zone.Id)
tags, err := p.tagsForZone(ctx, *zone.Id, profile)
if err != nil {
tagErr = err
return false
@ -335,22 +357,36 @@ func (p *AWSProvider) Zones(ctx context.Context) (map[string]*route53.HostedZone
}
}
zones[aws.StringValue(zone.Id)] = zone
zones[aws.StringValue(zone.Id)] = &profiledZone{
profile: profile,
zone: zone,
}
}
return true
}
err := p.client.ListHostedZonesPagesWithContext(ctx, &route53.ListHostedZonesInput{}, f)
if err != nil {
return nil, provider.NewSoftError(fmt.Errorf("failed to list hosted zones: %w", err))
}
if tagErr != nil {
return nil, provider.NewSoftError(fmt.Errorf("failed to list zones tags: %w", tagErr))
for p, client := range p.clients {
profile = p
err := client.ListHostedZonesPagesWithContext(ctx, &route53.ListHostedZonesInput{}, f)
if err != nil {
var awsErr awserr.Error
if errors.As(err, &awsErr) {
if awsErr.Code() == route53.ErrCodeThrottlingException {
log.Warnf("Skipping AWS profile %q due to provider side throttling: %v", profile, awsErr.Message())
continue
}
// nothing to do here. Falling through to general error handling
}
return nil, provider.NewSoftError(fmt.Errorf("failed to list hosted zones: %w", err))
}
if tagErr != nil {
return nil, provider.NewSoftError(fmt.Errorf("failed to list zones tags: %w", tagErr))
}
}
for _, zone := range zones {
log.Debugf("Considering zone: %s (domain: %s)", aws.StringValue(zone.Id), aws.StringValue(zone.Name))
log.Debugf("Considering zone: %s (domain: %s)", aws.StringValue(zone.zone.Id), aws.StringValue(zone.zone.Name))
}
if p.zonesCache.duration > time.Duration(0) {
@ -369,7 +405,7 @@ func wildcardUnescape(s string) string {
// Records returns the list of records in a given hosted zone.
func (p *AWSProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) {
zones, err := p.Zones(ctx)
zones, err := p.zones(ctx)
if err != nil {
return nil, provider.NewSoftError(fmt.Errorf("records retrieval failed: %w", err))
}
@ -377,7 +413,7 @@ func (p *AWSProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoi
return p.records(ctx, zones)
}
func (p *AWSProvider) records(ctx context.Context, zones map[string]*route53.HostedZone) ([]*endpoint.Endpoint, error) {
func (p *AWSProvider) records(ctx context.Context, zones map[string]*profiledZone) ([]*endpoint.Endpoint, error) {
endpoints := make([]*endpoint.Endpoint, 0)
f := func(resp *route53.ListResourceRecordSetsOutput, lastPage bool) (shouldContinue bool) {
for _, r := range resp.ResourceRecordSets {
@ -458,12 +494,13 @@ func (p *AWSProvider) records(ctx context.Context, zones map[string]*route53.Hos
for _, z := range zones {
params := &route53.ListResourceRecordSetsInput{
HostedZoneId: z.Id,
HostedZoneId: z.zone.Id,
MaxItems: aws.String(route53PageSize),
}
if err := p.client.ListResourceRecordSetsPagesWithContext(ctx, params, f); err != nil {
return nil, provider.NewSoftError(fmt.Errorf("failed to list resource records sets for zone %s: %w", *z.Id, err))
client := p.clients[z.profile]
if err := client.ListResourceRecordSetsPagesWithContext(ctx, params, f); err != nil {
return nil, fmt.Errorf("failed to list resource records sets for zone %s using aws profile %q: %w", *z.zone.Id, z.profile, err)
}
}
@ -546,7 +583,7 @@ func (p *AWSProvider) GetDomainFilter() endpoint.DomainFilter {
// ApplyChanges applies a given set of changes in a given zone.
func (p *AWSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
zones, err := p.Zones(ctx)
zones, err := p.zones(ctx)
if err != nil {
return provider.NewSoftError(fmt.Errorf("failed to list zones, not applying changes: %w", err))
}
@ -562,7 +599,7 @@ func (p *AWSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) e
}
// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.
func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes, zones map[string]*route53.HostedZone) error {
func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes, zones map[string]*profiledZone) error {
// return early if there is nothing to change
if len(changes) == 0 {
log.Info("All records are already up to date")
@ -577,6 +614,12 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
var failedZones []string
for z, cs := range changesByZone {
log := log.WithFields(log.Fields{
"zoneName": aws.StringValue(zones[z].zone.Name),
"zoneID": z,
"profile": zones[z].profile,
})
var failedUpdate bool
// group changes into new changes and into changes that failed in a previous iteration and are retried
@ -591,7 +634,7 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
}
for _, c := range b {
log.Infof("Desired change: %s %s %s [Id: %s]", *c.Action, *c.ResourceRecordSet.Name, *c.ResourceRecordSet.Type, z)
log.Infof("Desired change: %s %s %s", *c.Action, *c.ResourceRecordSet.Name, *c.ResourceRecordSet.Type)
}
if !p.dryRun {
@ -604,8 +647,9 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
successfulChanges := 0
if _, err := p.client.ChangeResourceRecordSetsWithContext(ctx, params); err != nil {
log.Errorf("Failure in zone %s [Id: %s] when submitting change batch: %v", aws.StringValue(zones[z].Name), z, err)
client := p.clients[zones[z].profile]
if _, err := client.ChangeResourceRecordSetsWithContext(ctx, params); err != nil {
log.Errorf("Failure in zone %s when submitting change batch: %v", aws.StringValue(zones[z].zone.Name), err)
changesByOwnership := groupChangesByNameAndOwnershipRelation(b)
@ -614,12 +658,12 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
for _, changes := range changesByOwnership {
for _, c := range changes {
log.Debugf("Desired change: %s %s %s [Id: %s]", *c.Action, *c.ResourceRecordSet.Name, *c.ResourceRecordSet.Type, z)
log.Debugf("Desired change: %s %s %s", *c.Action, *c.ResourceRecordSet.Name, *c.ResourceRecordSet.Type)
}
params.ChangeBatch = &route53.ChangeBatch{
Changes: changes.Route53Changes(),
}
if _, err := p.client.ChangeResourceRecordSetsWithContext(ctx, params); err != nil {
if _, err := client.ChangeResourceRecordSetsWithContext(ctx, params); err != nil {
failedUpdate = true
log.Errorf("Failed submitting change (error: %v), it will be retried in a separate change batch in the next iteration", err)
p.failedChangesQueue[z] = append(p.failedChangesQueue[z], changes...)
@ -636,7 +680,7 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
if successfulChanges > 0 {
// z is the R53 Hosted Zone ID already as aws.StringValue
log.Infof("%d record(s) in zone %s [Id: %s] were successfully updated", successfulChanges, aws.StringValue(zones[z].Name), z)
log.Infof("%d record(s) were successfully updated", successfulChanges)
}
if i != len(batchCs)-1 {
@ -871,8 +915,10 @@ func groupChangesByNameAndOwnershipRelation(cs Route53Changes) map[string]Route5
return changesByOwnership
}
func (p *AWSProvider) tagsForZone(ctx context.Context, zoneID string) (map[string]string, error) {
response, err := p.client.ListTagsForResourceWithContext(ctx, &route53.ListTagsForResourceInput{
func (p *AWSProvider) tagsForZone(ctx context.Context, zoneID string, profile string) (map[string]string, error) {
client := p.clients[profile]
response, err := client.ListTagsForResourceWithContext(ctx, &route53.ListTagsForResourceInput{
ResourceType: aws.String("hostedzone"),
ResourceId: aws.String(zoneID),
})
@ -979,11 +1025,11 @@ func sortChangesByActionNameType(cs Route53Changes) Route53Changes {
}
// changesByZone separates a multi-zone change into a single change per zone.
func changesByZone(zones map[string]*route53.HostedZone, changeSet Route53Changes) map[string]Route53Changes {
func changesByZone(zones map[string]*profiledZone, changeSet Route53Changes) map[string]Route53Changes {
changes := make(map[string]Route53Changes)
for _, z := range zones {
changes[aws.StringValue(z.Id)] = Route53Changes{}
changes[aws.StringValue(z.zone.Id)] = Route53Changes{}
}
for _, c := range changeSet {
@ -1000,7 +1046,7 @@ func changesByZone(zones map[string]*route53.HostedZone, changeSet Route53Change
// if it's not, this will fail
rrset := *c.ResourceRecordSet
aliasTarget := *rrset.AliasTarget
aliasTarget.HostedZoneId = aws.String(cleanZoneID(aws.StringValue(z.Id)))
aliasTarget.HostedZoneId = aws.String(cleanZoneID(aws.StringValue(z.zone.Id)))
rrset.AliasTarget = &aliasTarget
c = &Route53Change{
Change: route53.Change{
@ -1009,8 +1055,8 @@ func changesByZone(zones map[string]*route53.HostedZone, changeSet Route53Change
},
}
}
changes[aws.StringValue(z.Id)] = append(changes[aws.StringValue(z.Id)], c)
log.Debugf("Adding %s to zone %s [Id: %s]", hostname, aws.StringValue(z.Name), aws.StringValue(z.Id))
changes[aws.StringValue(z.zone.Id)] = append(changes[aws.StringValue(z.zone.Id)], c)
log.Debugf("Adding %s to zone %s [Id: %s]", hostname, aws.StringValue(z.zone.Name), aws.StringValue(z.zone.Id))
}
}
@ -1027,15 +1073,15 @@ func changesByZone(zones map[string]*route53.HostedZone, changeSet Route53Change
// suitableZones returns all suitable private zones and the most suitable public zone
//
// for a given hostname and a set of zones.
func suitableZones(hostname string, zones map[string]*route53.HostedZone) []*route53.HostedZone {
var matchingZones []*route53.HostedZone
var publicZone *route53.HostedZone
func suitableZones(hostname string, zones map[string]*profiledZone) []*profiledZone {
var matchingZones []*profiledZone
var publicZone *profiledZone
for _, z := range zones {
if aws.StringValue(z.Name) == hostname || strings.HasSuffix(hostname, "."+aws.StringValue(z.Name)) {
if z.Config == nil || !aws.BoolValue(z.Config.PrivateZone) {
if aws.StringValue(z.zone.Name) == hostname || strings.HasSuffix(hostname, "."+aws.StringValue(z.zone.Name)) {
if z.zone.Config == nil || !aws.BoolValue(z.zone.Config.PrivateZone) {
// Only select the best matching public zone
if publicZone == nil || len(aws.StringValue(z.Name)) > len(aws.StringValue(publicZone.Name)) {
if publicZone == nil || len(aws.StringValue(z.zone.Name)) > len(aws.StringValue(publicZone.zone.Name)) {
publicZone = z
}
} else {

View File

@ -750,14 +750,14 @@ func TestAWSApplyChanges(t *testing.T) {
ctx := tt.setup(provider)
provider.zonesCache = &zonesListCache{duration: 0 * time.Minute}
counter := NewRoute53APICounter(provider.client)
provider.client = counter
counter := NewRoute53APICounter(provider.clients[defaultAWSProfile])
provider.clients[defaultAWSProfile] = counter
require.NoError(t, provider.ApplyChanges(ctx, changes))
assert.Equal(t, 1, counter.calls["ListHostedZonesPages"], tt.name)
assert.Equal(t, tt.listRRSets, counter.calls["ListResourceRecordSetsPages"], tt.name)
validateRecords(t, listAWSRecords(t, provider.client, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."), []*route53.ResourceRecordSet{
validateRecords(t, listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."), []*route53.ResourceRecordSet{
{
Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."),
Type: aws.String(route53.RRTypeA),
@ -854,7 +854,7 @@ func TestAWSApplyChanges(t *testing.T) {
ResourceRecords: []*route53.ResourceRecord{{Value: aws.String("10 mailhost1.foo.elb.amazonaws.com")}},
},
})
validateRecords(t, listAWSRecords(t, provider.client, "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do."), []*route53.ResourceRecordSet{
validateRecords(t, listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do."), []*route53.ResourceRecordSet{
{
Name: aws.String("create-test.zone-2.ext-dns-test-2.teapot.zalan.do."),
Type: aws.String(route53.RRTypeA),
@ -1023,8 +1023,8 @@ func TestAWSApplyChangesDryRun(t *testing.T) {
validateRecords(t,
append(
listAWSRecords(t, provider.client, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."),
listAWSRecords(t, provider.client, "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.")...),
listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."),
listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.")...),
originalRecords)
}
@ -1064,23 +1064,35 @@ func TestAWSChangesByZones(t *testing.T) {
},
}
zones := map[string]*route53.HostedZone{
zones := map[string]*profiledZone{
"foo-example-org": {
Id: aws.String("foo-example-org"),
Name: aws.String("foo.example.org."),
profile: defaultAWSProfile,
zone: &route53.HostedZone{
Id: aws.String("foo-example-org"),
Name: aws.String("foo.example.org."),
},
},
"bar-example-org": {
Id: aws.String("bar-example-org"),
Name: aws.String("bar.example.org."),
profile: defaultAWSProfile,
zone: &route53.HostedZone{
Id: aws.String("bar-example-org"),
Name: aws.String("bar.example.org."),
},
},
"bar-example-org-private": {
Id: aws.String("bar-example-org-private"),
Name: aws.String("bar.example.org."),
Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(true)},
profile: defaultAWSProfile,
zone: &route53.HostedZone{
Id: aws.String("bar-example-org-private"),
Name: aws.String("bar.example.org."),
Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(true)},
},
},
"baz-example-org": {
Id: aws.String("baz-example-org"),
Name: aws.String("baz.example.org."),
profile: defaultAWSProfile,
zone: &route53.HostedZone{
Id: aws.String("baz-example-org"),
Name: aws.String("baz.example.org."),
},
},
}
@ -1161,7 +1173,7 @@ func TestAWSsubmitChanges(t *testing.T) {
}
ctx := context.Background()
zones, _ := provider.Zones(ctx)
zones, _ := provider.zones(ctx)
records, _ := provider.Records(ctx)
cs := make(Route53Changes, 0, len(endpoints))
cs = append(cs, provider.newChanges(route53.ChangeActionCreate, endpoints)...)
@ -1179,7 +1191,7 @@ func TestAWSsubmitChangesError(t *testing.T) {
clientStub.MockMethod("ChangeResourceRecordSets", mock.Anything).Return(nil, fmt.Errorf("Mock route53 failure"))
ctx := context.Background()
zones, err := provider.Zones(ctx)
zones, err := provider.zones(ctx)
require.NoError(t, err)
ep := endpoint.NewEndpointWithTTL("fail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.0.0.1")
@ -1192,7 +1204,7 @@ func TestAWSsubmitChangesRetryOnError(t *testing.T) {
provider, clientStub := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, nil)
ctx := context.Background()
zones, err := provider.Zones(ctx)
zones, err := provider.zones(ctx)
require.NoError(t, err)
ep1 := endpoint.NewEndpointWithTTL("success.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.0.0.1")
@ -1636,7 +1648,7 @@ func TestAWSCreateRecordsWithCNAME(t *testing.T) {
Create: adjusted,
}))
recordSets := listAWSRecords(t, provider.client, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.")
recordSets := listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.")
validateRecords(t, recordSets, []*route53.ResourceRecordSet{
{
@ -1700,7 +1712,7 @@ func TestAWSCreateRecordsWithALIAS(t *testing.T) {
Create: adjusted,
}))
recordSets := listAWSRecords(t, provider.client, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.")
recordSets := listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.")
validateRecords(t, recordSets, []*route53.ResourceRecordSet{
{
@ -1789,40 +1801,40 @@ func TestAWSCanonicalHostedZone(t *testing.T) {
}
func TestAWSSuitableZones(t *testing.T) {
zones := map[string]*route53.HostedZone{
zones := map[string]*profiledZone{
// Public domain
"example-org": {Id: aws.String("example-org"), Name: aws.String("example.org.")},
"example-org": {profile: defaultAWSProfile, zone: &route53.HostedZone{Id: aws.String("example-org"), Name: aws.String("example.org.")}},
// Public subdomain
"bar-example-org": {Id: aws.String("bar-example-org"), Name: aws.String("bar.example.org."), Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(false)}},
"bar-example-org": {profile: defaultAWSProfile, zone: &route53.HostedZone{Id: aws.String("bar-example-org"), Name: aws.String("bar.example.org."), Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(false)}}},
// Public subdomain
"longfoo-bar-example-org": {Id: aws.String("longfoo-bar-example-org"), Name: aws.String("longfoo.bar.example.org.")},
"longfoo-bar-example-org": {profile: defaultAWSProfile, zone: &route53.HostedZone{Id: aws.String("longfoo-bar-example-org"), Name: aws.String("longfoo.bar.example.org.")}},
// Private domain
"example-org-private": {Id: aws.String("example-org-private"), Name: aws.String("example.org."), Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(true)}},
"example-org-private": {profile: defaultAWSProfile, zone: &route53.HostedZone{Id: aws.String("example-org-private"), Name: aws.String("example.org."), Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(true)}}},
// Private subdomain
"bar-example-org-private": {Id: aws.String("bar-example-org-private"), Name: aws.String("bar.example.org."), Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(true)}},
"bar-example-org-private": {profile: defaultAWSProfile, zone: &route53.HostedZone{Id: aws.String("bar-example-org-private"), Name: aws.String("bar.example.org."), Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(true)}}},
}
for _, tc := range []struct {
hostname string
expected []*route53.HostedZone
expected []*profiledZone
}{
// bar.example.org is NOT suitable
{"foobar.example.org.", []*route53.HostedZone{zones["example-org-private"], zones["example-org"]}},
{"foobar.example.org.", []*profiledZone{zones["example-org-private"], zones["example-org"]}},
// all matching private zones are suitable
// https://github.com/kubernetes-sigs/external-dns/pull/356
{"bar.example.org.", []*route53.HostedZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}},
{"bar.example.org.", []*profiledZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}},
{"foo.bar.example.org.", []*route53.HostedZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}},
{"foo.example.org.", []*route53.HostedZone{zones["example-org-private"], zones["example-org"]}},
{"foo.bar.example.org.", []*profiledZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}},
{"foo.example.org.", []*profiledZone{zones["example-org-private"], zones["example-org"]}},
{"foo.kubernetes.io.", nil},
} {
suitableZones := suitableZones(tc.hostname, zones)
sort.Slice(suitableZones, func(i, j int) bool {
return *suitableZones[i].Id < *suitableZones[j].Id
return *suitableZones[i].zone.Id < *suitableZones[j].zone.Id
})
sort.Slice(tc.expected, func(i, j int) bool {
return *tc.expected[i].Id < *tc.expected[j].Id
return *tc.expected[i].zone.Id < *tc.expected[j].zone.Id
})
assert.Equal(t, tc.expected, suitableZones)
}
@ -1835,7 +1847,7 @@ func createAWSZone(t *testing.T, provider *AWSProvider, zone *route53.HostedZone
HostedZoneConfig: zone.Config,
}
if _, err := provider.client.CreateHostedZoneWithContext(context.Background(), params); err != nil {
if _, err := provider.clients[defaultAWSProfile].CreateHostedZoneWithContext(context.Background(), params); err != nil {
require.EqualError(t, err, route53.ErrCodeHostedZoneAlreadyExists)
}
}
@ -1863,7 +1875,7 @@ func setAWSRecords(t *testing.T, provider *AWSProvider, records []*route53.Resou
})
}
zones, err := provider.Zones(ctx)
zones, err := provider.zones(ctx)
require.NoError(t, err)
err = provider.submitChanges(ctx, changes, zones)
require.NoError(t, err)
@ -1893,7 +1905,7 @@ func newAWSProviderWithTagFilter(t *testing.T, domainFilter endpoint.DomainFilte
client := NewRoute53APIStub(t)
provider := &AWSProvider{
client: client,
clients: map[string]Route53API{defaultAWSProfile: client},
batchChangeSize: defaultBatchChangeSize,
batchChangeSizeBytes: defaultBatchChangeSizeBytes,
batchChangeSizeValues: defaultBatchChangeSizeValues,
@ -1933,7 +1945,7 @@ func newAWSProviderWithTagFilter(t *testing.T, domainFilter endpoint.DomainFilte
Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(false)},
})
setupZoneTags(provider.client.(*Route53APIStub))
setupZoneTags(provider.clients[defaultAWSProfile].(*Route53APIStub))
setAWSRecords(t, provider, records)

View File

@ -35,9 +35,58 @@ type AWSSessionConfig struct {
AssumeRole string
AssumeRoleExternalID string
APIRetries int
Profile string
}
func NewSession(awsConfig AWSSessionConfig) (*session.Session, error) {
func CreateDefaultSession(cfg *externaldns.Config) *session.Session {
result, err := newSession(
AWSSessionConfig{
AssumeRole: cfg.AWSAssumeRole,
AssumeRoleExternalID: cfg.AWSAssumeRoleExternalID,
APIRetries: cfg.AWSAPIRetries,
},
)
if err != nil {
logrus.Fatal(err)
}
return result
}
func CreateSessions(cfg *externaldns.Config) map[string]*session.Session {
result := make(map[string]*session.Session)
if len(cfg.AWSProfiles) == 0 || (len(cfg.AWSProfiles) == 1 && cfg.AWSProfiles[0] == "") {
session, err := newSession(
AWSSessionConfig{
AssumeRole: cfg.AWSAssumeRole,
AssumeRoleExternalID: cfg.AWSAssumeRoleExternalID,
APIRetries: cfg.AWSAPIRetries,
},
)
if err != nil {
logrus.Fatal(err)
}
result[defaultAWSProfile] = session
} else {
for _, profile := range cfg.AWSProfiles {
session, err := newSession(
AWSSessionConfig{
AssumeRole: cfg.AWSAssumeRole,
AssumeRoleExternalID: cfg.AWSAssumeRoleExternalID,
APIRetries: cfg.AWSAPIRetries,
Profile: profile,
},
)
if err != nil {
logrus.Fatal(err)
}
result[profile] = session
}
}
return result
}
func newSession(awsConfig AWSSessionConfig) (*session.Session, error) {
config := aws.NewConfig().WithMaxRetries(awsConfig.APIRetries)
config.WithHTTPClient(
@ -52,6 +101,7 @@ func NewSession(awsConfig AWSSessionConfig) (*session.Session, error) {
session, err := session.NewSessionWithOptions(session.Options{
Config: *config,
SharedConfigState: session.SharedConfigEnable,
Profile: awsConfig.Profile,
})
if err != nil {
return nil, fmt.Errorf("instantiating AWS session: %w", err)

View File

@ -0,0 +1,74 @@
/*
Copyright 2023 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package aws
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_newSession(t *testing.T) {
t.Run("should use profile from credentials file", func(t *testing.T) {
// setup
credsFile, err := prepareCredentialsFile(t)
defer os.Remove(credsFile.Name())
require.NoError(t, err)
os.Setenv("AWS_SHARED_CREDENTIALS_FILE", credsFile.Name())
defer os.Unsetenv("AWS_SHARED_CREDENTIALS_FILE")
// when
s, err := newSession(AWSSessionConfig{Profile: "profile2"})
require.NoError(t, err)
creds, err := s.Config.Credentials.Get()
// then
assert.NoError(t, err)
assert.Equal(t, "AKID2345", creds.AccessKeyID)
assert.Equal(t, "SECRET2", creds.SecretAccessKey)
})
t.Run("should respect env variables without profile", func(t *testing.T) {
// setup
os.Setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
os.Setenv("AWS_SECRET_ACCESS_KEY", "topsecret")
defer os.Unsetenv("AWS_ACCESS_KEY_ID")
defer os.Unsetenv("AWS_SECRET_ACCESS_KEY")
// when
s, err := newSession(AWSSessionConfig{})
require.NoError(t, err)
creds, err := s.Config.Credentials.Get()
// then
assert.NoError(t, err)
assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", creds.AccessKeyID)
assert.Equal(t, "topsecret", creds.SecretAccessKey)
})
}
func prepareCredentialsFile(t *testing.T) (*os.File, error) {
credsFile, err := os.CreateTemp("", "aws-*.creds")
require.NoError(t, err)
_, err = credsFile.WriteString("[profile1]\naws_access_key_id=AKID1234\naws_secret_access_key=SECRET1\n\n[profile2]\naws_access_key_id=AKID2345\naws_secret_access_key=SECRET2\n")
require.NoError(t, err)
err = credsFile.Close()
require.NoError(t, err)
return credsFile, err
}