Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(aws): multiple zone roles #5057

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions docs/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,4 @@
| `--webhook-provider-read-timeout=5s` | The read timeout for the webhook provider in duration format (default: 5s) |
| `--webhook-provider-write-timeout=10s` | The write timeout for the webhook provider in duration format (default: 10s) |
| `--[no-]webhook-server` | When enabled, runs as a webhook server instead of a controller. (default: false). |
| `--aws-domain-roles=AWS-DOMAIN-ROLES` | When using the AWS provider, specify the domain roles to use for the hosted zone (optional) |
107 changes: 107 additions & 0 deletions docs/tutorials/aws.md
Original file line number Diff line number Diff line change
Expand Up @@ -1045,3 +1045,110 @@ Because those limits are in place, `aws-batch-change-size` can be set to any val
## Using CRD source to manage DNS records in AWS

Please refer to the [CRD source documentation](../sources/crd.md#example) for more information.

## Strategies for Scoping Zones

> Without specifying these flags, management applies to all zones.

In order to manage specific zones, you may need to combine multiple options

| Argument | Description | Flow Control |
|:----------------------------|:----------------------------------------------------------------------------|:------------:|
| `--zone-id-filter` | Specify multiple times if needed | OR |
| `--domain-filter` | By domain suffix - specify multiple times if needed | OR |
| `--regex-domain-filter` | By domain suffix but as a regex - overrides domain-filter | AND |
| `--exclude-domains` | To exclude a domain or subdomain | OR |
| `--regex-domain-exclusion` | Subtracts its matches from `regex-domain-filter`'s matches | AND |
| `--aws-zone-type` | Only sync zones of this type `[public\|private]` | OR |
| `--aws-zone-tags` | Only sync zones with this tag | AND |

Minimum required configuration

```sh
args:
--provider=aws
--registry=txt
--source=service
```

### Filter by Zone Type

> If this flag is not specified, management applies to both public and private zones.

```sh
args:
--aws-zone-type=private|public # choose between public or private
...
```

### Filter by Domain

> Specify multiple times if needed.

```sh
args:
--domain-filter=example.com
--domain-filter=.paradox.example.com
...
```

Example `--domain-filter=example.com` will allow for zone `example.com` and any zones that end in `.example.com`, including `an.example.com`, i.e., the subdomains of example.com.

When there are multiple domains, filter `--domain-filter=example.com` will match domains `example.com`, `ex.par.example.com`, `par.example.com`, `x.par.eu-west-1.example.com`.

And if the filter is prepended with `.` e.g., `--domain-filter=.example.com` it will allow *only* zones that end in `.example.com`, i.e., the subdomains of example.com but not the `example.com` zone itself. Example result: `ex.par.eu-west-1.example.com`, `ex.par.example.com`, `par.example.com`.

> Note: if you prepend the filter with ".", it will not attempt to match parent zones.

### Filter by Zone ID

> Specify multiple times if needed, the flow logic is OR

```sh
args:
--zone-id-filter=ABCDEF12345678
--zone-id-filter=XYZDEF12345888
...
```

### Filter by Tag

> Specify multiple times if needed, the flow logic is AND

Keys only

```sh
args:
--aws-zone-tags=owner
--aws-zone-tags=vertical
```

Or specify keys with values

```sh
args:
--aws-zone-tags=owner=k8s
--aws-zone-tags=vertical=k8s
```

Can't specify multiple or separate values with commas: `key1=val1,key2=val2` at the moment.
Filter only by value `--aws-zone-tags==tag-value` is not supported.

```sh
args:
--aws-zone-tags=team=k8s,vertical=platform # this is not supported
--aws-zone-tags==tag-value # this is not supported
```

### Add Roles specific to the zone

If you have multiple zones and want to manage them with different roles, you can configure `external-dns` with the following option:

```sh
args:
--aws-domain-roles=example.com=arn:aws:iam::123456789012:role/external-dns-role
--aws-domain-roles=example.org=arn:aws:iam::123456789011:role/external-dns-role
```

`--aws-domain-roles` is a map of domain names to IAM roles. The domain/hosted zone names should match the `--domain-filter` values.
AWS also sets STS rate limits on a per account per region basis i.e. for a single account on a single region you can make 600 requests per second.
13 changes: 8 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,12 @@ func main() {
p, err = alibabacloud.NewAlibabaCloudProvider(cfg.AlibabaCloudConfigFile, domainFilter, zoneIDFilter, cfg.AlibabaCloudZoneType, cfg.DryRun)
case "aws":
configs := aws.CreateV2Configs(cfg)
clients := make(map[string]aws.Route53API, len(configs))
for profile, config := range configs {
clients[profile] = route53.NewFromConfig(config)
clients := make(map[string][]*aws.AWSZoneConfig, len(configs))
for profile, configZones := range configs {
for _, configZone := range configZones {
configZone.Route53Config = route53.NewFromConfig(configZone.Config)
clients[profile] = append(clients[profile], configZone)
}
}

p, err = aws.NewAWSProvider(
Expand All @@ -241,7 +244,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, cfg.AWSSDCreateTag, sd.NewFromConfig(aws.CreateDefaultV2Config(cfg)))
p, err = awssd.NewAWSSDProvider(domainFilter, cfg.AWSZoneType, cfg.DryRun, cfg.AWSSDServiceCleanup, cfg.TXTOwnerID, cfg.AWSSDCreateTag, sd.NewFromConfig(aws.CreateDefaultV2Config(cfg).Config))
case "azure-dns", "azure":
p, err = azure.NewAzureProvider(cfg.AzureConfigFile, domainFilter, zoneNameFilter, zoneIDFilter, cfg.AzureSubscriptionID, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.AzureActiveDirectoryAuthorityHost, cfg.AzureZonesCacheDuration, cfg.DryRun)
case "azure-private-dns":
Expand Down Expand Up @@ -400,7 +403,7 @@ func main() {
},
}
}
r, err = registry.NewDynamoDBRegistry(p, cfg.TXTOwnerID, dynamodb.NewFromConfig(aws.CreateDefaultV2Config(cfg), dynamodbOpts...), 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.NewFromConfig(aws.CreateDefaultV2Config(cfg).Config, dynamodbOpts...), 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
4 changes: 4 additions & 0 deletions pkg/apis/externaldns/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ type Config struct {
TraefikDisableLegacy bool
TraefikDisableNew bool
NAT64Networks []string
AWSDomainRoles map[string]string
}

var defaultConfig = &Config{
Expand Down Expand Up @@ -375,12 +376,14 @@ var defaultConfig = &Config{
TraefikDisableLegacy: false,
TraefikDisableNew: false,
NAT64Networks: []string{},
AWSDomainRoles: map[string]string{},
}

// NewConfig returns new Config object
func NewConfig() *Config {
return &Config{
AWSSDCreateTag: map[string]string{},
AWSDomainRoles: map[string]string{},
}
}

Expand Down Expand Up @@ -638,6 +641,7 @@ func App(cfg *Config) *kingpin.Application {
app.Flag("webhook-provider-write-timeout", "The write timeout for the webhook provider in duration format (default: 10s)").Default(defaultConfig.WebhookProviderWriteTimeout.String()).DurationVar(&cfg.WebhookProviderWriteTimeout)

app.Flag("webhook-server", "When enabled, runs as a webhook server instead of a controller. (default: false).").BoolVar(&cfg.WebhookServer)
app.Flag("aws-domain-roles", "When using the AWS provider, specify the domain roles to use for the hosted zone (optional)").StringMapVar(&cfg.AWSDomainRoles)

return app
}
5 changes: 5 additions & 0 deletions pkg/apis/externaldns/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ var (
WebhookProviderURL: "http://localhost:8888",
WebhookProviderReadTimeout: 5 * time.Second,
WebhookProviderWriteTimeout: 10 * time.Second,
AWSDomainRoles: map[string]string{},
}

overriddenConfig = &Config{
Expand Down Expand Up @@ -245,6 +246,7 @@ var (
WebhookProviderURL: "http://localhost:8888",
WebhookProviderReadTimeout: 5 * time.Second,
WebhookProviderWriteTimeout: 10 * time.Second,
AWSDomainRoles: map[string]string{"example.com": "arn:aws:iam::123456789012:role/role1", "example.org": "arn:aws:iam::123456789012:role/role2"},
}
)

Expand Down Expand Up @@ -351,6 +353,8 @@ func TestParseFlags(t *testing.T) {
"--aws-sd-service-cleanup",
"--aws-sd-create-tag=key1=value1",
"--aws-sd-create-tag=key2=value2",
"--aws-domain-roles=example.com=arn:aws:iam::123456789012:role/role1",
"--aws-domain-roles=example.org=arn:aws:iam::123456789012:role/role2",
"--no-aws-evaluate-target-health",
"--policy=upsert-only",
"--registry=noop",
Expand Down Expand Up @@ -508,6 +512,7 @@ func TestParseFlags(t *testing.T) {
"EXTERNAL_DNS_IBMCLOUD_CONFIG_FILE": "ibmcloud.json",
"EXTERNAL_DNS_TENCENT_CLOUD_CONFIG_FILE": "tencent-cloud.json",
"EXTERNAL_DNS_TENCENT_CLOUD_ZONE_TYPE": "private",
"EXTERNAL_DNS_AWS_DOMAIN_ROLES": "example.com=arn:aws:iam::123456789012:role/role1\nexample.org=arn:aws:iam::123456789012:role/role2",
},
expected: overriddenConfig,
},
Expand Down
128 changes: 72 additions & 56 deletions provider/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,10 @@ type Route53Change struct {
type Route53Changes []*Route53Change

type profiledZone struct {
profile string
zone *route53types.HostedZone
profile string
zone *route53types.HostedZone
zoneName string
client Route53API
}

func (cs Route53Changes) Route53Changes() []route53types.Change {
Expand Down Expand Up @@ -269,7 +271,7 @@ type zonesListCache struct {
// AWSProvider is an implementation of Provider for AWS Route53.
type AWSProvider struct {
provider.BaseProvider
clients map[string]Route53API
clients map[string][]*AWSZoneConfig
dryRun bool
batchChangeSize int
batchChangeSizeBytes int
Expand Down Expand Up @@ -310,7 +312,7 @@ type AWSConfig struct {
}

// NewAWSProvider initializes a new AWS Route53 based Provider.
func NewAWSProvider(awsConfig AWSConfig, clients map[string]Route53API) (*AWSProvider, error) {
func NewAWSProvider(awsConfig AWSConfig, clients map[string][]*AWSZoneConfig) (*AWSProvider, error) {
provider := &AWSProvider{
clients: clients,
domainFilter: awsConfig.DomainFilter,
Expand Down Expand Up @@ -356,55 +358,13 @@ func (p *AWSProvider) zones(ctx context.Context) (map[string]*profiledZone, erro

zones := make(map[string]*profiledZone)

for profile, client := range p.clients {
paginator := route53.NewListHostedZonesPaginator(client, &route53.ListHostedZonesInput{})
for profile, hostedZoneClients := range p.clients {
var err error
for _, client := range hostedZoneClients {
zones, err = p.fetchFilteredZonesForClient(ctx, client, profile)

for paginator.HasMorePages() {
resp, err := paginator.NextPage(ctx)
if err != nil {
var te *route53types.ThrottlingException
if errors.As(err, &te) {
log.Infof("Skipping AWS profile %q due to provider side throttling: %v", profile, te.ErrorMessage())
continue
}
// nothing to do here. Falling through to general error handling
return nil, provider.NewSoftError(fmt.Errorf("failed to list hosted zones: %w", err))
}
var zonesToTagFilter []string
for _, zone := range resp.HostedZones {
if !p.zoneIDFilter.Match(*zone.Id) {
continue
}

if !p.zoneTypeFilter.Match(zone) {
continue
}

if !p.domainFilter.Match(*zone.Name) {
if !p.zoneMatchParent {
continue
}
if !p.domainFilter.MatchParent(*zone.Name) {
continue
}
}

if !p.zoneTagFilter.IsEmpty() {
zonesToTagFilter = append(zonesToTagFilter, cleanZoneID(*zone.Id))
}

zones[*zone.Id] = &profiledZone{
profile: profile,
zone: &zone,
}
}

if len(zonesToTagFilter) > 0 {
if zTags, err := p.tagsForZone(ctx, zonesToTagFilter, profile); err != nil {
return nil, provider.NewSoftErrorf("failed to list tags for zones %w", err)
} else {
zTags.filterZonesByTags(p, zones)
}
return nil, provider.NewSoftErrorf("failed to list zones tags: %w", err)
}
}
}
Expand All @@ -423,6 +383,64 @@ func (p *AWSProvider) zones(ctx context.Context) (map[string]*profiledZone, erro
return zones, nil
}

func (p *AWSProvider) fetchFilteredZonesForClient(ctx context.Context, client *AWSZoneConfig, profile string) (map[string]*profiledZone, error) {
profileZones := make(map[string]*profiledZone)
paginator := route53.NewListHostedZonesPaginator(client.Route53Config, &route53.ListHostedZonesInput{})

for paginator.HasMorePages() {
resp, err := paginator.NextPage(ctx)
if err != nil {
var te *route53types.ThrottlingException
if errors.As(err, &te) {
log.Infof("Skipping AWS profile %q due to provider side throttling: %v", profile, te.ErrorMessage())
continue
}
// nothing to do here. Falling through to general error handling
return nil, provider.NewSoftError(fmt.Errorf("failed to list hosted zones: %w", err))
}
var zonesToTagFilter []string
for _, zone := range resp.HostedZones {
if !p.zoneIDFilter.Match(*zone.Id) {
continue
}

if !p.zoneTypeFilter.Match(zone) {
continue
}

if !p.domainFilter.Match(*zone.Name) {
if !p.zoneMatchParent {
continue
}
if !p.domainFilter.MatchParent(*zone.Name) {
continue
}
}

if !p.zoneTagFilter.IsEmpty() {
zonesToTagFilter = append(zonesToTagFilter, cleanZoneID(*zone.Id))
}

profileZones[*zone.Id] = &profiledZone{
profile: profile,
zone: &zone,
zoneName: client.HostedZoneName,
client: client.Route53Config,
}
}

if len(zonesToTagFilter) > 0 {
if zTags, err := p.tagsForZone(ctx, zonesToTagFilter, client.Route53Config); err != nil {
return nil, provider.NewSoftErrorf("failed to list tags for zones %w", err)
} else {
zTags.filterZonesByTags(p, profileZones)
}
}
}

return profileZones, nil
}

// wildcardUnescape converts \\052.abc back to *.abc
// Route53 stores wildcards escaped: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html?shortFooter=true#domain-name-format-asterisk
func wildcardUnescape(s string) string {
Expand Down Expand Up @@ -465,7 +483,7 @@ func (p *AWSProvider) records(ctx context.Context, zones map[string]*profiledZon
endpoints := make([]*endpoint.Endpoint, 0)

for _, z := range zones {
client := p.clients[z.profile]
client := z.client

paginator := route53.NewListResourceRecordSetsPaginator(client, &route53.ListResourceRecordSetsInput{
HostedZoneId: z.zone.Id,
Expand Down Expand Up @@ -699,7 +717,7 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,

successfulChanges := 0

client := p.clients[zones[z].profile]
client := zones[z].client
if _, err := client.ChangeResourceRecordSets(ctx, params); err != nil {
log.Errorf("Failure in zone %s when submitting change batch: %v", *zones[z].zone.Name, err)

Expand Down Expand Up @@ -975,9 +993,7 @@ func groupChangesByNameAndOwnershipRelation(cs Route53Changes) map[string]Route5
return changesByOwnership
}

func (p *AWSProvider) tagsForZone(ctx context.Context, zoneIDs []string, profile string) (zoneTags, error) {
client := p.clients[profile]

func (p *AWSProvider) tagsForZone(ctx context.Context, zoneIDs []string, client Route53API) (zoneTags, error) {
result := zoneTags{}

for i := 0; i < len(zoneIDs); i += batchSize {
Expand Down
Loading
Loading