Skip to content

Commit

Permalink
NE-1323: Add AWS Assume Role ARN API
Browse files Browse the repository at this point in the history
Adds the ability to specify a Role ARN to create Route 53 DNS
records in a different AWS Account. Supports Shared VPC scenario.

`api/v1beta1/externaldns_types.go`: Add AssumeRole API
`api/v1beta1/webhook_test.go`: Add AssumeRole WebHook Validation test
`api/v1beta1/zz_generated.deepcopy.go`: Generated
`bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml`:
Generated
`config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml`:
Generated
`docs/usage.md`: Add section on AWS Assume Role
`pkg/operator/controller/externaldns/credentials_request.go`: Add
sts:AssumeRole to credential requests
`pkg/operator/controller/externaldns/deployment_test.go`: Unit test
`desiredExternalDNSDeployment`
`pkg/operator/controller/externaldns/pod.go`: Add --aws-assume-role
argument with AssumeRole ARN value
  • Loading branch information
gcs278 committed Sep 5, 2023
1 parent 72053a8 commit 42fe267
Show file tree
Hide file tree
Showing 13 changed files with 336 additions and 8 deletions.
20 changes: 17 additions & 3 deletions api/v1beta1/externaldns_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,14 @@ type ExternalDNSAWSProviderOptions struct {
// +kubebuilder:validation:Required
// +required
Credentials SecretReference `json:"credentials"`
// TODO: Additionally support access for:
// - kiam/kube2iam enabled clusters ("iam.amazonaws.com/role" POD's annotation to assume IAM role)
// - EKS clusters ("eks.amazonaws.com/role-arn" ServiceAccount's annotation to assume IAM role)

// assumeRole is a reference to the IAM role that
// ExternalDNS will be assuming in order to perform
// any DNS updates.
//
// +kubebuilder:validation:Optional
// +optional
AssumeRole *ExternalDNSAWSAssumeRoleOptions `json:"assumeRole,omitempty"`
}

type ExternalDNSGCPProviderOptions struct {
Expand Down Expand Up @@ -485,6 +490,15 @@ const (
HostnameAnnotationPolicyAllow HostnameAnnotationPolicy = "Allow"
)

type ExternalDNSAWSAssumeRoleOptions struct {
// arn is an AWS role ARN that the ExternalDNS
// operator will assume when making DNS updates.
//
// +kubebuilder:validation:Required
// +required
ARN string `json:"arn,omitempty"`
}

// ExternalDNSServiceSourceOptions describes options
// specific to the ExternalDNS service source.
type ExternalDNSServiceSourceOptions struct {
Expand Down
12 changes: 12 additions & 0 deletions api/v1beta1/externaldns_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"regexp"

"github.com/aws/aws-sdk-go/aws/arn"
"k8s.io/apimachinery/pkg/runtime"

utilErrors "k8s.io/apimachinery/pkg/util/errors"
Expand Down Expand Up @@ -71,6 +72,7 @@ func (r *ExternalDNS) validate(old runtime.Object) error {
r.validateSources(old),
r.validateHostnameAnnotationPolicy(),
r.validateProviderCredentials(),
r.validateAWSRoleARN(),
})
}

Expand Down Expand Up @@ -156,3 +158,13 @@ func (r *ExternalDNS) validateProviderCredentials() error {
}
return nil
}

func (r *ExternalDNS) validateAWSRoleARN() error {
// Ensure we have a valid arn if it is specified.
provider := r.Spec.Provider
if provider.AWS != nil && provider.AWS.AssumeRole != nil && !arn.IsARN(provider.AWS.AssumeRole.ARN) {
return fmt.Errorf("arn %q is not a valid AWS ARN", provider.AWS.AssumeRole.ARN)
}

return nil
}
56 changes: 56 additions & 0 deletions api/v1beta1/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ var _ = Describe("ExternalDNS admission webhook when platform is OCP", func() {
err := k8sClient.Create(context.Background(), resource)
Expect(err).Should(Succeed())
})
It("valid RoleARN", func() {
resource := makeExternalDNS("test-valid-rolearn-openshift", nil)
resource.Spec.Provider = ExternalDNSProvider{
Type: ProviderTypeAWS,
AWS: &ExternalDNSAWSProviderOptions{
AssumeRole: &ExternalDNSAWSAssumeRoleOptions{
ARN: "arn:aws:iam::123456789012:role/foo",
},
},
}
err := k8sClient.Create(context.Background(), resource)
Expect(err).Should(Succeed())
})
It("invalid RoleARN rejected", func() {
resource := makeExternalDNS("test-invalid-rolearn-openshift", nil)
resource.Spec.Provider = ExternalDNSProvider{
Type: ProviderTypeAWS,
AWS: &ExternalDNSAWSProviderOptions{
AssumeRole: &ExternalDNSAWSAssumeRoleOptions{
ARN: "arn:aws:iam:bad123456789012:role/foo",
},
},
}
err := k8sClient.Create(context.Background(), resource)
Expect(err).ShouldNot(Succeed())
Expect(err.Error()).Should(ContainSubstring(`arn "arn:aws:iam:bad123456789012:role/foo" is not a valid AWS ARN`))
})
})
Context("resource with Azure provider", func() {
It("ignores when provider Azure credentials are not specified", func() {
Expand Down Expand Up @@ -175,6 +202,35 @@ var _ = Describe("ExternalDNS admission webhook", func() {
Expect(err).ShouldNot(Succeed())
Expect(err.Error()).Should(ContainSubstring("credentials secret must be specified when provider type is AWS"))
})
It("valid RoleARN", func() {
resource := makeExternalDNS("test-valid-rolearn", nil)
resource.Spec.Provider = ExternalDNSProvider{
Type: ProviderTypeAWS,
AWS: &ExternalDNSAWSProviderOptions{
AssumeRole: &ExternalDNSAWSAssumeRoleOptions{
ARN: "arn:aws:iam::123456789012:role/foo",
},
Credentials: SecretReference{Name: "credentials"},
},
}
err := k8sClient.Create(context.Background(), resource)
Expect(err).Should(Succeed())
})
It("invalid RoleARN rejected", func() {
resource := makeExternalDNS("test-invalid-rolearn", nil)
resource.Spec.Provider = ExternalDNSProvider{
Type: ProviderTypeAWS,
AWS: &ExternalDNSAWSProviderOptions{
AssumeRole: &ExternalDNSAWSAssumeRoleOptions{
ARN: "arn:aws:iam:bad123456789012:role/foo",
},
Credentials: SecretReference{Name: "credentials"},
},
}
err := k8sClient.Create(context.Background(), resource)
Expect(err).ShouldNot(Succeed())
Expect(err.Error()).Should(ContainSubstring(`arn "arn:aws:iam:bad123456789012:role/foo" is not a valid AWS ARN`))
})
})

Context("resource with Azure provider", func() {
Expand Down
22 changes: 21 additions & 1 deletion api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,16 @@ spec:
description: AWS describes provider configuration options specific
to AWS (Route 53).
properties:
assumeRole:
description: AssumeRole is a reference to the IAM role that
the controller will be assuming in order to perform any
DNS updates.
properties:
arn:
description: ARN is an AWS role ARN that the external-dns
operator will assume when making DNS updates.
type: string
type: object
credentials:
description: "Credentials is a reference to a secret containing
the following keys (with corresponding values): \n * aws_access_key_id
Expand Down
10 changes: 10 additions & 0 deletions config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,16 @@ spec:
description: AWS describes provider configuration options specific
to AWS (Route 53).
properties:
assumeRole:
description: AssumeRole is a reference to the IAM role that
the controller will be assuming in order to perform any
DNS updates.
properties:
arn:
description: ARN is an AWS role ARN that the external-dns
operator will assume when making DNS updates.
type: string
type: object
credentials:
description: "Credentials is a reference to a secret containing
the following keys (with corresponding values): \n * aws_access_key_id
Expand Down
54 changes: 50 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ provider configuration in the `ExternalDNS` resource. However, it does not provi
it expects the credentials to be in the same namespace as the operator itself. It then copies over the credentials into
the namespace where the _external-dns_ deployments are created so that they can be mounted by the pods.

## AWS
# AWS

Create a secret with the access key id and secret:

Expand Down Expand Up @@ -55,11 +55,57 @@ spec:
Once this is created the _external-dns-operator_ will create a deployment of _external-dns_ which is configured to
manage DNS records in AWS Route53.

## AWS GovCloud
## Assume Role

The _external-dns-operator_ supports managing records in another AWS account's hosted zone. To achieve this, you will
require an IAM Role ARN with the necessary permissions properly set up. This Role ARN should then be specified in the
`ExternalDNS` resource in the following manner:

```yaml
apiVersion: externaldns.olm.openshift.io/v1beta1
kind: ExternalDNS
metadata:
name: aws-example
spec:
provider:
type: AWS
aws:
credentials:
name: aws-access-key
assumeRole:
arn: arn:aws:iam::123456789012:role/role-name # Replace with the desire Role ARN
zones: # Replace with the desired hosted zone IDs
- "Z3URY6TWQ91KXX"
source:
type: Service
fqdnTemplate:
- '{{.Name}}.mydomain.net'
```

**Note**: Due to a limitation of the `v1beta1` API requiring the `credentials` field, OpenShift users will be required
to provide an empty (`""`) credentials field. The empty credentials will be ignored and the secret provided by
OpenShift's Cloud Credentials Operator will be used:

```yaml
apiVersion: externaldns.olm.openshift.io/v1beta1
kind: ExternalDNS
metadata:
name: aws-example
spec:
provider:
type: AWS
aws:
credentials:
name: "" # Empty Credentials
assumeRole:
arn: arn:aws:iam::123456789012:role/role-name # Replace with the desire Role ARN
```

## GovCloud
The operator makes the assumption that `ExternalDNS` instances which target GovCloud DNS also run on the GovCloud. This is needed to detect the AWS region.
As for the rest: the usage is exactly the same as for `AWS`.

## Infoblox
# Infoblox

Before creating an `ExternalDNS` resource for the [Infoblox](https://www.infoblox.com/wp-content/uploads/infoblox-deployment-infoblox-rest-api.pdf)
the following information is required:
Expand Down Expand Up @@ -110,7 +156,7 @@ spec:
Once this is created the _external-dns-operator_ will create a deployment of _external-dns_ which is configured to
manage DNS records in Infoblox.

## BlueCat
# BlueCat

The BlueCat provider requires
the [BlueCat Gateway](https://docs.bluecatnetworks.com/r/Gateway-Installation-Guide/Installing-BlueCat-Gateway/20.3.1)
Expand Down
1 change: 1 addition & 0 deletions pkg/operator/controller/externaldns/credentials_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ func createProviderConfig(externalDNS *operatorv1beta1.ExternalDNS, platformStat
"route53:ListHostedZones",
"route53:ListResourceRecordSets",
"tag:GetResources",
"sts:AssumeRole",
},
Resource: "*",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ func desiredAWSProviderSpec() runtime.Object {
"route53:ListHostedZones",
"route53:ListResourceRecordSets",
"tag:GetResources",
"sts:AssumeRole",
},
Resource: "*",
},
Expand All @@ -311,6 +312,7 @@ func desiredAWSProviderSpecGovARN() runtime.Object {
"route53:ListHostedZones",
"route53:ListResourceRecordSets",
"tag:GetResources",
"sts:AssumeRole",
},
Resource: "*",
},
Expand Down
59 changes: 59 additions & 0 deletions pkg/operator/controller/externaldns/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,55 @@ func TestDesiredExternalDNSDeployment(t *testing.T) {
},
},
},
{
name: "RoleARN set AWS Route",
inputExternalDNS: testAWSExternalDNSRoleARN(operatorv1beta1.SourceTypeRoute, "arn:aws:iam:123456789012:role/foo"),
expectedTemplatePodSpec: corev1.PodSpec{
ServiceAccountName: test.OperandName,
NodeSelector: map[string]string{
osLabel: linuxOS,
masterNodeRoleLabel: "",
},
Tolerations: []corev1.Toleration{
{
Key: masterNodeRoleLabel,
Operator: corev1.TolerationOpExists,
Effect: corev1.TaintEffectNoSchedule,
},
},
Containers: []corev1.Container{
{
Name: ExternalDNSContainerName,
Image: test.OperandImage,
Args: []string{
"--aws-assume-role=arn:aws:iam:123456789012:role/foo",
"--metrics-address=127.0.0.1:7979",
"--txt-owner-id=external-dns-test",
"--zone-id-filter=my-dns-public-zone",
"--provider=aws",
"--source=openshift-route",
"--policy=sync",
"--registry=txt",
"--log-level=debug",
"--ignore-hostname-annotation",
`--fqdn-template={{""}}`,
"--txt-prefix=external-dns-",
},
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Drop: []corev1.Capability{allCapabilities},
},
Privileged: pointer.Bool(false),
RunAsNonRoot: pointer.Bool(true),
AllowPrivilegeEscalation: pointer.Bool(false),
SeccompProfile: &corev1.SeccompProfile{
Type: corev1.SeccompProfileTypeRuntimeDefault,
},
},
},
},
},
},
{
name: "Nominal Azure Route",
inputSecretName: azureSecret,
Expand Down Expand Up @@ -5317,6 +5366,16 @@ func testAWSExternalDNSDomainFilter(zones []string, source operatorv1beta1.Exter
return extdns
}

func testAWSExternalDNSRoleARN(source operatorv1beta1.ExternalDNSSourceType, roleARN string) *operatorv1beta1.ExternalDNS {
extdns := testCreateDNSFromSourceWRTCloudProvider(source, operatorv1beta1.ProviderTypeAWS, nil, "")
extdns.Spec.Provider.AWS = &operatorv1beta1.ExternalDNSAWSProviderOptions{
AssumeRole: &operatorv1beta1.ExternalDNSAWSAssumeRoleOptions{
ARN: roleARN,
},
}
return extdns
}

func testPlatformStatusGCP(projectID string) *configv1.PlatformStatus {
return &configv1.PlatformStatus{
Type: configv1.GCPPlatformType,
Expand Down
4 changes: 4 additions & 0 deletions pkg/operator/controller/externaldns/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ func (b *externalDNSContainerBuilder) fillAWSFields(container *corev1.Container)
container.Args = append(container.Args, "--aws-prefer-cname")
}

if b.externalDNS.Spec.Provider.AWS != nil && b.externalDNS.Spec.Provider.AWS.AssumeRole != nil {
container.Args = append(container.Args, fmt.Sprintf("--aws-assume-role=%s", b.externalDNS.Spec.Provider.AWS.AssumeRole.ARN))
}

// don't add empty credentials environment variables if no secret was given
if len(b.secretName) == 0 {
return
Expand Down
Loading

0 comments on commit 42fe267

Please sign in to comment.