diff --git a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml index 29cc567267..0f2abac50e 100644 --- a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml +++ b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml @@ -3083,6 +3083,36 @@ spec: description: Partition is the AWS security partition being used. Defaults to "aws" type: string + podIdentityAssociations: + description: |- + PodIdentityAssociations represent Kubernetes Service Accounts mapping to AWS IAM Roles without IRSA, using EKS Pod Identity. + This requires using the AWS EKS Addon for Pod Identity. + items: + description: PodIdentityAssociation represents an association between + a Kubernetes Service Account in a namespace, and an AWS IAM role + which allows the service principal `pods.eks.amazonaws.com` in + its trust policy. + properties: + roleARN: + description: RoleARN is the ARN of an IAM role which the Service + Account can assume. + type: string + serviceAccountName: + description: ServiceAccountName is the name of the kubernetes + Service Account within the namespace + type: string + serviceAccountNamespace: + default: default + description: ServiceAccountNamespace is the kubernetes namespace, + which the kubernetes Service Account resides in. Defaults + to "default" namespace. + type: string + required: + - roleARN + - serviceAccountName + - serviceAccountNamespace + type: object + type: array region: description: The AWS Region the cluster lives in. type: string diff --git a/controlplane/eks/api/v1beta1/conversion.go b/controlplane/eks/api/v1beta1/conversion.go index 9a0c2720c6..5e00658dd5 100644 --- a/controlplane/eks/api/v1beta1/conversion.go +++ b/controlplane/eks/api/v1beta1/conversion.go @@ -46,6 +46,8 @@ func (r *AWSManagedControlPlane) ConvertTo(dstRaw conversion.Hub) error { dst.Spec.RolePermissionsBoundary = restored.Spec.RolePermissionsBoundary dst.Status.Version = restored.Status.Version dst.Spec.BootstrapSelfManagedAddons = restored.Spec.BootstrapSelfManagedAddons + dst.Spec.PodIdentityAssociations = restored.Spec.PodIdentityAssociations + return nil } diff --git a/controlplane/eks/api/v1beta1/zz_generated.conversion.go b/controlplane/eks/api/v1beta1/zz_generated.conversion.go index b7bb9b0a6f..145c50157f 100644 --- a/controlplane/eks/api/v1beta1/zz_generated.conversion.go +++ b/controlplane/eks/api/v1beta1/zz_generated.conversion.go @@ -374,6 +374,7 @@ func autoConvert_v1beta2_AWSManagedControlPlaneSpec_To_v1beta1_AWSManagedControl out.TokenMethod = (*EKSTokenMethod)(unsafe.Pointer(in.TokenMethod)) out.AssociateOIDCProvider = in.AssociateOIDCProvider out.Addons = (*[]Addon)(unsafe.Pointer(in.Addons)) + // WARNING: in.PodIdentityAssociations requires manual conversion: does not exist in peer-type out.OIDCIdentityProviderConfig = (*OIDCIdentityProviderConfig)(unsafe.Pointer(in.OIDCIdentityProviderConfig)) if err := Convert_v1beta2_VpcCni_To_v1beta1_VpcCni(&in.VpcCni, &out.VpcCni, s); err != nil { return err diff --git a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go index 4f4c559b81..ccf99edba1 100644 --- a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go +++ b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go @@ -188,6 +188,11 @@ type AWSManagedControlPlaneSpec struct { //nolint: maligned // +optional Addons *[]Addon `json:"addons,omitempty"` + // PodIdentityAssociations represent Kubernetes Service Accounts mapping to AWS IAM Roles without IRSA, using EKS Pod Identity. + // This requires using the AWS EKS Addon for Pod Identity. + // +optional + PodIdentityAssociations []PodIdentityAssociation `json:"podIdentityAssociations,omitempty"` + // IdentityProviderconfig is used to specify the oidc provider config // to be attached with this eks cluster // +optional diff --git a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go index 8970b29cd7..2b7bd7e986 100644 --- a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go +++ b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go @@ -25,6 +25,7 @@ import ( "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/version" "k8s.io/klog/v2" @@ -107,6 +108,7 @@ func (*awsManagedControlPlaneWebhook) ValidateCreate(_ context.Context, obj runt allErrs = append(allErrs, r.Spec.AdditionalTags.Validate()...) allErrs = append(allErrs, r.validateNetwork()...) allErrs = append(allErrs, r.validatePrivateDNSHostnameTypeOnLaunch()...) + allErrs = append(allErrs, r.validServiceAccountName()...) if len(allErrs) == 0 { return nil, nil @@ -148,6 +150,7 @@ func (*awsManagedControlPlaneWebhook) ValidateUpdate(ctx context.Context, oldObj allErrs = append(allErrs, r.validateKubeProxy()...) allErrs = append(allErrs, r.Spec.AdditionalTags.Validate()...) allErrs = append(allErrs, r.validatePrivateDNSHostnameTypeOnLaunch()...) + allErrs = append(allErrs, r.validServiceAccountName()...) if r.Spec.Region != oldAWSManagedControlplane.Spec.Region { allErrs = append(allErrs, @@ -436,6 +439,28 @@ func (r *AWSManagedControlPlane) validatePrivateDNSHostnameTypeOnLaunch() field. return allErrs } +func (r *AWSManagedControlPlane) validServiceAccountName() field.ErrorList { + var allErrs field.ErrorList + + if r.Spec.PodIdentityAssociations != nil { + for i, association := range r.Spec.PodIdentityAssociations { + associationPath := field.NewPath("spec", "podIdentityAssociations").Index(i) + if association.ServiceAccountName == "" { + allErrs = append(allErrs, field.Required(associationPath.Child("serviceAccountName"), "serviceAccountName is required")) + } + + // kubernetes uses ValidateServiceAccountName internally, which maps to IsDNS1123Subdomain + // https://github.com/kubernetes/apimachinery/blob/d794766488ac2892197a7cc8d0b4b46b0edbda80/pkg/api/validation/generic.go#L68 + + if validationErrs := validation.IsDNS1123Subdomain(association.ServiceAccountName); len(validationErrs) > 0 { + allErrs = append(allErrs, field.Invalid(associationPath.Child("serviceAccountName"), association.ServiceAccountName, fmt.Sprintf("serviceAccountName is invalid: %v", validationErrs))) + } + } + } + + return allErrs +} + func (r *AWSManagedControlPlane) validateNetwork() field.ErrorList { var allErrs field.ErrorList diff --git a/controlplane/eks/api/v1beta2/conditions_consts.go b/controlplane/eks/api/v1beta2/conditions_consts.go index fc8fa66721..563ac525a5 100644 --- a/controlplane/eks/api/v1beta2/conditions_consts.go +++ b/controlplane/eks/api/v1beta2/conditions_consts.go @@ -52,6 +52,13 @@ const ( EKSAddonsConfiguredFailedReason = "EKSAddonsConfiguredFailed" ) +const ( + // EKSPodIdentityAssociationConfiguredCondition condition reports on the successful reconciliation of EKS pod identity associations. + EKSPodIdentityAssociationConfiguredCondition clusterv1.ConditionType = "EKSPodIdentityAssociationConfigured" + // EKSPodIdentityAssociationFailedReason is used to report failures while reconciling the EKS pod identity associations. + EKSPodIdentityAssociationFailedReason = "EKSPodIdentityAssociationConfigurationFailed" +) + const ( // EKSIdentityProviderConfiguredCondition condition reports on the successful association of identity provider config. EKSIdentityProviderConfiguredCondition clusterv1.ConditionType = "EKSIdentityProviderConfigured" diff --git a/controlplane/eks/api/v1beta2/types.go b/controlplane/eks/api/v1beta2/types.go index c740f868f1..a7208d61ce 100644 --- a/controlplane/eks/api/v1beta2/types.go +++ b/controlplane/eks/api/v1beta2/types.go @@ -79,12 +79,10 @@ var ( EKSTokenMethodAWSCli = EKSTokenMethod("aws-cli") ) -var ( - // DefaultEKSControlPlaneRole is the name of the default IAM role to use for the EKS control plane - // if no other role is supplied in the spec and if iam role creation is not enabled. The default - // can be created using clusterawsadm or created manually. - DefaultEKSControlPlaneRole = fmt.Sprintf("eks-controlplane%s", iamv1.DefaultNameSuffix) -) +// DefaultEKSControlPlaneRole is the name of the default IAM role to use for the EKS control plane +// if no other role is supplied in the spec and if iam role creation is not enabled. The default +// can be created using clusterawsadm or created manually. +var DefaultEKSControlPlaneRole = fmt.Sprintf("eks-controlplane%s", iamv1.DefaultNameSuffix) // IAMAuthenticatorConfig represents an aws-iam-authenticator configuration. type IAMAuthenticatorConfig struct { @@ -279,3 +277,17 @@ type OIDCIdentityProviderConfig struct { // +optional Tags infrav1.Tags `json:"tags,omitempty"` } + +// PodIdentityAssociation represents an association between a Kubernetes Service Account in a namespace, and an AWS IAM role which allows the service principal `pods.eks.amazonaws.com` in its trust policy. +type PodIdentityAssociation struct { + // ServiceAccountName is the name of the kubernetes Service Account within the namespace + // +kubebuilder:validation:Required + ServiceAccountName string `json:"serviceAccountName"` + // ServiceAccountNamespace is the kubernetes namespace, which the kubernetes Service Account resides in. Defaults to "default" namespace. + // +kubebuilder:validation:Required + // +kubebuilder:default=default + ServiceAccountNamespace string `json:"serviceAccountNamespace"` + // RoleARN is the ARN of an IAM role which the Service Account can assume. + // +kubebuilder:validation:Required + RoleARN string `json:"roleARN,omitempty"` +} diff --git a/controlplane/eks/api/v1beta2/zz_generated.deepcopy.go b/controlplane/eks/api/v1beta2/zz_generated.deepcopy.go index 216357e2fe..973b636144 100644 --- a/controlplane/eks/api/v1beta2/zz_generated.deepcopy.go +++ b/controlplane/eks/api/v1beta2/zz_generated.deepcopy.go @@ -165,6 +165,11 @@ func (in *AWSManagedControlPlaneSpec) DeepCopyInto(out *AWSManagedControlPlaneSp } } } + if in.PodIdentityAssociations != nil { + in, out := &in.PodIdentityAssociations, &out.PodIdentityAssociations + *out = make([]PodIdentityAssociation, len(*in)) + copy(*out, *in) + } if in.OIDCIdentityProviderConfig != nil { in, out := &in.OIDCIdentityProviderConfig, &out.OIDCIdentityProviderConfig *out = new(OIDCIdentityProviderConfig) @@ -557,6 +562,21 @@ func (in *OIDCProviderStatus) DeepCopy() *OIDCProviderStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodIdentityAssociation) DeepCopyInto(out *PodIdentityAssociation) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodIdentityAssociation. +func (in *PodIdentityAssociation) DeepCopy() *PodIdentityAssociation { + if in == nil { + return nil + } + out := new(PodIdentityAssociation) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RoleMapping) DeepCopyInto(out *RoleMapping) { *out = *in diff --git a/controlplane/eks/controllers/awsmanagedcontrolplane_controller_test.go b/controlplane/eks/controllers/awsmanagedcontrolplane_controller_test.go index aaa679e3f0..309f419ca6 100644 --- a/controlplane/eks/controllers/awsmanagedcontrolplane_controller_test.go +++ b/controlplane/eks/controllers/awsmanagedcontrolplane_controller_test.go @@ -454,7 +454,8 @@ func mockedCallsForMissingEverything(ec2Rec *mocks.MockEC2APIMockRecorder, subne Name: aws.String("tag-key"), Values: aws.StringSlice([]string{"sigs.k8s.io/cluster-api-provider-aws/cluster/test-cluster"}), }, - }})).Return(&ec2.DescribeRouteTablesOutput{ + }, + })).Return(&ec2.DescribeRouteTablesOutput{ RouteTables: []*ec2.RouteTable{ { Routes: []*ec2.Route{ @@ -514,7 +515,8 @@ func mockedCallsForMissingEverything(ec2Rec *mocks.MockEC2APIMockRecorder, subne Name: aws.String("state"), Values: aws.StringSlice([]string{ec2.VpcStatePending, ec2.VpcStateAvailable}), }, - }}), gomock.Any()).Return(nil).MinTimes(1).MaxTimes(2) + }, + }), gomock.Any()).Return(nil).MinTimes(1).MaxTimes(2) ec2Rec.DescribeAddressesWithContext(context.TODO(), gomock.Eq(&ec2.DescribeAddressesInput{ Filters: []*ec2.Filter{ @@ -949,6 +951,11 @@ func mockedEKSCluster(ctx context.Context, g *WithT, eksRec *mock_eksiface.MockE }).Return(&eks.ListAddonsOutput{}, nil) eksRec.UpdateClusterConfig(ctx, gomock.AssignableToTypeOf(&eks.UpdateClusterConfigInput{})).After(waitUntilClusterActiveCall).Return(&eks.UpdateClusterConfigOutput{}, nil) + eksRec.ListPodIdentityAssociations(context.TODO(), gomock.Eq(&eks.ListPodIdentityAssociationsInput{ + ClusterName: aws.String("test-cluster"), + })).Return(&eks.ListPodIdentityAssociationsOutput{ + Associations: []ekstypes.PodIdentityAssociationSummary{}, + }, nil) awsNodeRec.ReconcileCNI(gomock.Any()).Return(nil) kubeProxyRec.ReconcileKubeProxy(gomock.Any()).Return(nil) diff --git a/docs/book/src/SUMMARY_PREFIX.md b/docs/book/src/SUMMARY_PREFIX.md index de1756f422..3a5dccc68e 100644 --- a/docs/book/src/SUMMARY_PREFIX.md +++ b/docs/book/src/SUMMARY_PREFIX.md @@ -20,6 +20,7 @@ - [Creating a cluster](./topics/eks/creating-a-cluster.md) - [Using EKS Console](./topics/eks/eks-console.md) - [Using EKS Addons](./topics/eks/addons.md) + - [Using EKS Pod Identity Associations](./topics/eks/pod-identity-associations.md) - [Enabling Encryption](./topics/eks/encryption.md) - [Cluster Upgrades](./topics/eks/cluster-upgrades.md) - [ROSA Support](./topics/rosa/index.md) diff --git a/docs/book/src/topics/eks/pod-identity-associations.md b/docs/book/src/topics/eks/pod-identity-associations.md new file mode 100644 index 0000000000..05611dbeff --- /dev/null +++ b/docs/book/src/topics/eks/pod-identity-associations.md @@ -0,0 +1,88 @@ +# EKS Pod Identity Associations + +[EKS Pod Identity Associations](https://aws.amazon.com/blogs/containers/introducing-amazon-eks-add-ons/) can be used with EKS clusters created using Cluster API Provider AWS. + +## Prerequisites + +### Setting up the IAM role in AWS + +Outside of CAPI/CAPA, you must first create an IAM Role which allows the `pods.eks.amazonaws.com` service principal in the trust policy. EKS Identities trust relationships must also include the `sts:TagSession` permission (on top of the `sts:AssumeRole` permission). + +This is a sample trust policy which allows a kubernetes service account to assume this role. We'll call the role `capi-test-role` in the next steps. + +```yaml +{ + "Version": "2012-10-17", + "Statement": + [ + { + "Effect": "Allow", + "Principal": { "Service": "pods.eks.amazonaws.com" }, + "Action": ["sts:AssumeRole", "sts:TagSession"], + }, + ], +} +``` + +### Installing the EKS Pod Identity Agent + +The EKS Pod Identity Agent can be installed as a Managed Add-on through the AWS Console, or through CAPA. +To install the addon through CAPA, add it to `AWSManagedControlPlane`. Please ensure that the version is up to date, according to the [addons section](addons.md). + +```yaml +# [...] +kind: AWSManagedControlPlane +spec: + # [...] + addons: + # [...] + - conflictResolution: overwrite + name: eks-pod-identity-agent + version: v1.1.0-eksbuild.1 +``` + +You can verify that this is running on your kubernetes cluster with `kubectl get deploy -A | grep eks` + +## Mapping a service account to an IAM role + +Now that you have created a role `capi-test-role` in AWS, and have added the EKS agent to your cluster, we must add the following to our `AWSManagedControlPlane` under `.spec.podIdentityAssociations` + +```yaml +# [...] +kind: AWSManagedControlPlane +spec: + # [...] + podIdentityAssociations: + - serviceAccountNamespace: default + serviceAccountName: myserviceaccount + roleARN: arn:aws:iam::012345678901:role/capi-test-role +``` + +- `.serviceAccountNamespace` and `.serviceAccountName` refer to the [`ServiceAccount`](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/) object in the Kubernetes cluster +- `.roleARN` is the AWS ARN for the IAM role you created in step 1 (named `capi-test-role` in this tutorial). Make sure to copy this exactly from your AWS console (`IAM > Roles`). + +To use the same IAM role across multiple service accounts/namespaces, you must create multiple associations. + +A full CAPA example of everything mentioned above, including 2 role mappings, is shown below: + +```yaml +kind: AWSManagedControlPlane +apiVersion: controlplane.cluster.x-k8s.io/v1beta1 +metadata: + name: "capi-managed-test" +spec: + region: "eu-west-2" + sshKeyName: "capi-management" + version: "v1.27.0" + addons: + - conflictResolution: overwrite + name: eks-pod-identity-agent + version: v1.1.0-eksbuild.1 + podIdentityAssociations: + - serviceAccountNamespace: default + serviceAccountName: myserviceaccount + roleARN: arn:aws:iam::012345678901:role/capi-test-role + - serviceAccountNamespace: another-namespace + serviceAccountName: another-service-account + roleARN: arn:aws:iam::012345678901:role/capi-test-role +``` diff --git a/go.mod b/go.mod index f4faa4a69a..f2027436e2 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,12 @@ require ( github.com/aws/amazon-vpc-cni-k8s v1.15.5 github.com/aws/aws-lambda-go v1.41.0 github.com/aws/aws-sdk-go v1.55.7 - github.com/aws/aws-sdk-go-v2 v1.36.5 + github.com/aws/aws-sdk-go-v2 v1.36.6 github.com/aws/aws-sdk-go-v2/config v1.27.11 github.com/aws/aws-sdk-go-v2/credentials v1.17.11 github.com/aws/aws-sdk-go-v2/service/autoscaling v1.52.4 github.com/aws/aws-sdk-go-v2/service/ec2 v1.159.0 - github.com/aws/aws-sdk-go-v2/service/eks v1.64.0 + github.com/aws/aws-sdk-go-v2/service/eks v1.66.2 github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.29.6 github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.45.2 github.com/aws/aws-sdk-go-v2/service/iam v1.32.0 @@ -86,8 +86,8 @@ require ( github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect github.com/aws/aws-sdk-go-v2/service/cloudformation v1.50.0 // indirect diff --git a/go.sum b/go.sum index ea5310f7d1..13392c95f3 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,8 @@ github.com/aws/aws-lambda-go v1.41.0 h1:l/5fyVb6Ud9uYd411xdHZzSf2n86TakxzpvIoz7l github.com/aws/aws-lambda-go v1.41.0/go.mod h1:jwFe2KmMsHmffA1X2R09hH6lFzJQxzI8qK17ewzbQMM= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0= -github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= +github.com/aws/aws-sdk-go-v2 v1.36.6 h1:zJqGjVbRdTPojeCGWn5IR5pbJwSQSBh5RWFTQcEQGdU= +github.com/aws/aws-sdk-go-v2 v1.36.6/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg= github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= @@ -58,10 +58,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHH github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 h1:SsytQyTMHMDPspp+spo7XwXTP44aJZZAC7fBV2C5+5s= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 h1:i2vNHQiXUvKhs3quBR6aqlgJaiaexz/aNvdCktW/kAM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 h1:osMWfm/sC/L4tvEdQ65Gri5ZZDCUpuYJZbTTDrsn4I0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37/go.mod h1:ZV2/1fbjOPr4G4v38G3Ww5TBT4+hmsK45s/rxu1fGy0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 h1:v+X21AvTb2wZ+ycg1gx+orkB/9U6L7AOp93R7qYxsxM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37/go.mod h1:G0uM1kyssELxmJ2VZEfG0q2npObR3BAkF3c1VsfVnfs= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= @@ -72,8 +72,8 @@ github.com/aws/aws-sdk-go-v2/service/cloudformation v1.50.0 h1:Ap5tOJfeAH1hO2UQc github.com/aws/aws-sdk-go-v2/service/cloudformation v1.50.0/go.mod h1:/v2KYdCW4BaHKayenaWEXOOdxItIwEA3oU0XzuQY3F0= github.com/aws/aws-sdk-go-v2/service/ec2 v1.159.0 h1:DmmVmiLPlcntOcjWMRwDPMNx/wi2kAVrf2ZmSN5gkAg= github.com/aws/aws-sdk-go-v2/service/ec2 v1.159.0/go.mod h1:xejKuuRDjz6z5OqyeLsz01MlOqqW7CqpAB4PabNvpu8= -github.com/aws/aws-sdk-go-v2/service/eks v1.64.0 h1:EYeOThTRysemFtC6J6h6b7dNg3jN03QuO5cg92ojIQE= -github.com/aws/aws-sdk-go-v2/service/eks v1.64.0/go.mod h1:v1xXy6ea0PHtWkjFUvAUh6B/5wv7UF909Nru0dOIJDk= +github.com/aws/aws-sdk-go-v2/service/eks v1.66.2 h1:gDvxe1rFYhU9sfA/S8TePGE7gfC0vB9pCs6B4zbm5Ng= +github.com/aws/aws-sdk-go-v2/service/eks v1.66.2/go.mod h1:lpcShMkoQ94JiSVoEF1yE2WP40IV02bbnaT6oYP7cQo= github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.29.6 h1:9grU/+HRwLXJV8XUjEPThJj/H+0oHkeNBFpSSfZekeg= github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.29.6/go.mod h1:N4fs285CsnBHlAkzBpQapefR/noggTyF09fWs72EzB4= github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.45.2 h1:vX70Z4lNSr7XsioU0uJq5yvxgI50sB66MvD+V/3buS4= diff --git a/pkg/cloud/services/eks/addons.go b/pkg/cloud/services/eks/addons.go index 3d2e75f0a5..4e6f8b0d1a 100644 --- a/pkg/cloud/services/eks/addons.go +++ b/pkg/cloud/services/eks/addons.go @@ -40,7 +40,7 @@ func (s *Service) reconcileAddons(ctx context.Context) error { // Get available addon names for the cluster addonNames, err := s.listAddons(ctx, eksClusterName) if err != nil { - s.Error(err, "failed listing addons") + s.scope.Error(err, "failed listing addons") return fmt.Errorf("listing eks addons: %w", err) } diff --git a/pkg/cloud/services/eks/eks.go b/pkg/cloud/services/eks/eks.go index 05b760fa44..eb224c4632 100644 --- a/pkg/cloud/services/eks/eks.go +++ b/pkg/cloud/services/eks/eks.go @@ -56,6 +56,13 @@ func (s *Service) ReconcileControlPlane(ctx context.Context) error { } conditions.MarkTrue(s.scope.ControlPlane, ekscontrolplanev1.EKSAddonsConfiguredCondition) + // EKS Pod Identity Associations + if err := s.reconcilePodIdentities(ctx); err != nil { + conditions.MarkFalse(s.scope.ControlPlane, ekscontrolplanev1.EKSPodIdentityAssociationConfiguredCondition, ekscontrolplanev1.EKSPodIdentityAssociationFailedReason, clusterv1.ConditionSeverityError, err.Error()) + return errors.Wrap(err, "failed reconciling eks pod identity associations") + } + conditions.MarkTrue(s.scope.ControlPlane, ekscontrolplanev1.EKSPodIdentityAssociationConfiguredCondition) + // EKS Identity Provider if err := s.reconcileIdentityProvider(ctx); err != nil { conditions.MarkFalse(s.scope.ControlPlane, ekscontrolplanev1.EKSIdentityProviderConfiguredCondition, ekscontrolplanev1.EKSIdentityProviderConfiguredFailedReason, clusterv1.ConditionSeverityWarning, "%s", err.Error()) diff --git a/pkg/cloud/services/eks/mock_eksiface/eksapi_mock.go b/pkg/cloud/services/eks/mock_eksiface/eksapi_mock.go index cbc119e961..6f44e0ecee 100644 --- a/pkg/cloud/services/eks/mock_eksiface/eksapi_mock.go +++ b/pkg/cloud/services/eks/mock_eksiface/eksapi_mock.go @@ -172,6 +172,26 @@ func (mr *MockEKSAPIMockRecorder) CreateNodegroup(arg0, arg1 interface{}, arg2 . return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNodegroup", reflect.TypeOf((*MockEKSAPI)(nil).CreateNodegroup), varargs...) } +// CreatePodIdentityAssociation mocks base method. +func (m *MockEKSAPI) CreatePodIdentityAssociation(arg0 context.Context, arg1 *eks.CreatePodIdentityAssociationInput, arg2 ...func(*eks.Options)) (*eks.CreatePodIdentityAssociationOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CreatePodIdentityAssociation", varargs...) + ret0, _ := ret[0].(*eks.CreatePodIdentityAssociationOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreatePodIdentityAssociation indicates an expected call of CreatePodIdentityAssociation. +func (mr *MockEKSAPIMockRecorder) CreatePodIdentityAssociation(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePodIdentityAssociation", reflect.TypeOf((*MockEKSAPI)(nil).CreatePodIdentityAssociation), varargs...) +} + // DeleteAddon mocks base method. func (m *MockEKSAPI) DeleteAddon(arg0 context.Context, arg1 *eks.DeleteAddonInput, arg2 ...func(*eks.Options)) (*eks.DeleteAddonOutput, error) { m.ctrl.T.Helper() @@ -252,6 +272,26 @@ func (mr *MockEKSAPIMockRecorder) DeleteNodegroup(arg0, arg1 interface{}, arg2 . return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNodegroup", reflect.TypeOf((*MockEKSAPI)(nil).DeleteNodegroup), varargs...) } +// DeletePodIdentityAssociation mocks base method. +func (m *MockEKSAPI) DeletePodIdentityAssociation(arg0 context.Context, arg1 *eks.DeletePodIdentityAssociationInput, arg2 ...func(*eks.Options)) (*eks.DeletePodIdentityAssociationOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DeletePodIdentityAssociation", varargs...) + ret0, _ := ret[0].(*eks.DeletePodIdentityAssociationOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeletePodIdentityAssociation indicates an expected call of DeletePodIdentityAssociation. +func (mr *MockEKSAPIMockRecorder) DeletePodIdentityAssociation(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePodIdentityAssociation", reflect.TypeOf((*MockEKSAPI)(nil).DeletePodIdentityAssociation), varargs...) +} + // DescribeAddon mocks base method. func (m *MockEKSAPI) DescribeAddon(arg0 context.Context, arg1 *eks.DescribeAddonInput, arg2 ...func(*eks.Options)) (*eks.DescribeAddonOutput, error) { m.ctrl.T.Helper() @@ -492,6 +532,26 @@ func (mr *MockEKSAPIMockRecorder) ListIdentityProviderConfigs(arg0, arg1 interfa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListIdentityProviderConfigs", reflect.TypeOf((*MockEKSAPI)(nil).ListIdentityProviderConfigs), varargs...) } +// ListPodIdentityAssociations mocks base method. +func (m *MockEKSAPI) ListPodIdentityAssociations(arg0 context.Context, arg1 *eks.ListPodIdentityAssociationsInput, arg2 ...func(*eks.Options)) (*eks.ListPodIdentityAssociationsOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListPodIdentityAssociations", varargs...) + ret0, _ := ret[0].(*eks.ListPodIdentityAssociationsOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPodIdentityAssociations indicates an expected call of ListPodIdentityAssociations. +func (mr *MockEKSAPIMockRecorder) ListPodIdentityAssociations(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPodIdentityAssociations", reflect.TypeOf((*MockEKSAPI)(nil).ListPodIdentityAssociations), varargs...) +} + // TagResource mocks base method. func (m *MockEKSAPI) TagResource(arg0 context.Context, arg1 *eks.TagResourceInput, arg2 ...func(*eks.Options)) (*eks.TagResourceOutput, error) { m.ctrl.T.Helper() diff --git a/pkg/cloud/services/eks/pod_identity.go b/pkg/cloud/services/eks/pod_identity.go new file mode 100644 index 0000000000..ca795a1d99 --- /dev/null +++ b/pkg/cloud/services/eks/pod_identity.go @@ -0,0 +1,120 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package eks + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/eks" + "github.com/aws/aws-sdk-go-v2/service/eks/types" + + ekscontrolplanev1 "sigs.k8s.io/cluster-api-provider-aws/v2/controlplane/eks/api/v1beta2" + ekspodidentities "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/eks/podidentities" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/record" +) + +func (s *Service) reconcilePodIdentities(ctx context.Context) error { + s.scope.Info("Reconciling EKS Pod Identities") + + eksClusterName := s.scope.KubernetesClusterName() + + // Get existing eks pod identities on the cluster + currentAssociations, err := s.listEksPodIdentities(ctx, eksClusterName) + if err != nil { + s.Error(err, "failed listing eks pod identity assocations") + return fmt.Errorf("listing eks pod identity assocations: %w", err) + } + + if len(currentAssociations) == 0 && len(s.scope.ControlPlane.Spec.PodIdentityAssociations) == 0 { + s.scope.Debug("no eks pod identities found, no action needed") + return nil + } + + s.scope.Debug("eks pod identities found, creating reconciliation plan") + desiredAssociations := s.translateAPIToPodAssociation(s.scope.ControlPlane.Spec.PodIdentityAssociations) + existingAssociations := s.translateAWSToPodAssociation(currentAssociations) + + s.scope.Debug("creating eks pod identity association plan", "cluster", eksClusterName) + podAssociationsPlan := ekspodidentities.NewPlan(eksClusterName, desiredAssociations, existingAssociations, s.EKSClient) + procedures, err := podAssociationsPlan.Create(ctx) + if err != nil { + s.scope.Error(err, "failed creating eks pod identity association plan") + return fmt.Errorf("creating eks pod identity association plan: %w", err) + } + for _, procedure := range procedures { + s.scope.Debug("Executing pod association procedure", "name", procedure.Name()) + if err := procedure.Do(ctx); err != nil { + s.scope.Error(err, "failed executing pod association procedure", "name", procedure.Name()) + return fmt.Errorf("executing pod association procedure %s: %w", procedure.Name(), err) + } + } + + record.Eventf(s.scope.ControlPlane, "SuccessfulReconcileEKSClusterPodIdentityAssociations", "Reconciled Pod Identity associations for EKS Cluster %s", s.scope.KubernetesClusterName()) + s.scope.Info("Reconcile EKS pod identity associations completed successfully") + + return nil +} + +func (s *Service) listEksPodIdentities(ctx context.Context, eksClusterName string) ([]types.PodIdentityAssociationSummary, error) { + s.Debug("getting list of associated eks pod identities") + + input := &eks.ListPodIdentityAssociationsInput{ + ClusterName: &eksClusterName, + } + + output, err := s.EKSClient.ListPodIdentityAssociations(ctx, input) + if err != nil { + return nil, fmt.Errorf("listing eks pod identity assocations: %w", err) + } + + return output.Associations, nil +} + +func (s *Service) translateAPIToPodAssociation(assocs []ekscontrolplanev1.PodIdentityAssociation) []ekspodidentities.EKSPodIdentityAssociation { + converted := []ekspodidentities.EKSPodIdentityAssociation{} + + for _, assoc := range assocs { + a := assoc + c := ekspodidentities.EKSPodIdentityAssociation{ + ServiceAccountName: a.ServiceAccountName, + ServiceAccountNamespace: a.ServiceAccountNamespace, + RoleARN: a.RoleARN, + } + + converted = append(converted, c) + } + + return converted +} + +func (s *Service) translateAWSToPodAssociation(assocs []types.PodIdentityAssociationSummary) []ekspodidentities.EKSPodIdentityAssociation { + converted := []ekspodidentities.EKSPodIdentityAssociation{} + + for _, assoc := range assocs { + c := ekspodidentities.EKSPodIdentityAssociation{ + ServiceAccountName: *assoc.ServiceAccount, + ServiceAccountNamespace: *assoc.Namespace, + RoleARN: *assoc.AssociationArn, + AssociationID: *assoc.AssociationId, + } + + converted = append(converted, c) + } + + return converted +} diff --git a/pkg/cloud/services/eks/service.go b/pkg/cloud/services/eks/service.go index c4c274a123..e46a917984 100644 --- a/pkg/cloud/services/eks/service.go +++ b/pkg/cloud/services/eks/service.go @@ -29,6 +29,7 @@ import ( "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/scope" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services" "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services/eks/iam" + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/eks/podidentities" ) // EKSAPI defines the EKS API interface. @@ -62,6 +63,8 @@ type EKSAPI interface { TagResource(ctx context.Context, params *eks.TagResourceInput, optFns ...func(*eks.Options)) (*eks.TagResourceOutput, error) UntagResource(ctx context.Context, params *eks.UntagResourceInput, optFns ...func(*eks.Options)) (*eks.UntagResourceOutput, error) DisassociateIdentityProviderConfig(ctx context.Context, params *eks.DisassociateIdentityProviderConfigInput, optFns ...func(*eks.Options)) (*eks.DisassociateIdentityProviderConfigOutput, error) + ListPodIdentityAssociations(ctx context.Context, params *eks.ListPodIdentityAssociationsInput, optFns ...func(*eks.Options)) (*eks.ListPodIdentityAssociationsOutput, error) + podidentities.EKSAPIPodIdentity // Waiters for EKS Cluster WaitUntilClusterActive(ctx context.Context, params *eks.DescribeClusterInput, maxWait time.Duration) error diff --git a/pkg/eks/podidentities/plan.go b/pkg/eks/podidentities/plan.go new file mode 100644 index 0000000000..cdadbde3d0 --- /dev/null +++ b/pkg/eks/podidentities/plan.go @@ -0,0 +1,106 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package podidentities provides a plan to manage EKS podidentities associations. +package podidentities + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/service/eks" + + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/planner" +) + +// EKSAPIPodIdentity defines the EKS API interface. +type EKSAPIPodIdentity interface { + CreatePodIdentityAssociation(ctx context.Context, params *eks.CreatePodIdentityAssociationInput, optFns ...func(*eks.Options)) (*eks.CreatePodIdentityAssociationOutput, error) + DeletePodIdentityAssociation(ctx context.Context, params *eks.DeletePodIdentityAssociationInput, optFns ...func(*eks.Options)) (*eks.DeletePodIdentityAssociationOutput, error) +} + +// NewPlan creates a new Plan to manage EKS pod identities. +func NewPlan(clusterName string, desiredAssociations, currentAssociations []EKSPodIdentityAssociation, client EKSAPIPodIdentity) planner.Plan { + return &plan{ + currentAssociations: currentAssociations, + desiredAssociations: desiredAssociations, + eksClient: client, + clusterName: clusterName, + } +} + +// Plan is a plan that will manage EKS pod identities. +type plan struct { + currentAssociations []EKSPodIdentityAssociation + desiredAssociations []EKSPodIdentityAssociation + eksClient EKSAPIPodIdentity + clusterName string +} + +func (a *plan) getCurrentAssociation(association EKSPodIdentityAssociation) bool { + for _, current := range a.currentAssociations { + if current.ServiceAccountName == association.ServiceAccountName && current.ServiceAccountNamespace == association.ServiceAccountNamespace { + return true + } + } + return false +} + +func (a *plan) getDesiredAssociation(association EKSPodIdentityAssociation) bool { + for _, desired := range a.desiredAssociations { + if desired.ServiceAccountName == association.ServiceAccountName && desired.ServiceAccountNamespace == association.ServiceAccountNamespace { + return true + } + } + return false +} + +// Create will create the plan (i.e. list of procedures) for managing EKS pod identities. +func (a *plan) Create(_ context.Context) ([]planner.Procedure, error) { + procedures := []planner.Procedure{} + + for _, d := range a.desiredAssociations { + desired := d + existsInCurrent := a.getCurrentAssociation(desired) + + // Create pod association if is doesnt already exist + if !existsInCurrent { + procedures = append(procedures, + &CreatePodIdentityAssociationProcedure{ + eksClient: a.eksClient, + clusterName: a.clusterName, + newAssociation: &desired, + }, + ) + } + } + + for _, current := range a.currentAssociations { + existsInDesired := a.getDesiredAssociation(current) + + if !existsInDesired { + // Delete pod association if it exists + procedures = append(procedures, + &DeletePodIdentityAssociationProcedure{ + eksClient: a.eksClient, + clusterName: a.clusterName, + existingAssociationID: current.AssociationID, + }, + ) + } + } + + return procedures, nil +} diff --git a/pkg/eks/podidentities/plan_test.go b/pkg/eks/podidentities/plan_test.go new file mode 100644 index 0000000000..8b5faed211 --- /dev/null +++ b/pkg/eks/podidentities/plan_test.go @@ -0,0 +1,178 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package podidentities + +import ( + "context" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + "github.com/aws/aws-sdk-go-v2/service/eks/types" + "github.com/golang/mock/gomock" + . "github.com/onsi/gomega" + + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/cloud/services/eks/mock_eksiface" +) + +func TestEKSPodIdentityAssociationsPlan(t *testing.T) { + clusterName := "default.cluster" + namespace := "my-namespace" + roleArn := "aws://rolearn" + responseAssociationArn := "aws://association-arn" + associationID := "aws://association-id" + serviceAccount := "my-service-account" + created := time.Now() + + testCases := []struct { + name string + desired []EKSPodIdentityAssociation + current []EKSPodIdentityAssociation + expect func(m *mock_eksiface.MockEKSAPIMockRecorder) + expectCreateError bool + expectDoError bool + }{ + { + name: "no desired and no current", + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + // Do nothing + }, + expectCreateError: false, + expectDoError: false, + }, + { + name: "no current and 1 desired", + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m. + CreatePodIdentityAssociation(gomock.Any(), gomock.Eq(&eks.CreatePodIdentityAssociationInput{ + Namespace: aws.String(namespace), + RoleArn: aws.String(roleArn), + ServiceAccount: aws.String(serviceAccount), + ClusterName: aws.String(clusterName), + })). + Return(&eks.CreatePodIdentityAssociationOutput{ + Association: &types.PodIdentityAssociation{ + AssociationArn: aws.String(responseAssociationArn), + AssociationId: aws.String(associationID), + Namespace: aws.String(namespace), + RoleArn: aws.String(roleArn), + ServiceAccount: aws.String(serviceAccount), + ClusterName: aws.String(clusterName), + CreatedAt: &created, + ModifiedAt: &created, + }, + }, nil) + }, + desired: []EKSPodIdentityAssociation{ + { + ServiceAccountName: serviceAccount, + ServiceAccountNamespace: namespace, + RoleARN: roleArn, + }, + }, + current: []EKSPodIdentityAssociation{}, + expectCreateError: false, + expectDoError: false, + }, + { + name: "1 current and 1 desired", + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) {}, + desired: []EKSPodIdentityAssociation{ + { + ServiceAccountName: serviceAccount, + ServiceAccountNamespace: namespace, + RoleARN: roleArn, + }, + }, + current: []EKSPodIdentityAssociation{ + { + ServiceAccountName: serviceAccount, + ServiceAccountNamespace: namespace, + RoleARN: roleArn, + }, + }, + expectCreateError: false, + expectDoError: false, + }, + { + name: "1 current and 0 desired", + expect: func(m *mock_eksiface.MockEKSAPIMockRecorder) { + m. + DeletePodIdentityAssociation(gomock.Any(), gomock.Eq(&eks.DeletePodIdentityAssociationInput{ + AssociationId: aws.String(associationID), + ClusterName: aws.String(clusterName), + })). + Return(&eks.DeletePodIdentityAssociationOutput{ + Association: &types.PodIdentityAssociation{ + AssociationArn: aws.String(responseAssociationArn), + AssociationId: aws.String(associationID), + Namespace: aws.String(namespace), + RoleArn: aws.String(roleArn), + ServiceAccount: aws.String(serviceAccount), + ClusterName: aws.String(clusterName), + CreatedAt: &created, + ModifiedAt: &created, + }, + }, nil) + }, + desired: []EKSPodIdentityAssociation{}, + current: []EKSPodIdentityAssociation{ + { + ServiceAccountName: serviceAccount, + ServiceAccountNamespace: namespace, + RoleARN: roleArn, + AssociationID: associationID, + }, + }, + expectCreateError: false, + expectDoError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mockControl := gomock.NewController(t) + defer mockControl.Finish() + + eksMock := mock_eksiface.NewMockEKSAPI(mockControl) + tc.expect(eksMock.EXPECT()) + + ctx := context.TODO() + + planner := NewPlan(clusterName, tc.desired, tc.current, eksMock) + procedures, err := planner.Create(ctx) + if tc.expectCreateError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + g.Expect(procedures).NotTo(BeNil()) + + for _, proc := range procedures { + procErr := proc.Do(ctx) + if tc.expectDoError { + g.Expect(procErr).To(HaveOccurred()) + return + } + g.Expect(procErr).To(BeNil()) + } + }) + } +} diff --git a/pkg/eks/podidentities/procedures.go b/pkg/eks/podidentities/procedures.go new file mode 100644 index 0000000000..9f92c379dc --- /dev/null +++ b/pkg/eks/podidentities/procedures.go @@ -0,0 +1,88 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package podidentities + +import ( + "context" + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" +) + +// errPodIdentityAssociationNotFound defines an error for when an eks pod identity is not found. +var errPodIdentityAssociationNotFound = errors.New("eks pod identity association not found") + +// DeletePodIdentityAssociationProcedure is a procedure that will delete an EKS eks pod identity. +type DeletePodIdentityAssociationProcedure struct { + eksClient EKSAPIPodIdentity + clusterName string + existingAssociationID string +} + +// Do implements the logic for the procedure. +func (p *DeletePodIdentityAssociationProcedure) Do(ctx context.Context) error { + input := &eks.DeletePodIdentityAssociationInput{ + AssociationId: aws.String(p.existingAssociationID), + ClusterName: aws.String(p.clusterName), + } + + if _, err := p.eksClient.DeletePodIdentityAssociation(ctx, input); err != nil { + return fmt.Errorf("deleting eks pod identity %s: %w", p.existingAssociationID, err) + } + + return nil +} + +// Name is the name of the procedure. +func (p *DeletePodIdentityAssociationProcedure) Name() string { + return "eks_pod_identity_delete" +} + +// CreatePodIdentityAssociationProcedure is a procedure that will create an EKS eks pod identity for a cluster. +type CreatePodIdentityAssociationProcedure struct { + eksClient EKSAPIPodIdentity + clusterName string + newAssociation *EKSPodIdentityAssociation +} + +// Do implements the logic for the procedure. +func (p *CreatePodIdentityAssociationProcedure) Do(ctx context.Context) error { + if p.newAssociation == nil { + return fmt.Errorf("getting desired eks pod identity for cluster %s: %w", p.clusterName, errPodIdentityAssociationNotFound) + } + + input := &eks.CreatePodIdentityAssociationInput{ + ClusterName: aws.String(p.clusterName), + Namespace: &p.newAssociation.ServiceAccountNamespace, + RoleArn: &p.newAssociation.RoleARN, + ServiceAccount: &p.newAssociation.ServiceAccountName, + } + + _, err := p.eksClient.CreatePodIdentityAssociation(ctx, input) + if err != nil { + return fmt.Errorf("creating desired eks pod identity for cluster %s: %w", p.clusterName, err) + } + + return nil +} + +// Name is the name of the procedure. +func (p *CreatePodIdentityAssociationProcedure) Name() string { + return "eks_pod_identity_create" +} diff --git a/pkg/eks/podidentities/types.go b/pkg/eks/podidentities/types.go new file mode 100644 index 0000000000..3469aea0b8 --- /dev/null +++ b/pkg/eks/podidentities/types.go @@ -0,0 +1,25 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package podidentities + +// EKSPodIdentityAssociation represents an EKS pod identity association. +type EKSPodIdentityAssociation struct { + ServiceAccountName string + ServiceAccountNamespace string + RoleARN string + AssociationID string +}