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 d94cb5acd8..5a3f4b4c22 100644 --- a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml +++ b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml @@ -2637,6 +2637,35 @@ 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: + - 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 57284afd25..ef5d2ca099 100644 --- a/controlplane/eks/api/v1beta1/conversion.go +++ b/controlplane/eks/api/v1beta1/conversion.go @@ -40,6 +40,7 @@ func (r *AWSManagedControlPlane) ConvertTo(dstRaw conversion.Hub) error { } dst.Spec.VpcCni.Disable = r.Spec.DisableVPCCNI dst.Spec.Partition = restored.Spec.Partition + 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 ecc37543d6..377f64ad62 100644 --- a/controlplane/eks/api/v1beta1/zz_generated.conversion.go +++ b/controlplane/eks/api/v1beta1/zz_generated.conversion.go @@ -389,6 +389,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 fa96f494d8..4928bf9371 100644 --- a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go +++ b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go @@ -164,6 +164,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 4b44508b65..9598fc4485 100644 --- a/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go +++ b/controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go @@ -24,6 +24,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" @@ -63,8 +64,10 @@ func (r *AWSManagedControlPlane) SetupWebhookWithManager(mgr ctrl.Manager) error // +kubebuilder:webhook:verbs=create;update,path=/validate-controlplane-cluster-x-k8s-io-v1beta2-awsmanagedcontrolplane,mutating=false,failurePolicy=fail,matchPolicy=Equivalent,groups=controlplane.cluster.x-k8s.io,resources=awsmanagedcontrolplanes,versions=v1beta2,name=validation.awsmanagedcontrolplanes.controlplane.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 // +kubebuilder:webhook:verbs=create;update,path=/mutate-controlplane-cluster-x-k8s-io-v1beta2-awsmanagedcontrolplane,mutating=true,failurePolicy=fail,matchPolicy=Equivalent,groups=controlplane.cluster.x-k8s.io,resources=awsmanagedcontrolplanes,versions=v1beta2,name=default.awsmanagedcontrolplanes.controlplane.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1;v1beta1 -var _ webhook.Defaulter = &AWSManagedControlPlane{} -var _ webhook.Validator = &AWSManagedControlPlane{} +var ( + _ webhook.Defaulter = &AWSManagedControlPlane{} + _ webhook.Validator = &AWSManagedControlPlane{} +) func parseEKSVersion(raw string) (*version.Version, error) { v, err := version.ParseGeneric(raw) @@ -95,6 +98,7 @@ func (r *AWSManagedControlPlane) ValidateCreate() (admission.Warnings, error) { 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 @@ -129,6 +133,7 @@ func (r *AWSManagedControlPlane) ValidateUpdate(old runtime.Object) (admission.W 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, @@ -403,6 +408,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 1ef47215ce..90bbff8eb4 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 160f556db9..d1b22b796a 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) @@ -552,6 +557,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 7a642d847c..bb056c5154 100644 --- a/controlplane/eks/controllers/awsmanagedcontrolplane_controller_test.go +++ b/controlplane/eks/controllers/awsmanagedcontrolplane_controller_test.go @@ -409,7 +409,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{ @@ -469,7 +470,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{ @@ -901,6 +903,12 @@ func mockedEKSCluster(g *WithT, eksRec *mock_eksiface.MockEKSAPIMockRecorder, ia ClusterName: aws.String("test-cluster"), }).Return(&eks.ListAddonsOutput{}, nil) + eksRec.ListPodIdentityAssociationsWithContext(context.TODO(), gomock.Eq(&eks.ListPodIdentityAssociationsInput{ + ClusterName: aws.String("test-cluster"), + })).Return(&eks.ListPodIdentityAssociationsOutput{ + Associations: []*eks.PodIdentityAssociationSummary{}, + }, nil) + awsNodeRec.ReconcileCNI(gomock.Any()).Return(nil) kubeProxyRec.ReconcileKubeProxy(gomock.Any()).Return(nil) iamAuthenticatorRec.ReconcileIAMAuthenticator(gomock.Any()).Return(nil) diff --git a/docs/book/src/SUMMARY_PREFIX.md b/docs/book/src/SUMMARY_PREFIX.md index e6e2a1c65c..7bfe1f6d1e 100644 --- a/docs/book/src/SUMMARY_PREFIX.md +++ b/docs/book/src/SUMMARY_PREFIX.md @@ -19,6 +19,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/pkg/cloud/services/eks/addons.go b/pkg/cloud/services/eks/addons.go index 45c9a8cd82..552c8b59d3 100644 --- a/pkg/cloud/services/eks/addons.go +++ b/pkg/cloud/services/eks/addons.go @@ -38,7 +38,7 @@ func (s *Service) reconcileAddons(ctx context.Context) error { // Get available addon names for the cluster addonNames, err := s.listAddons(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 958230bccd..b6fb483f8b 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, err.Error()) diff --git a/pkg/cloud/services/eks/pod_identity.go b/pkg/cloud/services/eks/pod_identity.go new file mode 100644 index 0000000000..3a537b9eed --- /dev/null +++ b/pkg/cloud/services/eks/pod_identity.go @@ -0,0 +1,119 @@ +/* +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/service/eks" + + 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) ([]*eks.PodIdentityAssociationSummary, error) { + s.Debug("getting list of associated eks pod identities") + + input := &eks.ListPodIdentityAssociationsInput{ + ClusterName: &eksClusterName, + } + + output, err := s.EKSClient.ListPodIdentityAssociationsWithContext(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 []*eks.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/eks/podidentities/plan.go b/pkg/eks/podidentities/plan.go new file mode 100644 index 0000000000..48a052545a --- /dev/null +++ b/pkg/eks/podidentities/plan.go @@ -0,0 +1,100 @@ +/* +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/service/eks/eksiface" + + "sigs.k8s.io/cluster-api-provider-aws/v2/pkg/planner" +) + +// NewPlan creates a new Plan to manage EKS pod identities. +func NewPlan(clusterName string, desiredAssociations, currentAssociations []EKSPodIdentityAssociation, client eksiface.EKSAPI) 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 eksiface.EKSAPI + 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..441a816932 --- /dev/null +++ b/pkg/eks/podidentities/plan_test.go @@ -0,0 +1,177 @@ +/* +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/aws" + "github.com/aws/aws-sdk-go/service/eks" + "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.Eq(&eks.CreatePodIdentityAssociationInput{ + Namespace: aws.String(namespace), + RoleArn: aws.String(roleArn), + ServiceAccount: aws.String(serviceAccount), + ClusterName: aws.String(clusterName), + })). + Return(&eks.CreatePodIdentityAssociationOutput{ + Association: &eks.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.Eq(&eks.DeletePodIdentityAssociationInput{ + AssociationId: aws.String(associationID), + ClusterName: aws.String(clusterName), + })). + Return(&eks.DeletePodIdentityAssociationOutput{ + Association: &eks.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..3181b69e9d --- /dev/null +++ b/pkg/eks/podidentities/procedures.go @@ -0,0 +1,89 @@ +/* +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/aws" + "github.com/aws/aws-sdk-go/service/eks" + "github.com/aws/aws-sdk-go/service/eks/eksiface" +) + +// 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 eksiface.EKSAPI + clusterName string + existingAssociationID string +} + +// Do implements the logic for the procedure. +func (p *DeletePodIdentityAssociationProcedure) Do(_ context.Context) error { + input := &eks.DeletePodIdentityAssociationInput{ + AssociationId: aws.String(p.existingAssociationID), + ClusterName: aws.String(p.clusterName), + } + + if _, err := p.eksClient.DeletePodIdentityAssociation(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 eksiface.EKSAPI + clusterName string + newAssociation *EKSPodIdentityAssociation +} + +// Do implements the logic for the procedure. +func (p *CreatePodIdentityAssociationProcedure) Do(_ 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(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 +}