Skip to content

✨ Add EKS Pod Identities to AWSManagedControlPlane #4906

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
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
serviceAccountRoleARN:
description: RoleARN is the ARN of an IAM role which the Service
Account can assume.
type: string
required:
- serviceAccountName
- serviceAccountNamespace
type: object
type: array
region:
description: The AWS Region the cluster lives in.
type: string
Expand Down
1 change: 1 addition & 0 deletions controlplane/eks/api/v1beta1/conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions controlplane/eks/api/v1beta1/zz_generated.conversion.go

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

5 changes: 5 additions & 0 deletions controlplane/eks/api/v1beta2/awsmanagedcontrolplane_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions controlplane/eks/api/v1beta2/awsmanagedcontrolplane_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions controlplane/eks/api/v1beta2/conditions_consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 18 additions & 6 deletions controlplane/eks/api/v1beta2/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:"serviceAccountRoleARN,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're all called serviceAccountXXX which is an antipattern in Kubernetes manifests. I'd recommend moving these to .serviceAccount.{name,namespace,roleARN}.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal here was to be overtly explicit as I have found that our customers get confused on that sometimes. Nothing that docs cannot fix however

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In thinking more about this, I remembered that the kubernetes Pod spec switched from serviceAccount to serviceAccountName a few years back.
I do err on the side of clarity for maintainability's sake when it comes to naming, but would definitely not go against whatever is considered standard. Do you have a reference for the naming convention to help me decide?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In thinking about this more, whilst the medium is a CRD, the keys are in reference to an AWS object. I would expect a PodIdentityAssociation.Name to refer to the name of an AWS object when using the AWS API

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pod.spec.serviceAccountName is a singular field that won't change. It doesn't allow referring to another namespace on purpose.

The 3 fields you're adding will always be filled and none can be optional or defaulted (even the namespace), so a common prefix is unusual for newly-designed CRDs. See for example Ingress.spec.defaultBackend.service.{name,port} or Gateway API's Gateway.[...].listeners[*].{name,protocol,port}. Core APIs such as Pod aren't great examples since they can't be evolved without lots of breaking changes, and also a pod spec mostly only refers to names and doesn't provide many comparable examples here.

Therefore, here's my suggestion (dedenting roleARN as compared to my initial comment, since it's not referring to a service account):

  podIdentityAssociations:
    - serviceAccount:
        name: myserviceaccount
        namespace: default
      roleARN: arn:aws:iam::012345678901:role/capi-test-role

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is definitely a back and forth on the semantics on this. I personally dont see this as being a blocking change, and moreso a semantic preference.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least the JSON field name and struct name should be aligned, since currently they're not lowercase-equal which could be confusing:

Suggested change
// 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:"serviceAccountRoleARN,omitempty"`
// 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
ServiceAccountRoleARN string `json:"serviceAccountRoleARN,omitempty"`

Or the other way around (json:"roleARN") – doesn't matter.

Then the documented examples in docs/book/src/topics/eks/pod-identity-associations.md must be converted to the final naming choice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, the roleARN is an AWS resource, not a service account, so the naming shouldnt be consistent. The suggested shouldn't be committed imo

}
20 changes: 20 additions & 0 deletions controlplane/eks/api/v1beta2/zz_generated.deepcopy.go

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

Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/book/src/SUMMARY_PREFIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
89 changes: 89 additions & 0 deletions docs/book/src/topics/eks/pod-identity-associations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 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:
- serviceAccount:
namespace: default
name: myserviceaccount
roleARN: arn:aws:iam::012345678901:role/capi-test-role
```

- `serviceAccount.namespace` and `serviceAccount.name` refer to the [`ServiceAccount`](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/) object in the Kubernetes cluster
- `serviceAccount.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
serviceAccountRoleARN: arn:aws:iam::012345678901:role/capi-test-role
- serviceAccountNamespace: another-namespace
serviceAccountName: another-service-account
serviceAccountRoleARN: arn:aws:iam::012345678901:role/capi-test-role
```
2 changes: 1 addition & 1 deletion pkg/cloud/services/eks/addons.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
7 changes: 7 additions & 0 deletions pkg/cloud/services/eks/eks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading