Skip to content

Commit

Permalink
Allow usage of (multiple) AWS profiles using .credentials file
Browse files Browse the repository at this point in the history
  • Loading branch information
roehrijn committed Oct 5, 2023
1 parent 4eb7e75 commit 2815f48
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 96 deletions.
34 changes: 28 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,26 @@ func main() {
zoneTypeFilter := provider.NewZoneTypeFilter(cfg.AWSZoneType)
zoneTagFilter := provider.NewZoneTagFilter(cfg.AWSZoneTagFilter)

var awsSession *session.Session
awsSessions := make(map[string]*session.Session, len(cfg.AWSProfiles))
var awsDefaultSession *session.Session
if cfg.Provider == "aws" || cfg.Provider == "aws-sd" || cfg.Registry == "dynamodb" || cfg.RunAWSProviderAsWebhook {
awsSession, err = aws.NewSession(
for _, profile := range cfg.AWSProfiles {
session, err := aws.NewSession(
aws.AWSSessionConfig{
AssumeRole: cfg.AWSAssumeRole,
AssumeRoleExternalID: cfg.AWSAssumeRoleExternalID,
APIRetries: cfg.AWSAPIRetries,
Profile: profile,
},
)
if err != nil {
log.Fatal(err)
}

awsSessions[profile] = session
}

awsDefaultSession, err = aws.NewSession(
aws.AWSSessionConfig{
AssumeRole: cfg.AWSAssumeRole,
AssumeRoleExternalID: cfg.AWSAssumeRoleExternalID,
Expand Down Expand Up @@ -224,6 +241,11 @@ func main() {
case "alibabacloud":
p, err = alibabacloud.NewAlibabaCloudProvider(cfg.AlibabaCloudConfigFile, domainFilter, zoneIDFilter, cfg.AlibabaCloudZoneType, cfg.DryRun)
case "aws":
clients := make(map[string]aws.Route53API, len(awsSessions))
for profile, session := range awsSessions {
clients[profile] = route53.New(session)
}

p, err = aws.NewAWSProvider(
aws.AWSConfig{
DomainFilter: domainFilter,
Expand All @@ -237,15 +259,15 @@ 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
if cfg.Registry != "noop" && cfg.Registry != "aws-sd" {
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(awsDefaultSession))
case "azure-dns", "azure":
p, err = azure.NewAzureProvider(cfg.AzureConfigFile, domainFilter, zoneNameFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.DryRun)
case "azure-private-dns":
Expand Down Expand Up @@ -417,7 +439,7 @@ func main() {
PreferCNAME: cfg.AWSPreferCNAME,
DryRun: cfg.DryRun,
ZoneCacheDuration: cfg.AWSZoneCacheDuration,
}, route53.New(awsSession))
}, map[string]aws.Route53API{aws.DefaultAWSProfile: route53.New(awsDefaultSession)})
if awsErr != nil {
log.Fatal(awsErr)
}
Expand All @@ -444,7 +466,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(awsDefaultSession, 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":
Expand Down
2 changes: 2 additions & 0 deletions pkg/apis/externaldns/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type Config struct {
AWSZoneType string
AWSZoneTagFilter []string
AWSAssumeRole string
AWSProfiles []string
AWSAssumeRoleExternalID string
AWSBatchChangeSize int
AWSBatchChangeInterval time.Duration
Expand Down Expand Up @@ -471,6 +472,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)
Expand Down
122 changes: 82 additions & 40 deletions provider/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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"
"github.com/pkg/errors"
Expand All @@ -36,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
Expand Down Expand Up @@ -208,6 +210,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 {
Expand All @@ -219,13 +226,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
batchChangeInterval time.Duration
Expand Down Expand Up @@ -259,9 +266,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,
Expand All @@ -280,14 +287,27 @@ 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
}

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 {
Expand All @@ -305,7 +325,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
Expand All @@ -315,22 +335,40 @@ 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, errors.Wrap(err, "failed to list hosted zones")
}
if tagErr != nil {
return nil, errors.Wrap(tagErr, "failed to list zones tags")
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() == "AccessDenied" {
log.Warnf("Skipping profile %q due to missing permission: %v", profile, awsErr.Message())
continue
}
if awsErr.Code() == "InvalidClientTokenId" || awsErr.Code() == "ExpiredToken" || awsErr.Code() == "SignatureDoesNotMatch" {
log.Warnf("Skipping profile %q due to credential issues: %v", profile, awsErr.Message())
continue
}
}
return nil, fmt.Errorf("failed to list hosted zones with profile %q: %w", profile, err)
}
if tagErr != nil {
return nil, fmt.Errorf("failed to list zones tags with profile %q: %w", profile, err)
}

}

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) {
Expand All @@ -349,15 +387,15 @@ 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, errors.Wrap(err, "records retrieval failed")
}

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 {
Expand Down Expand Up @@ -438,12 +476,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, errors.Wrapf(err, "failed to list resource records sets for zone %s", *z.Id)
client := p.clients[z.profile]
if err := client.ListResourceRecordSetsPagesWithContext(ctx, params, f); err != nil {
return nil, errors.Wrapf(err, "failed to list resource records sets for zone %s usinf profile %q", *z.zone.Id, z.profile)
}
}

Expand Down Expand Up @@ -526,7 +565,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 errors.Wrap(err, "failed to list zones, not applying changes")
}
Expand All @@ -542,7 +581,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")
Expand Down Expand Up @@ -583,8 +622,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 [Id: %s] when submitting change batch: %v", aws.StringValue(zones[z].zone.Name), z, err)

changesByOwnership := groupChangesByNameAndOwnershipRelation(b)

Expand All @@ -598,7 +638,7 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
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...)
Expand All @@ -615,7 +655,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) in zone %s [Id: %s] were successfully updated", successfulChanges, aws.StringValue(zones[z].zone.Name), z)
}

if i != len(batchCs)-1 {
Expand Down Expand Up @@ -839,8 +879,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),
})
Expand Down Expand Up @@ -916,11 +958,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 {
Expand All @@ -937,7 +979,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{
Expand All @@ -946,8 +988,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))
}
}

Expand All @@ -964,15 +1006,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 {
Expand Down
Loading

0 comments on commit 2815f48

Please sign in to comment.