diff --git a/api/v1beta1/externaldns_types.go b/api/v1beta1/externaldns_types.go index bcc67cd0..577f8484 100644 --- a/api/v1beta1/externaldns_types.go +++ b/api/v1beta1/externaldns_types.go @@ -254,9 +254,16 @@ type ExternalDNSAWSProviderOptions struct { // https://github.com/kubernetes-sigs/external-dns/blob/master/docs/tutorials/aws.md // for more information. // - // +kubebuilder:validation:Required - // +required + // +kubebuilder:validation:Optional + // +optional Credentials SecretReference `json:"credentials"` + + // RoleARN contains the ARN of a IAM role that will be assumed when using the AWS API. + // It provides the ability to use a hosted zone in another AWS account. + // + // +kubebuilder:validation:Optional + // +optional + RoleARN *string `json:"roleARN,omitempty"` // 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) diff --git a/api/v1beta1/externaldns_webhook.go b/api/v1beta1/externaldns_webhook.go index e377892f..cf719c4a 100644 --- a/api/v1beta1/externaldns_webhook.go +++ b/api/v1beta1/externaldns_webhook.go @@ -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" @@ -69,6 +70,7 @@ func (r *ExternalDNS) validate(old runtime.Object) error { r.validateFilters(), r.validateSources(old), r.validateHostnameAnnotationPolicy(), + r.validateAWSRoleARN(), r.validateProviderCredentials(), }) } @@ -155,3 +157,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.RoleARN != nil && !arn.IsARN(*provider.AWS.RoleARN) { + return errors.New(fmt.Sprintf("arn %s is not a valid AWS ARN", *provider.AWS.RoleARN)) + } + + return nil +} diff --git a/api/v1beta1/webhook_test.go b/api/v1beta1/webhook_test.go index c5b7097f..7798131c 100644 --- a/api/v1beta1/webhook_test.go +++ b/api/v1beta1/webhook_test.go @@ -52,6 +52,29 @@ 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{ + RoleARN: pointer.String("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{ + RoleARN: pointer.String("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() { @@ -175,6 +198,31 @@ 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{ + RoleARN: pointer.String("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{ + RoleARN: pointer.String("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() { diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 22383946..075b580c 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -58,6 +58,11 @@ func (in *ExternalDNS) DeepCopyObject() runtime.Object { func (in *ExternalDNSAWSProviderOptions) DeepCopyInto(out *ExternalDNSAWSProviderOptions) { *out = *in out.Credentials = in.Credentials + if in.RoleARN != nil { + in, out := &in.RoleARN, &out.RoleARN + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalDNSAWSProviderOptions. @@ -253,7 +258,7 @@ func (in *ExternalDNSProvider) DeepCopyInto(out *ExternalDNSProvider) { if in.AWS != nil { in, out := &in.AWS, &out.AWS *out = new(ExternalDNSAWSProviderOptions) - **out = **in + (*in).DeepCopyInto(*out) } if in.GCP != nil { in, out := &in.GCP, &out.GCP diff --git a/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml b/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml index e534a1b2..666e558c 100644 --- a/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml +++ b/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml @@ -582,6 +582,12 @@ spec: required: - name type: object + roleARN: + description: RoleARN contains the ARN of a IAM role that will + be assumed when using the AWS API. It provides the ability + to use a hosted zone in another AWS account. + pattern: ^arn:(aws|aws-cn|aws-us-gov):iam::[0-9]{12}:role\/.*$ + type: string required: - credentials type: object diff --git a/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml b/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml index 4c46596d..dd1f34f4 100644 --- a/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml +++ b/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml @@ -584,8 +584,11 @@ spec: required: - name type: object - required: - - credentials + roleARN: + description: RoleARN contains the ARN of a IAM role that will + be assumed when using the AWS API. It provides the ability + to use a hosted zone in another AWS account. + type: string type: object azure: description: Azure describes provider configuration options specific diff --git a/pkg/operator/controller/externaldns/credentials_request.go b/pkg/operator/controller/externaldns/credentials_request.go index 35b46903..097d55ca 100644 --- a/pkg/operator/controller/externaldns/credentials_request.go +++ b/pkg/operator/controller/externaldns/credentials_request.go @@ -248,6 +248,7 @@ func createProviderConfig(externalDNS *operatorv1beta1.ExternalDNS, platformStat "route53:ListHostedZones", "route53:ListResourceRecordSets", "tag:GetResources", + "sts:AssumeRole", }, Resource: "*", }, diff --git a/pkg/operator/controller/externaldns/credentials_request_test.go b/pkg/operator/controller/externaldns/credentials_request_test.go index 8a714f05..90df6074 100644 --- a/pkg/operator/controller/externaldns/credentials_request_test.go +++ b/pkg/operator/controller/externaldns/credentials_request_test.go @@ -285,6 +285,7 @@ func desiredAWSProviderSpec() runtime.Object { "route53:ListHostedZones", "route53:ListResourceRecordSets", "tag:GetResources", + "sts:AssumeRole", }, Resource: "*", }, @@ -311,6 +312,7 @@ func desiredAWSProviderSpecGovARN() runtime.Object { "route53:ListHostedZones", "route53:ListResourceRecordSets", "tag:GetResources", + "sts:AssumeRole", }, Resource: "*", }, diff --git a/pkg/operator/controller/externaldns/deployment_test.go b/pkg/operator/controller/externaldns/deployment_test.go index 79ec8d59..75abf22d 100644 --- a/pkg/operator/controller/externaldns/deployment_test.go +++ b/pkg/operator/controller/externaldns/deployment_test.go @@ -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, @@ -5317,6 +5366,14 @@ 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{ + RoleARN: pointer.String(roleARN), + } + return extdns +} + func testPlatformStatusGCP(projectID string) *configv1.PlatformStatus { return &configv1.PlatformStatus{ Type: configv1.GCPPlatformType, diff --git a/pkg/operator/controller/externaldns/pod.go b/pkg/operator/controller/externaldns/pod.go index 237eeec5..e64096c4 100644 --- a/pkg/operator/controller/externaldns/pod.go +++ b/pkg/operator/controller/externaldns/pod.go @@ -194,6 +194,10 @@ func (b *externalDNSContainerBuilder) fillProviderAgnosticFields(seq int, zone s args = append(args, "--ignore-hostname-annotation") } + if b.externalDNS.Spec.Provider.AWS != nil && b.externalDNS.Spec.Provider.AWS.RoleARN != nil { + args = append(args, fmt.Sprintf("--aws-assume-role=%s", *b.externalDNS.Spec.Provider.AWS.RoleARN)) + } + if len(b.externalDNS.Spec.Source.FQDNTemplate) > 0 { args = append(args, fmt.Sprintf("--fqdn-template=%s", strings.Join(b.externalDNS.Spec.Source.FQDNTemplate, ","))) } else { diff --git a/vendor/github.com/aws/aws-sdk-go/aws/arn/arn.go b/vendor/github.com/aws/aws-sdk-go/aws/arn/arn.go new file mode 100644 index 00000000..1c496742 --- /dev/null +++ b/vendor/github.com/aws/aws-sdk-go/aws/arn/arn.go @@ -0,0 +1,93 @@ +// Package arn provides a parser for interacting with Amazon Resource Names. +package arn + +import ( + "errors" + "strings" +) + +const ( + arnDelimiter = ":" + arnSections = 6 + arnPrefix = "arn:" + + // zero-indexed + sectionPartition = 1 + sectionService = 2 + sectionRegion = 3 + sectionAccountID = 4 + sectionResource = 5 + + // errors + invalidPrefix = "arn: invalid prefix" + invalidSections = "arn: not enough sections" +) + +// ARN captures the individual fields of an Amazon Resource Name. +// See http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html for more information. +type ARN struct { + // The partition that the resource is in. For standard AWS regions, the partition is "aws". If you have resources in + // other partitions, the partition is "aws-partitionname". For example, the partition for resources in the China + // (Beijing) region is "aws-cn". + Partition string + + // The service namespace that identifies the AWS product (for example, Amazon S3, IAM, or Amazon RDS). For a list of + // namespaces, see + // http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#genref-aws-service-namespaces. + Service string + + // The region the resource resides in. Note that the ARNs for some resources do not require a region, so this + // component might be omitted. + Region string + + // The ID of the AWS account that owns the resource, without the hyphens. For example, 123456789012. Note that the + // ARNs for some resources don't require an account number, so this component might be omitted. + AccountID string + + // The content of this part of the ARN varies by service. It often includes an indicator of the type of resource — + // for example, an IAM user or Amazon RDS database - followed by a slash (/) or a colon (:), followed by the + // resource name itself. Some services allows paths for resource names, as described in + // http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#arns-paths. + Resource string +} + +// Parse parses an ARN into its constituent parts. +// +// Some example ARNs: +// arn:aws:elasticbeanstalk:us-east-1:123456789012:environment/My App/MyEnvironment +// arn:aws:iam::123456789012:user/David +// arn:aws:rds:eu-west-1:123456789012:db:mysql-db +// arn:aws:s3:::my_corporate_bucket/exampleobject.png +func Parse(arn string) (ARN, error) { + if !strings.HasPrefix(arn, arnPrefix) { + return ARN{}, errors.New(invalidPrefix) + } + sections := strings.SplitN(arn, arnDelimiter, arnSections) + if len(sections) != arnSections { + return ARN{}, errors.New(invalidSections) + } + return ARN{ + Partition: sections[sectionPartition], + Service: sections[sectionService], + Region: sections[sectionRegion], + AccountID: sections[sectionAccountID], + Resource: sections[sectionResource], + }, nil +} + +// IsARN returns whether the given string is an ARN by looking for +// whether the string starts with "arn:" and contains the correct number +// of sections delimited by colons(:). +func IsARN(arn string) bool { + return strings.HasPrefix(arn, arnPrefix) && strings.Count(arn, ":") >= arnSections-1 +} + +// String returns the canonical representation of the ARN +func (arn ARN) String() string { + return arnPrefix + + arn.Partition + arnDelimiter + + arn.Service + arnDelimiter + + arn.Region + arnDelimiter + + arn.AccountID + arnDelimiter + + arn.Resource +} diff --git a/vendor/modules.txt b/vendor/modules.txt index c78acd0d..45210b16 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -39,6 +39,7 @@ github.com/asaskevich/govalidator # github.com/aws/aws-sdk-go v1.41.6 ## explicit; go 1.11 github.com/aws/aws-sdk-go/aws +github.com/aws/aws-sdk-go/aws/arn github.com/aws/aws-sdk-go/aws/awserr github.com/aws/aws-sdk-go/aws/awsutil github.com/aws/aws-sdk-go/aws/client