diff --git a/api/v1beta1/externaldns_types.go b/api/v1beta1/externaldns_types.go index bcc67cd0..2bc16001 100644 --- a/api/v1beta1/externaldns_types.go +++ b/api/v1beta1/externaldns_types.go @@ -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 { @@ -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 { diff --git a/api/v1beta1/externaldns_webhook.go b/api/v1beta1/externaldns_webhook.go index 43732e0c..25b599d7 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" @@ -71,6 +72,7 @@ func (r *ExternalDNS) validate(old runtime.Object) error { r.validateSources(old), r.validateHostnameAnnotationPolicy(), r.validateProviderCredentials(), + r.validateAWSRoleARN(), }) } @@ -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 +} diff --git a/api/v1beta1/webhook_test.go b/api/v1beta1/webhook_test.go index 18482d11..8b26b6c9 100644 --- a/api/v1beta1/webhook_test.go +++ b/api/v1beta1/webhook_test.go @@ -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() { @@ -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() { diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 22383946..a8ae93c4 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -54,10 +54,30 @@ func (in *ExternalDNS) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalDNSAWSAssumeRoleOptions) DeepCopyInto(out *ExternalDNSAWSAssumeRoleOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalDNSAWSAssumeRoleOptions. +func (in *ExternalDNSAWSAssumeRoleOptions) DeepCopy() *ExternalDNSAWSAssumeRoleOptions { + if in == nil { + return nil + } + out := new(ExternalDNSAWSAssumeRoleOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExternalDNSAWSProviderOptions) DeepCopyInto(out *ExternalDNSAWSProviderOptions) { *out = *in out.Credentials = in.Credentials + if in.AssumeRole != nil { + in, out := &in.AssumeRole, &out.AssumeRole + *out = new(ExternalDNSAWSAssumeRoleOptions) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalDNSAWSProviderOptions. @@ -253,7 +273,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..58755a91 100644 --- a/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml +++ b/bundle/manifests/externaldns.olm.openshift.io_externaldnses.yaml @@ -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 diff --git a/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml b/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml index 4c46596d..c9a15622 100644 --- a/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml +++ b/config/crd/bases/externaldns.olm.openshift.io_externaldnses.yaml @@ -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 diff --git a/docs/usage.md b/docs/usage.md index 98461d28..428cc4a8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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: @@ -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: @@ -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) 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 70c1e914..ce8129ce 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,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, diff --git a/pkg/operator/controller/externaldns/pod.go b/pkg/operator/controller/externaldns/pod.go index 237eeec5..f7037860 100644 --- a/pkg/operator/controller/externaldns/pod.go +++ b/pkg/operator/controller/externaldns/pod.go @@ -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 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 d57234b2..1b93e0ff 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -33,6 +33,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