diff --git a/integration/tests/pod_identity_associations/pod_identity_associations_test.go b/integration/tests/pod_identity_associations/pod_identity_associations_test.go new file mode 100644 index 0000000000..7d230e473f --- /dev/null +++ b/integration/tests/pod_identity_associations/pod_identity_associations_test.go @@ -0,0 +1,455 @@ +//go:build integration +// +build integration + +package podidentityassociations + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awseks "github.com/aws/aws-sdk-go-v2/service/eks" + "github.com/aws/aws-sdk-go-v2/service/iam" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + . "github.com/weaveworks/eksctl/integration/runner" + + "github.com/weaveworks/eksctl/integration/tests" + "github.com/weaveworks/eksctl/pkg/actions/podidentityassociation" + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + "github.com/weaveworks/eksctl/pkg/eks" + "github.com/weaveworks/eksctl/pkg/testutils" +) + +const ( + nsInitial = "initial" + nsCLI = "cli" + nsConfigFile = "config-file" + nsUnowned = "unowned" + + sa1 = "service-account-1" + sa2 = "service-account-2" + sa3 = "service-account-3" + + rolePrefix = "eksctl-" + initialRole1 = rolePrefix + "pod-identity-role-1" + initialRole2 = rolePrefix + "pod-identity-role-2" +) + +var ( + params *tests.Params + ctl *eks.ClusterProvider + role1ARN, role2ARN string + err error +) + +func init() { + // Call testing.Init() prior to tests.NewParams(), as otherwise -test.* will not be recognised. See also: https://golang.org/doc/go1.13#testing + testing.Init() + params = tests.NewParamsWithGivenClusterName("pod-identity-associations", "test") + ctl, err = eks.New(context.TODO(), &api.ProviderConfig{Region: params.Region}, nil) + if err != nil { + panic(err) + } +} + +func TestPodIdentityAssociations(t *testing.T) { + testutils.RegisterAndRun(t) +} + +var _ = BeforeSuite(func() { + roleOutput, err := ctl.AWSProvider.IAM().CreateRole(context.Background(), &iam.CreateRoleInput{ + RoleName: aws.String(initialRole1), + AssumeRolePolicyDocument: trustPolicy, + }) + Expect(err).NotTo(HaveOccurred()) + role1ARN = *roleOutput.Role.Arn + + roleOutput, err = ctl.AWSProvider.IAM().CreateRole(context.Background(), &iam.CreateRoleInput{ + RoleName: aws.String(initialRole2), + AssumeRolePolicyDocument: trustPolicy, + }) + Expect(err).NotTo(HaveOccurred()) + role2ARN = *roleOutput.Role.Arn +}) + +var _ = Describe("(Integration) [PodIdentityAssociations Test]", Ordered, func() { + + Context("Cluster with pod identity associations", func() { + var ( + cfg *api.ClusterConfig + ) + + BeforeAll(func() { + cfg = makeClusterConfig() + }) + + It("should create a cluster with pod identity associations", func() { + cfg.Addons = []*api.Addon{{Name: api.PodIdentityAgentAddon}} + cfg.IAM.PodIdentityAssociations = []api.PodIdentityAssociation{ + { + Namespace: nsInitial, + ServiceAccountName: sa1, + RoleARN: role1ARN, + }, + { + Namespace: nsInitial, + ServiceAccountName: sa2, + RoleARN: role1ARN, + }, + { + Namespace: nsInitial, + ServiceAccountName: sa3, + RoleARN: role1ARN, + }, + } + + data, err := json.Marshal(cfg) + Expect(err).NotTo(HaveOccurred()) + + Expect(params.EksctlCreateCmd. + WithArgs( + "cluster", + "--config-file", "-", + "--verbose", "4", + ). + WithoutArg("--region", params.Region). + WithStdin(bytes.NewReader(data))).To(RunSuccessfully()) + }) + + It("should fetch all expected associations", func() { + var output []podidentityassociation.Summary + session := params.EksctlGetCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--output", "json", + ).Run() + Expect(session.ExitCode()).To(Equal(0)) + Expect(json.Unmarshal(session.Out.Contents(), &output)).To(Succeed()) + Expect(output).To(HaveLen(3)) + }) + + Context("Create new pod identity associations", func() { + It("should fail to create a new association for the same namespace & service account", func() { + Expect(params.EksctlCreateCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsInitial, + "--service-account-name", sa1, + "--role-arn", role1ARN, + ), + ).NotTo(RunSuccessfully()) + }) + + It("should create a new association via CLI", func() { + Expect(params.EksctlCreateCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsCLI, + "--service-account-name", sa1, + "--well-known-policies", "certManager", + ), + ).To(RunSuccessfully()) + }) + + It("should create (multiple) associations via config file", func() { + cfg.IAM.PodIdentityAssociations = []api.PodIdentityAssociation{ + { + Namespace: nsConfigFile, + ServiceAccountName: sa1, + WellKnownPolicies: api.WellKnownPolicies{ + AutoScaler: true, + ExternalDNS: true, + }, + }, + { + Namespace: nsConfigFile, + ServiceAccountName: sa2, + PermissionPolicy: permissionPolicy, + }, + } + + data, err := json.Marshal(cfg) + Expect(err).NotTo(HaveOccurred()) + + Expect(params.EksctlCreateCmd. + WithArgs( + "podidentityassociation", + "--config-file", "-", + ). + WithoutArg("--region", params.Region). + WithStdin(bytes.NewReader(data)), + ).To(RunSuccessfully()) + }) + }) + + Context("Fetching pod identity associations", func() { + It("should fetch all associations for a cluster", func() { + var output []podidentityassociation.Summary + session := params.EksctlGetCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--output", "json", + ).Run() + Expect(session.ExitCode()).To(Equal(0)) + Expect(json.Unmarshal(session.Out.Contents(), &output)).To(Succeed()) + Expect(output).To(HaveLen(6)) + }) + + It("should fetch all associations for a namespace", func() { + var output []podidentityassociation.Summary + session := params.EksctlGetCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsConfigFile, + "--output", "json", + ).Run() + Expect(session.ExitCode()).To(Equal(0)) + Expect(json.Unmarshal(session.Out.Contents(), &output)).To(Succeed()) + Expect(output).To(HaveLen(2)) + }) + + It("should fetch a single association defined by namespace & service account", func() { + var output []podidentityassociation.Summary + session := params.EksctlGetCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsConfigFile, + "--service-account-name", sa1, + "--output", "json", + ).Run() + Expect(session.ExitCode()).To(Equal(0)) + Expect(json.Unmarshal(session.Out.Contents(), &output)).To(Succeed()) + Expect(output).To(HaveLen(1)) + }) + }) + + Context("Updating pod identity associations", func() { + It("should fail to update an association with role created by eksctl", func() { + Expect(params.EksctlUpdateCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsCLI, + "--service-account-name", sa1, + "--role-arn", role1ARN, + ), + ).NotTo(RunSuccessfully()) + }) + + It("should update an association via CLI", func() { + Expect(params.EksctlUpdateCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsInitial, + "--service-account-name", sa1, + "--role-arn", role2ARN, + ), + ).To(RunSuccessfully()) + }) + + It("should update (multiple) associations via config file", func() { + cfg.IAM.PodIdentityAssociations = []api.PodIdentityAssociation{ + { + Namespace: nsInitial, + ServiceAccountName: sa2, + RoleARN: role2ARN, + }, + { + Namespace: nsInitial, + ServiceAccountName: sa3, + RoleARN: role2ARN, + }, + } + + data, err := json.Marshal(cfg) + Expect(err).NotTo(HaveOccurred()) + + Expect(params.EksctlUpdateCmd. + WithArgs( + "podidentityassociation", + "--config-file", "-", + ). + WithoutArg("--region", params.Region). + WithStdin(bytes.NewReader(data)), + ).To(RunSuccessfully()) + }) + + It("should check all associations were updated successfully", func() { + var output []podidentityassociation.Summary + session := params.EksctlGetCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsInitial, + "--output", "json", + ).Run() + Expect(session.ExitCode()).To(Equal(0)) + Expect(json.Unmarshal(session.Out.Contents(), &output)).To(Succeed()) + Expect(output).To(HaveLen(3)) + Expect(output[0].RoleARN).To(Equal(role2ARN)) + Expect(output[1].RoleARN).To(Equal(role2ARN)) + Expect(output[2].RoleARN).To(Equal(role2ARN)) + }) + }) + + Context("Deleting pod identity associations", func() { + It("should delete an association via CLI", func() { + Expect(params.EksctlDeleteCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsInitial, + "--service-account-name", sa1, + ), + ).To(RunSuccessfully()) + }) + + It("should delete (multiple) associations via config file", func() { + cfg.IAM.PodIdentityAssociations = []api.PodIdentityAssociation{ + { + Namespace: nsInitial, + ServiceAccountName: sa2, + }, + { + Namespace: nsInitial, + ServiceAccountName: sa3, + }, + } + + data, err := json.Marshal(cfg) + Expect(err).NotTo(HaveOccurred()) + + Expect(params.EksctlDeleteCmd. + WithArgs( + "podidentityassociation", + "--config-file", "-", + ). + WithoutArg("--region", params.Region). + WithStdin(bytes.NewReader(data)), + ).To(RunSuccessfully()) + }) + + It("should check that all associations were deleted successfully", func() { + Expect(params.EksctlGetCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsInitial, + )).To(RunSuccessfullyWithOutputStringLines(ContainElement("No podidentityassociations found"))) + }) + }) + + Context("Unowned pod identity association", func() { + BeforeAll(func() { + _, err := ctl.AWSProvider.EKS().CreatePodIdentityAssociation(context.Background(), + &awseks.CreatePodIdentityAssociationInput{ + ClusterName: ¶ms.ClusterName, + Namespace: aws.String(nsUnowned), + ServiceAccount: aws.String(sa1), + RoleArn: &role1ARN, + }) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should fetch an unowned association", func() { + Expect(params.EksctlGetCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsUnowned, + "--service-account-name", sa1, + "--output", "json", + )).To(RunSuccessfullyWithOutputStringLines(ContainElements( + ContainSubstring(nsUnowned), + ContainSubstring(sa1), + ))) + }) + + It("should delete an unowned association", func() { + Expect(params.EksctlDeleteCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsUnowned, + "--service-account-name", sa1, + )).To(RunSuccessfully()) + + Expect(params.EksctlGetCmd. + WithArgs( + "podidentityassociation", + "--cluster", params.ClusterName, + "--namespace", nsUnowned, + "--service-account-name", sa1, + )).To(RunSuccessfullyWithOutputStringLines(ContainElement("No podidentityassociations found"))) + }) + }) + }) +}) + +var _ = AfterSuite(func() { + _, err = ctl.AWSProvider.IAM().DeleteRole(context.Background(), &iam.DeleteRoleInput{ + RoleName: aws.String(initialRole1), + }) + Expect(err).NotTo(HaveOccurred()) + + _, err = ctl.AWSProvider.IAM().DeleteRole(context.Background(), &iam.DeleteRoleInput{ + RoleName: aws.String(initialRole2), + }) + Expect(err).NotTo(HaveOccurred()) + + params.DeleteClusters() +}) + +var ( + makeClusterConfig = func() *api.ClusterConfig { + cfg := api.NewClusterConfig() + cfg.Metadata.Name = params.ClusterName + cfg.Metadata.Version = params.Version + cfg.Metadata.Region = params.Region + return cfg + } + + trustPolicy = aws.String(fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "%s" + ] + }, + "Action": [ + "sts:AssumeRole", + "sts:TagSession" + ] + } + ] + }`, api.EKSServicePrincipal)) + + permissionPolicy = api.InlineDocument{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + { + "Effect": "Allow", + "Action": []string{ + "autoscaling:DescribeAutoScalingGroups", + "autoscaling:DescribeAutoScalingInstances", + }, + "Resource": "*", + }, + }, + } +) diff --git a/pkg/actions/addon/tasks.go b/pkg/actions/addon/tasks.go index f0f618b48f..0c5142eb18 100644 --- a/pkg/actions/addon/tasks.go +++ b/pkg/actions/addon/tasks.go @@ -18,7 +18,7 @@ func CreateAddonTasks(ctx context.Context, cfg *api.ClusterConfig, clusterProvid var preAddons []*api.Addon var postAddons []*api.Addon for _, addon := range cfg.Addons { - if strings.ToLower(addon.Name) == "vpc-cni" { + if strings.EqualFold(addon.Name, api.VPCCNIAddon) { preAddons = append(preAddons, addon) } else { postAddons = append(postAddons, addon) diff --git a/pkg/actions/cluster/owned.go b/pkg/actions/cluster/owned.go index 6e996c8640..8c0a4afa98 100644 --- a/pkg/actions/cluster/owned.go +++ b/pkg/actions/cluster/owned.go @@ -11,12 +11,14 @@ import ( "github.com/weaveworks/eksctl/pkg/actions/addon" "github.com/weaveworks/eksctl/pkg/actions/nodegroup" + "github.com/weaveworks/eksctl/pkg/actions/podidentityassociation" api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" "github.com/weaveworks/eksctl/pkg/cfn/manager" "github.com/weaveworks/eksctl/pkg/ctl/cmdutils" "github.com/weaveworks/eksctl/pkg/eks" iamoidc "github.com/weaveworks/eksctl/pkg/iam/oidc" "github.com/weaveworks/eksctl/pkg/kubernetes" + "github.com/weaveworks/eksctl/pkg/utils/tasks" "github.com/weaveworks/eksctl/pkg/vpc" ) @@ -121,7 +123,12 @@ func (c *OwnedCluster) Delete(ctx context.Context, _, podEvictionWaitPeriod time return c.ctl.NewOpenIDConnectManager(ctx, c.cfg) } newTasksToDeleteAddonIAM := addon.NewRemover(c.stackManager).DeleteAddonIAMTasks - tasks, err := c.stackManager.NewTasksToDeleteClusterWithNodeGroups(ctx, c.clusterStack, allStacks, clusterOperable, newOIDCManager, newTasksToDeleteAddonIAM, c.ctl.Status.ClusterInfo.Cluster, kubernetes.NewCachedClientSet(clientSet), wait, force, func(errs chan error, _ string) error { + newTasksToDeletePodIdentityRoles := func() (*tasks.TaskTree, error) { + return podidentityassociation.NewDeleter(c.cfg.Metadata.Name, c.stackManager, c.ctl.AWSProvider.EKS()). + DeleteTasks(ctx, []podidentityassociation.Identifier{}) + } + + tasks, err := c.stackManager.NewTasksToDeleteClusterWithNodeGroups(ctx, c.clusterStack, allStacks, clusterOperable, newOIDCManager, newTasksToDeleteAddonIAM, newTasksToDeletePodIdentityRoles, c.ctl.Status.ClusterInfo.Cluster, kubernetes.NewCachedClientSet(clientSet), wait, force, func(errs chan error, _ string) error { logger.Info("trying to cleanup dangling network interfaces") stack, err := c.stackManager.DescribeClusterStack(ctx) if err != nil { diff --git a/pkg/actions/podidentityassociation/creator.go b/pkg/actions/podidentityassociation/creator.go index 47ef4c2ea8..210cf3cf31 100644 --- a/pkg/actions/podidentityassociation/creator.go +++ b/pkg/actions/podidentityassociation/creator.go @@ -3,9 +3,6 @@ package podidentityassociation import ( "context" "fmt" - "strings" - - "github.com/kris-nova/logger" api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" "github.com/weaveworks/eksctl/pkg/awsapi" @@ -28,19 +25,7 @@ func NewCreator(clusterName string, stackManager StackManager, eksAPI awsapi.EKS } func (c *Creator) CreatePodIdentityAssociations(ctx context.Context, podIdentityAssociations []api.PodIdentityAssociation) error { - taskTree := c.CreateTasks(ctx, podIdentityAssociations) - logger.Info(taskTree.Describe()) - - if errs := taskTree.DoAllSync(); len(errs) > 0 { - var allErrs []string - for _, err := range errs { - allErrs = append(allErrs, err.Error()) - } - return fmt.Errorf(strings.Join(allErrs, "\n")) - } - - logger.Info("successfully created all pod identity associations") - return nil + return runAllTasks(c.CreateTasks(ctx, podIdentityAssociations)) } func (c *Creator) CreateTasks(ctx context.Context, podIdentityAssociations []api.PodIdentityAssociation) *tasks.TaskTree { diff --git a/pkg/actions/podidentityassociation/deleter.go b/pkg/actions/podidentityassociation/deleter.go new file mode 100644 index 0000000000..960455e8f5 --- /dev/null +++ b/pkg/actions/podidentityassociation/deleter.go @@ -0,0 +1,186 @@ +package podidentityassociation + +import ( + "context" + "fmt" + "strings" + + cfntypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + + "golang.org/x/exp/slices" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + + "github.com/kris-nova/logger" + + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + "github.com/weaveworks/eksctl/pkg/cfn/manager" + "github.com/weaveworks/eksctl/pkg/utils/tasks" +) + +// A StackLister lists and describes CloudFormation stacks. +type StackLister interface { + ListStackNames(ctx context.Context, regExp string) ([]string, error) + DescribeStack(ctx context.Context, stack *manager.Stack) (*manager.Stack, error) +} + +// A StackDeleter lists and deletes CloudFormation stacks. +type StackDeleter interface { + StackLister + DeleteStackBySpecSync(ctx context.Context, stack *cfntypes.Stack, errCh chan error) error +} + +// APILister lists pod identity associations using the EKS API. +type APILister interface { + ListPodIdentityAssociations(ctx context.Context, params *eks.ListPodIdentityAssociationsInput, optFns ...func(*eks.Options)) (*eks.ListPodIdentityAssociationsOutput, error) +} + +// APIDeleter lists and deletes pod identity associations using the EKS API. +type APIDeleter interface { + APILister + DeletePodIdentityAssociation(ctx context.Context, params *eks.DeletePodIdentityAssociationInput, optFns ...func(*eks.Options)) (*eks.DeletePodIdentityAssociationOutput, error) +} + +// A Deleter deletes pod identity associations. +type Deleter struct { + // ClusterName is the cluster name. + ClusterName string + // StackDeleter is used to delete stacks. + StackDeleter StackDeleter + // APIDeleter deletes pod identity associations using the EKS API. + APIDeleter APIDeleter +} + +// Identifier represents a pod identity association. +type Identifier struct { + // Namespace is the namespace the service account belongs to. + Namespace string + // ServiceAccountName is the name of the Kubernetes ServiceAccount. + ServiceAccountName string +} + +func NewDeleter(clusterName string, stackDeleter StackDeleter, apiDeleter APIDeleter) *Deleter { + return &Deleter{ + ClusterName: clusterName, + StackDeleter: stackDeleter, + APIDeleter: apiDeleter, + } +} + +// Delete deletes the specified podIdentityAssociations. +func (d *Deleter) Delete(ctx context.Context, podIDs []Identifier) error { + tasks, err := d.DeleteTasks(ctx, podIDs) + if err != nil { + return err + } + return runAllTasks(tasks) +} + +func (d *Deleter) DeleteTasks(ctx context.Context, podIDs []Identifier) (*tasks.TaskTree, error) { + roleStackNames, err := d.StackDeleter.ListStackNames(ctx, fmt.Sprintf("^%s*", makeStackNamePrefix(d.ClusterName))) + if err != nil { + return nil, fmt.Errorf("error listing stack names for pod identity associations: %w", err) + } + taskTree := &tasks.TaskTree{Parallel: true} + + // this is true during cluster deletion, when no association identifier is given as user input, + // instead we will delete all pod-identity-role stacks for the cluster + if len(podIDs) == 0 { + for _, stackName := range roleStackNames { + name := strings.Clone(stackName) + taskTree.Append(&tasks.GenericTask{ + Description: fmt.Sprintf("deleting IAM resources stack %q", stackName), + Doer: func() error { + return d.deleteRoleStack(ctx, name) + }, + }) + } + return taskTree, nil + } + + for _, p := range podIDs { + taskTree.Append(d.makeDeleteTask(ctx, p, roleStackNames)) + } + return taskTree, nil +} + +func (d *Deleter) makeDeleteTask(ctx context.Context, p Identifier, roleStackNames []string) tasks.Task { + podIdentityAssociationID := makeID(p.Namespace, p.ServiceAccountName) + return &tasks.GenericTask{ + Description: fmt.Sprintf("delete pod identity association %q", podIdentityAssociationID), + Doer: func() error { + if err := d.deletePodIdentityAssociation(ctx, p, roleStackNames, podIdentityAssociationID); err != nil { + return fmt.Errorf("error deleting pod identity association %q: %w", podIdentityAssociationID, err) + } + return nil + }, + } +} + +func (d *Deleter) deletePodIdentityAssociation(ctx context.Context, p Identifier, roleStackNames []string, podIdentityAssociationID string) error { + output, err := d.APIDeleter.ListPodIdentityAssociations(ctx, &eks.ListPodIdentityAssociationsInput{ + ClusterName: aws.String(d.ClusterName), + Namespace: aws.String(p.Namespace), + ServiceAccount: aws.String(p.ServiceAccountName), + }) + if err != nil { + return fmt.Errorf("listing pod identity associations: %w", err) + } + switch len(output.Associations) { + case 0: + logger.Warning("pod identity association %q not found", podIdentityAssociationID) + default: + return fmt.Errorf("expected to find only 1 pod identity association for %q; got %d", podIdentityAssociationID, len(output.Associations)) + case 1: + if _, err := d.APIDeleter.DeletePodIdentityAssociation(ctx, &eks.DeletePodIdentityAssociationInput{ + ClusterName: aws.String(d.ClusterName), + AssociationId: output.Associations[0].AssociationId, + }); err != nil { + return fmt.Errorf("deleting pod identity association: %w", err) + } + } + + stackName := MakeStackName(d.ClusterName, p.Namespace, p.ServiceAccountName) + if !slices.Contains(roleStackNames, stackName) { + return nil + } + logger.Info("deleting IAM resources stack %q for pod identity association %q", stackName, podIdentityAssociationID) + return d.deleteRoleStack(ctx, stackName) +} + +func (d *Deleter) deleteRoleStack(ctx context.Context, stackName string) error { + stack, err := d.StackDeleter.DescribeStack(ctx, &manager.Stack{ + StackName: aws.String(stackName), + }) + if err != nil { + return fmt.Errorf("describing stack %q: %w", stackName, err) + } + + deleteStackCh := make(chan error) + if err := d.StackDeleter.DeleteStackBySpecSync(ctx, stack, deleteStackCh); err != nil { + return fmt.Errorf("deleting stack %q for IAM role: %w", stackName, err) + } + select { + case err := <-deleteStackCh: + return err + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for deletion of pod identity association: %w", ctx.Err()) + } +} + +// ToIdentifiers maps a list of PodIdentityAssociations to a list of Identifiers. +func ToIdentifiers(podIdentityAssociations []api.PodIdentityAssociation) []Identifier { + identifiers := make([]Identifier, len(podIdentityAssociations)) + for i, p := range podIdentityAssociations { + identifiers[i] = Identifier{ + Namespace: p.Namespace, + ServiceAccountName: p.ServiceAccountName, + } + } + return identifiers +} + +func makeID(namespace, serviceAccountName string) string { + return fmt.Sprintf("%s/%s", namespace, serviceAccountName) +} diff --git a/pkg/actions/podidentityassociation/deleter_test.go b/pkg/actions/podidentityassociation/deleter_test.go new file mode 100644 index 0000000000..03d8a7f017 --- /dev/null +++ b/pkg/actions/podidentityassociation/deleter_test.go @@ -0,0 +1,295 @@ +package podidentityassociation_test + +import ( + "context" + "crypto/sha1" + "fmt" + + cfntypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + "github.com/stretchr/testify/mock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/weaveworks/eksctl/pkg/actions/podidentityassociation" + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + managerfakes "github.com/weaveworks/eksctl/pkg/cfn/manager/fakes" + "github.com/weaveworks/eksctl/pkg/eks/mocksv2" + "github.com/weaveworks/eksctl/pkg/testutils/mockprovider" +) + +var _ = Describe("Pod Identity Deleter", func() { + type deleteEntry struct { + podIdentityAssociations []api.PodIdentityAssociation + mockCalls func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) + + expectedCalls func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) + expectedErr string + } + + mockStackManager := func(stackManager *managerfakes.FakeStackManager, stackName string) { + stackManager.DescribeStackReturns(&cfntypes.Stack{ + StackName: aws.String(stackName), + }, nil) + stackManager.DeleteStackBySpecSyncStub = func(_ context.Context, _ *cfntypes.Stack, errCh chan error) error { + close(errCh) + return nil + } + } + mockCalls := func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS, podID podidentityassociation.Identifier) { + stackName := makeStackName(podID) + associationID := fmt.Sprintf("%x", sha1.Sum([]byte(stackName))) + mockListPodIdentityAssociations(eksAPI, podID, []ekstypes.PodIdentityAssociationSummary{ + { + AssociationId: aws.String(associationID), + }, + }, nil) + eksAPI.On("DeletePodIdentityAssociation", mock.Anything, &eks.DeletePodIdentityAssociationInput{ + ClusterName: aws.String(clusterName), + AssociationId: aws.String(associationID), + }).Return(&eks.DeletePodIdentityAssociationOutput{}, nil) + mockStackManager(stackManager, stackName) + } + + DescribeTable("delete pod identity association", func(e deleteEntry) { + provider := mockprovider.NewMockProvider() + var stackManager managerfakes.FakeStackManager + e.mockCalls(&stackManager, provider.MockEKS()) + deleter := podidentityassociation.Deleter{ + ClusterName: clusterName, + StackDeleter: &stackManager, + APIDeleter: provider.EKS(), + } + err := deleter.Delete(context.Background(), podidentityassociation.ToIdentifiers(e.podIdentityAssociations)) + + if e.expectedErr != "" { + Expect(err).To(MatchError(e.expectedErr)) + } else { + Expect(err).NotTo(HaveOccurred()) + } + e.expectedCalls(&stackManager, provider.MockEKS()) + }, + Entry("one pod identity association exists", deleteEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podID := podidentityassociation.Identifier{ + Namespace: "default", + ServiceAccountName: "default", + } + mockListStackNames(stackManager, []podidentityassociation.Identifier{podID}) + mockCalls(stackManager, eksAPI, podID) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(1)) + eksAPI.AssertExpectations(GinkgoT()) + }, + }), + + Entry("multiple pod identity associations exist", deleteEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podIDs := []podidentityassociation.Identifier{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + } + mockListStackNames(stackManager, podIDs) + for _, podID := range podIDs { + mockCalls(stackManager, eksAPI, podID) + } + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(2)) + eksAPI.AssertExpectations(GinkgoT()) + }, + }), + + Entry("some pod identity associations do not exist", deleteEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + { + Namespace: "kube-system", + ServiceAccountName: "kube-proxy", + }, + { + Namespace: "kube-system", + ServiceAccountName: "coredns", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podIDs := []podidentityassociation.Identifier{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + { + Namespace: "kube-system", + ServiceAccountName: "coredns", + }, + } + mockListStackNames(stackManager, podIDs) + for _, podID := range podIDs { + mockCalls(stackManager, eksAPI, podID) + } + mockListPodIdentityAssociations(eksAPI, podidentityassociation.Identifier{ + Namespace: "kube-system", + ServiceAccountName: "kube-proxy", + }, nil, nil) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(3)) + Expect(stackManager.DeleteStackBySpecSyncCallCount()).To(Equal(3)) + eksAPI.AssertExpectations(GinkgoT()) + }, + }), + + Entry("pod identity association resource does not exist but IAM resources exist", deleteEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podID := podidentityassociation.Identifier{ + Namespace: "kube-system", + ServiceAccountName: "aws-node", + } + mockListStackNames(stackManager, []podidentityassociation.Identifier{podID}) + mockListPodIdentityAssociations(eksAPI, podID, nil, nil) + mockStackManager(stackManager, makeStackName(podID)) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(1)) + Expect(stackManager.DeleteStackBySpecSyncCallCount()).To(Equal(1)) + eksAPI.AssertExpectations(GinkgoT()) + }, + }), + + Entry("no pod identity associations exist", deleteEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podIDs := []podidentityassociation.Identifier{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + } + mockListStackNames(stackManager, nil) + for _, podID := range podIDs { + mockListPodIdentityAssociations(eksAPI, podID, nil, nil) + } + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(0)) + Expect(stackManager.DeleteStackBySpecSyncCallCount()).To(Equal(0)) + eksAPI.AssertExpectations(GinkgoT()) + }, + }), + + Entry("delete IAM resources on cluster deletion", deleteEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{}, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podIDs := []podidentityassociation.Identifier{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + { + Namespace: "kube-system", + ServiceAccountName: "default", + }, + } + mockListStackNames(stackManager, podIDs) + mockStackManager(stackManager, "") + }, + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(3)) + Expect(stackManager.DeleteStackBySpecSyncCallCount()).To(Equal(3)) + + var names []string + for i := 0; i < stackManager.DescribeStackCallCount(); i++ { + _, stack := stackManager.DescribeStackArgsForCall(i) + names = append(names, *stack.StackName) + } + Expect(names).To(ConsistOf( + makeStackName(podidentityassociation.Identifier{ + Namespace: "default", + ServiceAccountName: "default", + }), + makeStackName(podidentityassociation.Identifier{ + Namespace: "kube-system", + ServiceAccountName: "default", + }), + makeStackName(podidentityassociation.Identifier{ + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }), + )) + }, + }), + ) + +}) diff --git a/pkg/actions/podidentityassociation/tasks.go b/pkg/actions/podidentityassociation/tasks.go index aa872f0d87..e694225940 100644 --- a/pkg/actions/podidentityassociation/tasks.go +++ b/pkg/actions/podidentityassociation/tasks.go @@ -3,12 +3,15 @@ package podidentityassociation import ( "context" "fmt" + "strings" awseks "github.com/aws/aws-sdk-go-v2/service/eks" + "github.com/kris-nova/logger" api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" "github.com/weaveworks/eksctl/pkg/awsapi" "github.com/weaveworks/eksctl/pkg/cfn/builder" + "github.com/weaveworks/eksctl/pkg/utils/tasks" ) //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate @@ -68,14 +71,32 @@ func (t *createPodIdentityAssociationTask) Do(errorCh chan error) error { ServiceAccount: &t.podIdentityAssociation.ServiceAccountName, Tags: t.podIdentityAssociation.Tags, }); err != nil { - return fmt.Errorf("creating pod identity association for service account %s in namespace %s: %w", + return fmt.Errorf( + "creating pod identity association for service account %s in namespace %s: %w", t.podIdentityAssociation.ServiceAccountName, t.podIdentityAssociation.Namespace, err) } return nil } +func makeStackNamePrefix(clusterName string) string { + return fmt.Sprintf("eksctl-%s-podidentityrole-ns-", clusterName) +} + // MakeStackName creates a stack name for the specified access entry. func MakeStackName(clusterName, namespace, serviceAccountName string) string { - return fmt.Sprintf("eksctl-%s-podidentityrole-ns-%s-sa-%s", clusterName, namespace, serviceAccountName) + return fmt.Sprintf("%s%s-sa-%s", makeStackNamePrefix(clusterName), namespace, serviceAccountName) +} + +func runAllTasks(taskTree *tasks.TaskTree) error { + logger.Info(taskTree.Describe()) + if errs := taskTree.DoAllSync(); len(errs) > 0 { + var allErrs []string + for _, err := range errs { + allErrs = append(allErrs, err.Error()) + } + return fmt.Errorf(strings.Join(allErrs, "\n")) + } + logger.Info("successfully finished all tasks") + return nil } diff --git a/pkg/actions/podidentityassociation/updater.go b/pkg/actions/podidentityassociation/updater.go new file mode 100644 index 0000000000..7d373d7aae --- /dev/null +++ b/pkg/actions/podidentityassociation/updater.go @@ -0,0 +1,203 @@ +package podidentityassociation + +import ( + "context" + "errors" + "fmt" + "reflect" + "time" + + "golang.org/x/exp/slices" + + cfntypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + + "github.com/kris-nova/logger" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + "github.com/weaveworks/eksctl/pkg/cfn/builder" + "github.com/weaveworks/eksctl/pkg/cfn/manager" + "github.com/weaveworks/eksctl/pkg/utils/apierrors" + "github.com/weaveworks/eksctl/pkg/utils/tasks" +) + +// An Updater updates pod identity associations. +type Updater struct { + // ClusterName is the cluster name. + ClusterName string + // StackUpdater updates stacks. + StackUpdater StackUpdater + // APIDeleter updates pod identity associations using the EKS API. + APIUpdater APIUpdater +} + +// A StackUpdater updates CloudFormation stacks. +type StackUpdater interface { + StackLister + // MustUpdateStack updates the CloudFormation stack. + MustUpdateStack(ctx context.Context, options manager.UpdateStackOptions) error +} + +// APIUpdater updates pod identity associations using the EKS API. +type APIUpdater interface { + APILister + DescribePodIdentityAssociation(ctx context.Context, params *eks.DescribePodIdentityAssociationInput, optFns ...func(*eks.Options)) (*eks.DescribePodIdentityAssociationOutput, error) + UpdatePodIdentityAssociation(ctx context.Context, params *eks.UpdatePodIdentityAssociationInput, optFns ...func(*eks.Options)) (*eks.UpdatePodIdentityAssociationOutput, error) +} + +type updateConfig struct { + podIdentityAssociation api.PodIdentityAssociation + associationID string + hasIAMResourcesStack bool + stackName string +} + +// Update updates the specified pod identity associations. +func (u *Updater) Update(ctx context.Context, podIdentityAssociations []api.PodIdentityAssociation) error { + roleStackNames, err := u.StackUpdater.ListStackNames(ctx, makeStackNamePrefix(u.ClusterName)) + if err != nil { + return fmt.Errorf("error listing stack names for pod identity associations: %w", err) + } + taskTree := &tasks.TaskTree{ + Parallel: true, + } + for _, p := range podIdentityAssociations { + podIdentityAssociationID := makeID(p.Namespace, p.ServiceAccountName) + updateErr := func(err error) error { + return fmt.Errorf("error updating pod identity association %q: %w", podIdentityAssociationID, err) + } + updateConfig, err := u.makeUpdate(ctx, p, roleStackNames) + if err != nil { + return updateErr(err) + } + taskTree.Append(&tasks.GenericTask{ + Description: fmt.Sprintf("update pod identity association %s", podIdentityAssociationID), + Doer: func() error { + if err := u.update(ctx, updateConfig, podIdentityAssociationID); err != nil { + return updateErr(err) + } + return nil + }, + }) + } + logger.Info(taskTree.Describe()) + return runAllTasks(taskTree) +} + +func (u *Updater) update(ctx context.Context, updateConfig *updateConfig, podIdentityAssociationID string) error { + if !updateConfig.hasIAMResourcesStack { + return u.updatePodIdentityAssociation(ctx, updateConfig, podIdentityAssociationID) + } + + stack, err := u.StackUpdater.DescribeStack(ctx, &manager.Stack{ + StackName: aws.String(updateConfig.stackName), + }) + if err != nil { + return fmt.Errorf("describing IAM resources stack %q: %w", updateConfig.stackName, err) + } + if updateConfig.podIdentityAssociation.RoleName != "" && !slices.Contains(stack.Capabilities, cfntypes.CapabilityCapabilityNamedIam) { + return errors.New("cannot update role name if the pod identity association was not created with a role name") + } + rs := builder.NewIAMRoleResourceSetForPodIdentity(&updateConfig.podIdentityAssociation) + if err := rs.AddAllResources(); err != nil { + return fmt.Errorf("adding resources to CloudFormation template: %w", err) + } + template, err := rs.RenderJSON() + if err != nil { + return fmt.Errorf("generating CloudFormation template: %w", err) + } + if err := u.StackUpdater.MustUpdateStack(ctx, manager.UpdateStackOptions{ + StackName: updateConfig.stackName, + ChangeSetName: fmt.Sprintf("eksctl-%s-%s-update-%d", updateConfig.podIdentityAssociation.Namespace, updateConfig.podIdentityAssociation.ServiceAccountName, time.Now().Unix()), + Description: fmt.Sprintf("updating IAM resources stack %q for pod identity association %q", updateConfig.stackName, podIdentityAssociationID), + TemplateData: manager.TemplateBody(template), + Wait: true, + }); err != nil { + if _, ok := err.(*manager.NoChangeError); ok { + logger.Info("IAM resources for %q are already up-to-date", podIdentityAssociationID) + return nil + } + return fmt.Errorf("updating IAM resources for pod identity association: %w", err) + } + logger.Info("updated IAM resources stack %q for %q", updateConfig.stackName, podIdentityAssociationID) + stack, err = u.StackUpdater.DescribeStack(ctx, &manager.Stack{ + StackName: aws.String(updateConfig.stackName), + }) + if err != nil { + return fmt.Errorf("describing IAM resources stack: %w", err) + } + if err := rs.GetAllOutputs(*stack); err != nil { + return fmt.Errorf("error getting IAM role output from IAM resources stack: %w", err) + } + + return u.updatePodIdentityAssociation(ctx, updateConfig, podIdentityAssociationID) +} + +func (u *Updater) updatePodIdentityAssociation(ctx context.Context, updateConfig *updateConfig, podIdentityAssociationID string) error { + roleARN := updateConfig.podIdentityAssociation.RoleARN + if _, err := u.APIUpdater.UpdatePodIdentityAssociation(ctx, &eks.UpdatePodIdentityAssociationInput{ + AssociationId: aws.String(updateConfig.associationID), + ClusterName: aws.String(u.ClusterName), + RoleArn: aws.String(roleARN), + }); err != nil { + return fmt.Errorf("updating pod identity association (associationID: %s, roleARN: %s): %w", updateConfig.associationID, roleARN, err) + } + logger.Info("updated role ARN %q for pod identity association %q", roleARN, podIdentityAssociationID) + return nil +} + +func (u *Updater) makeUpdate(ctx context.Context, p api.PodIdentityAssociation, roleStackNames []string) (*updateConfig, error) { + const notFoundErrMsg = "pod identity association does not exist" + output, err := u.APIUpdater.ListPodIdentityAssociations(ctx, &eks.ListPodIdentityAssociationsInput{ + ClusterName: aws.String(u.ClusterName), + Namespace: aws.String(p.Namespace), + ServiceAccount: aws.String(p.ServiceAccountName), + }) + if err != nil { + if apierrors.IsNotFoundError(err) { + return nil, fmt.Errorf("%s: %w", notFoundErrMsg, err) + } + return nil, fmt.Errorf("error listing pod identity associations: %w", err) + } + switch len(output.Associations) { + case 0: + return nil, errors.New(notFoundErrMsg) + default: + return nil, fmt.Errorf("expected to find only 1 pod identity association; got %d", len(output.Associations)) + case 1: + describeOutput, err := u.APIUpdater.DescribePodIdentityAssociation(ctx, &eks.DescribePodIdentityAssociationInput{ + ClusterName: aws.String(u.ClusterName), + AssociationId: output.Associations[0].AssociationId, + }) + if err != nil { + return nil, fmt.Errorf("error describing pod identity association: %w", err) + } + stackName := MakeStackName(u.ClusterName, p.Namespace, p.ServiceAccountName) + hasIAMResourcesStack := slices.Contains(roleStackNames, stackName) + if hasIAMResourcesStack { + if describeOutput.Association.RoleArn != nil && p.RoleARN != "" && p.RoleARN != *describeOutput.Association.RoleArn { + return nil, errors.New("cannot change podIdentityAssociation.roleARN since the role was created by eksctl") + } + } else { + if p.RoleARN == "" { + return nil, errors.New("podIdentityAssociation.roleARN is required since the role was not created by eksctl") + } + podIDWithRoleARN := api.PodIdentityAssociation{ + Namespace: p.Namespace, + ServiceAccountName: p.ServiceAccountName, + RoleARN: p.RoleARN, + } + if !reflect.DeepEqual(p, podIDWithRoleARN) { + return nil, errors.New("only namespace, serviceAccountName and roleARN can be specified if the role was not created by eksctl") + } + } + return &updateConfig{ + podIdentityAssociation: p, + associationID: *describeOutput.Association.AssociationId, + hasIAMResourcesStack: hasIAMResourcesStack, + stackName: stackName, + }, nil + } +} diff --git a/pkg/actions/podidentityassociation/updater_test.go b/pkg/actions/podidentityassociation/updater_test.go new file mode 100644 index 0000000000..cb73a0e160 --- /dev/null +++ b/pkg/actions/podidentityassociation/updater_test.go @@ -0,0 +1,422 @@ +package podidentityassociation_test + +import ( + "context" + "crypto/sha1" + "fmt" + + cfntypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types" + ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/eks" + "github.com/stretchr/testify/mock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/weaveworks/eksctl/pkg/actions/podidentityassociation" + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + "github.com/weaveworks/eksctl/pkg/cfn/manager" + managerfakes "github.com/weaveworks/eksctl/pkg/cfn/manager/fakes" + "github.com/weaveworks/eksctl/pkg/cfn/outputs" + "github.com/weaveworks/eksctl/pkg/eks/mocksv2" + "github.com/weaveworks/eksctl/pkg/testutils/mockprovider" +) + +var _ = Describe("Pod Identity Update", func() { + type updateEntry struct { + podIdentityAssociations []api.PodIdentityAssociation + mockCalls func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) + + expectedCalls func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) + expectedErr string + } + + mockStackManager := func(stackManager *managerfakes.FakeStackManager, stackName string, outputs []cfntypes.Output, capabilities []cfntypes.Capability) { + stackManager.DescribeStackReturns(&cfntypes.Stack{ + StackName: aws.String(stackName), + Outputs: outputs, + Capabilities: capabilities, + }, nil) + } + + type mockOptions struct { + podIdentifier podidentityassociation.Identifier + updateRoleARN string + describeStackOutputs []cfntypes.Output + describeStackCapabilities []cfntypes.Capability + } + + mockCalls := func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS, o mockOptions) { + stackName := makeStackName(o.podIdentifier) + associationID := fmt.Sprintf("%x", sha1.Sum([]byte(stackName))) + mockListPodIdentityAssociations(eksAPI, o.podIdentifier, []ekstypes.PodIdentityAssociationSummary{ + { + AssociationId: aws.String(associationID), + }, + }, nil) + eksAPI.On("DescribePodIdentityAssociation", mock.Anything, &eks.DescribePodIdentityAssociationInput{ + AssociationId: aws.String(associationID), + ClusterName: aws.String(clusterName), + }).Return(&eks.DescribePodIdentityAssociationOutput{ + Association: &ekstypes.PodIdentityAssociation{ + AssociationId: aws.String(associationID), + RoleArn: aws.String("arn:aws:iam::1234567:role/Role"), + }, + }, nil) + if o.updateRoleARN != "" { + eksAPI.On("UpdatePodIdentityAssociation", mock.Anything, &eks.UpdatePodIdentityAssociationInput{ + AssociationId: aws.String(associationID), + ClusterName: aws.String(clusterName), + RoleArn: aws.String(o.updateRoleARN), + }).Return(&eks.UpdatePodIdentityAssociationOutput{}, nil) + } + mockStackManager(stackManager, stackName, o.describeStackOutputs, o.describeStackCapabilities) + } + + DescribeTable("update pod identity associations", func(e updateEntry) { + provider := mockprovider.NewMockProvider() + var stackManager managerfakes.FakeStackManager + + e.mockCalls(&stackManager, provider.MockEKS()) + updater := podidentityassociation.Updater{ + ClusterName: clusterName, + StackUpdater: &stackManager, + APIUpdater: provider.EKS(), + } + err := updater.Update(context.Background(), e.podIdentityAssociations) + if e.expectedErr != "" { + Expect(err).To(MatchError(e.expectedErr)) + } else { + Expect(err).NotTo(HaveOccurred()) + } + e.expectedCalls(&stackManager, provider.MockEKS()) + }, + Entry("pod identity association does not exist", updateEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podID := podidentityassociation.Identifier{ + Namespace: "default", + ServiceAccountName: "default", + } + mockListStackNames(stackManager, nil) + mockListPodIdentityAssociations(eksAPI, podID, nil, &ekstypes.NotFoundException{ + Message: aws.String("not found"), + }) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(0)) + Expect(stackManager.MustUpdateStackCallCount()).To(Equal(0)) + eksAPI.AssertExpectations(GinkgoT()) + }, + expectedErr: `error updating pod identity association "default/default": pod identity association does not exist: NotFoundException: not found`, + }), + + Entry("role ARN specified when the IAM resources were created by eksctl", updateEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + RoleARN: "arn:aws:iam::00000000:role/new-role", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + id := podidentityassociation.Identifier{ + Namespace: "default", + ServiceAccountName: "default", + } + mockListStackNames(stackManager, []podidentityassociation.Identifier{id}) + mockCalls(stackManager, eksAPI, mockOptions{ + podIdentifier: id, + }) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(0)) + Expect(stackManager.MustUpdateStackCallCount()).To(Equal(0)) + eksAPI.AssertExpectations(GinkgoT()) + }, + + expectedErr: `error updating pod identity association "default/default": cannot change podIdentityAssociation.roleARN since the role was created by eksctl`, + }), + + Entry("role ARN specified when the IAM resources were not created by eksctl", updateEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + RoleARN: "arn:aws:iam::00000000:role/new-role", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + mockListStackNames(stackManager, nil) + mockCalls(stackManager, eksAPI, mockOptions{ + podIdentifier: podidentityassociation.Identifier{ + Namespace: "default", + ServiceAccountName: "default", + }, + updateRoleARN: "arn:aws:iam::00000000:role/new-role", + }) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(0)) + Expect(stackManager.MustUpdateStackCallCount()).To(Equal(0)) + eksAPI.AssertExpectations(GinkgoT()) + }, + }), + + Entry("pod identity association has changes", updateEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podIdentifiers := []podidentityassociation.Identifier{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + } + mockListStackNames(stackManager, podIdentifiers) + describeStackOutputs := []cfntypes.Output{ + { + OutputKey: aws.String(outputs.IAMServiceAccountRoleName), + OutputValue: aws.String("arn:aws:iam::1234567:role/Role"), + }, + } + for _, options := range []mockOptions{ + { + podIdentifier: podIdentifiers[0], + updateRoleARN: "arn:aws:iam::1234567:role/Role", + describeStackOutputs: describeStackOutputs, + }, + { + podIdentifier: podIdentifiers[1], + updateRoleARN: "arn:aws:iam::1234567:role/Role", + describeStackOutputs: describeStackOutputs, + }, + } { + mockCalls(stackManager, eksAPI, options) + } + + stackManager.MustUpdateStackReturns(nil) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(4)) + Expect(stackManager.MustUpdateStackCallCount()).To(Equal(2)) + eksAPI.AssertExpectations(GinkgoT()) + }, + }), + + Entry("pod identity association has no changes", updateEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podIdentifiers := []podidentityassociation.Identifier{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + } + mockListStackNames(stackManager, podIdentifiers) + for _, options := range []mockOptions{ + { + podIdentifier: podIdentifiers[0], + }, + { + podIdentifier: podIdentifiers[1], + }, + } { + mockCalls(stackManager, eksAPI, options) + } + + stackManager.MustUpdateStackReturns(&manager.NoChangeError{ + Msg: "no changes found", + }) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(2)) + Expect(stackManager.MustUpdateStackCallCount()).To(Equal(2)) + eksAPI.AssertExpectations(GinkgoT()) + }, + }), + + Entry("fields that cannot be updated specified when the IAM resources were not created by eksctl", updateEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + RoleARN: "arn:aws:iam::00000000:role/new-role", + WellKnownPolicies: api.WellKnownPolicies{ + AutoScaler: true, + }, + PermissionPolicyARNs: []string{"arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"}, + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + mockListStackNames(stackManager, nil) + mockCalls(stackManager, eksAPI, mockOptions{ + podIdentifier: podidentityassociation.Identifier{ + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + }) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(0)) + Expect(stackManager.MustUpdateStackCallCount()).To(Equal(0)) + eksAPI.AssertExpectations(GinkgoT()) + }, + + expectedErr: `error updating pod identity association "kube-system/aws-node": only namespace, serviceAccountName and roleARN can be specified if the role was not created by eksctl`, + }), + + Entry("roleName specified when the pod identity association was not created with a roleName", updateEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + RoleName: "default-role", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podIdentifier := podidentityassociation.Identifier{ + Namespace: "kube-system", + ServiceAccountName: "aws-node", + } + mockListStackNames(stackManager, []podidentityassociation.Identifier{podIdentifier}) + + mockCalls(stackManager, eksAPI, mockOptions{ + podIdentifier: podIdentifier, + describeStackCapabilities: []cfntypes.Capability{cfntypes.CapabilityCapabilityIam}, + }) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(1)) + Expect(stackManager.MustUpdateStackCallCount()).To(Equal(0)) + eksAPI.AssertExpectations(GinkgoT()) + }, + + expectedErr: `error updating pod identity association "kube-system/aws-node": cannot update role name if the pod identity association was not created with a role name`, + }), + + Entry("roleName specified when the pod identity association was created with a roleName", updateEntry{ + podIdentityAssociations: []api.PodIdentityAssociation{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + RoleName: "default-role", + }, + }, + mockCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + podIdentifiers := []podidentityassociation.Identifier{ + { + Namespace: "default", + ServiceAccountName: "default", + }, + { + Namespace: "kube-system", + ServiceAccountName: "aws-node", + }, + } + mockListStackNames(stackManager, podIdentifiers) + describeStackOutputs := []cfntypes.Output{ + { + OutputKey: aws.String(outputs.IAMServiceAccountRoleName), + OutputValue: aws.String("arn:aws:iam::1234567:role/Role"), + }, + } + for _, options := range []mockOptions{ + { + podIdentifier: podIdentifiers[0], + describeStackOutputs: describeStackOutputs, + updateRoleARN: "arn:aws:iam::1234567:role/Role", + }, + { + podIdentifier: podIdentifiers[1], + updateRoleARN: "arn:aws:iam::1234567:role/Role", + describeStackOutputs: describeStackOutputs, + describeStackCapabilities: []cfntypes.Capability{cfntypes.CapabilityCapabilityIam, cfntypes.CapabilityCapabilityNamedIam}, + }, + } { + mockCalls(stackManager, eksAPI, options) + } + stackManager.MustUpdateStackReturns(nil) + }, + + expectedCalls: func(stackManager *managerfakes.FakeStackManager, eksAPI *mocksv2.EKS) { + Expect(stackManager.ListStackNamesCallCount()).To(Equal(1)) + Expect(stackManager.DescribeStackCallCount()).To(Equal(4)) + Expect(stackManager.MustUpdateStackCallCount()).To(Equal(2)) + eksAPI.AssertExpectations(GinkgoT()) + }, + }), + ) +}) + +func mockListPodIdentityAssociations(eksAPI *mocksv2.EKS, podID podidentityassociation.Identifier, output []ekstypes.PodIdentityAssociationSummary, err error) { + eksAPI.On("ListPodIdentityAssociations", mock.Anything, &eks.ListPodIdentityAssociationsInput{ + ClusterName: aws.String(clusterName), + Namespace: aws.String(podID.Namespace), + ServiceAccount: aws.String(podID.ServiceAccountName), + }).Return(&eks.ListPodIdentityAssociationsOutput{ + Associations: output, + }, err) +} + +func makeStackName(podID podidentityassociation.Identifier) string { + return fmt.Sprintf("eksctl-%s-podidentityrole-ns-%s-sa-%s", clusterName, podID.Namespace, podID.ServiceAccountName) +} + +func mockListStackNames(stackManager *managerfakes.FakeStackManager, podIdentifiers []podidentityassociation.Identifier) { + var stackNames []string + for _, id := range podIdentifiers { + stackNames = append(stackNames, makeStackName(id)) + } + stackManager.ListStackNamesReturns(stackNames, nil) +} diff --git a/pkg/apis/eksctl.io/v1alpha5/iam.go b/pkg/apis/eksctl.io/v1alpha5/iam.go index 12a7aefc92..af81ab3382 100644 --- a/pkg/apis/eksctl.io/v1alpha5/iam.go +++ b/pkg/apis/eksctl.io/v1alpha5/iam.go @@ -10,6 +10,7 @@ import ( // Commonly-used constants const ( AnnotationEKSRoleARN = "eks.amazonaws.com/role-arn" + EKSServicePrincipal = "pods.eks.amazonaws.com" ) // ClusterIAM holds all IAM attributes of a cluster diff --git a/pkg/apis/eksctl.io/v1alpha5/validation.go b/pkg/apis/eksctl.io/v1alpha5/validation.go index d0f071d2b2..8c2f193290 100644 --- a/pkg/apis/eksctl.io/v1alpha5/validation.go +++ b/pkg/apis/eksctl.io/v1alpha5/validation.go @@ -207,10 +207,6 @@ func ValidateClusterConfig(cfg *ClusterConfig) error { return fmt.Errorf("failed to validate Karpenter config: %w", err) } - if err := validatePodIdentityAssociations(cfg); err != nil { - return fmt.Errorf("failed to validate pod identity associations: %w", err) - } - return nil } @@ -286,36 +282,6 @@ func validateCloudWatchLogging(clusterConfig *ClusterConfig) error { return nil } -func validatePodIdentityAssociations(cfg *ClusterConfig) error { - for i, pia := range cfg.IAM.PodIdentityAssociations { - path := fmt.Sprintf("podIdentityAssociations[%d]", i) - if pia.Namespace == "" { - return fmt.Errorf("%s.namespace must be set", path) - } - if pia.ServiceAccountName == "" { - return fmt.Errorf("%s.serviceAccountName must be set", path) - } - if pia.RoleARN == "" && - len(pia.PermissionPolicy) == 0 && - len(pia.PermissionPolicyARNs) == 0 && - !pia.WellKnownPolicies.HasPolicy() { - return fmt.Errorf("at least one of the following must be specified: %[1]s.roleARN, %[1]s.permissionPolicy, %[1]s.permissionPolicyARNs, %[1]s.wellKnownPolicies", path) - } - if pia.RoleARN != "" { - if len(pia.PermissionPolicy) > 0 { - return fmt.Errorf("%[1]s.permissionPolicy cannot be specified when %[1]s.roleARN is set", path) - } - if len(pia.PermissionPolicyARNs) > 0 { - return fmt.Errorf("%[1]s.permissionPolicyARNs cannot be specified when %[1]s.roleARN is set", path) - } - if pia.WellKnownPolicies.HasPolicy() { - return fmt.Errorf("%[1]s.wellKnownPolicies cannot be specified when %[1]s.roleARN is set", path) - } - } - } - return nil -} - // ValidateVPCConfig validates the vpc setting if it is defined. func (c *ClusterConfig) ValidateVPCConfig() error { if c.VPC == nil { diff --git a/pkg/cfn/builder/iam.go b/pkg/cfn/builder/iam.go index c701dcecd8..3b87555ddb 100644 --- a/pkg/cfn/builder/iam.go +++ b/pkg/cfn/builder/iam.go @@ -8,7 +8,6 @@ import ( "github.com/kris-nova/logger" "github.com/weaveworks/eksctl/pkg/iam" - "github.com/weaveworks/eksctl/pkg/utils/names" gfniam "github.com/weaveworks/goformation/v4/cloudformation/iam" gfnt "github.com/weaveworks/goformation/v4/cloudformation/types" @@ -232,7 +231,7 @@ func NewIAMRoleResourceSetForPodIdentity(spec *api.PodIdentityAssociation) *IAMR serviceAccount: spec.ServiceAccountName, namespace: spec.Namespace, wellKnownPolicies: spec.WellKnownPolicies, - roleName: names.ForIAMRole(spec.RoleName), + roleName: spec.RoleName, permissionsBoundary: spec.PermissionsBoundaryARN, description: fmt.Sprintf( "IAM role for pod identity association %s", diff --git a/pkg/cfn/manager/api.go b/pkg/cfn/manager/api.go index 3533e9d2d9..1288284aea 100644 --- a/pkg/cfn/manager/api.go +++ b/pkg/cfn/manager/api.go @@ -326,6 +326,10 @@ func (c *StackCollection) checkASGTagsNumber(ngName, asgName string, propagatedT // UpdateStack will update a CloudFormation stack by creating and executing a ChangeSet func (c *StackCollection) UpdateStack(ctx context.Context, options UpdateStackOptions) error { + return c.updateStack(ctx, options, true) +} + +func (c *StackCollection) updateStack(ctx context.Context, options UpdateStackOptions, ignoreNoChangeError bool) error { logger.Info(options.Description) if options.Stack == nil { i := &Stack{StackName: &options.StackName} @@ -350,8 +354,11 @@ func (c *StackCollection) UpdateStack(ctx context.Context, options UpdateStackOp return err } if err := c.doWaitUntilChangeSetIsCreated(ctx, options.Stack, options.ChangeSetName); err != nil { - if _, ok := err.(*noChangeError); ok { - return nil + if _, ok := err.(*NoChangeError); ok { + if ignoreNoChangeError { + return nil + } + return err } return err } @@ -370,6 +377,11 @@ func (c *StackCollection) UpdateStack(ctx context.Context, options UpdateStackOp return nil } +// MustUpdateStack is like UpdateStack but returns a NoChangeError if there are no changes to execute. +func (c *StackCollection) MustUpdateStack(ctx context.Context, options UpdateStackOptions) error { + return c.updateStack(ctx, options, false) +} + // DescribeStack describes a cloudformation stack. func (c *StackCollection) DescribeStack(ctx context.Context, i *Stack) (*Stack, error) { input := &cloudformation.DescribeStacksInput{ @@ -468,19 +480,16 @@ func (c *StackCollection) ListStacksMatching(ctx context.Context, nameRegex stri return stacks, nil } -// ListClusterStackNames gets all stack names matching regex -func (c *StackCollection) ListClusterStackNames(ctx context.Context) ([]string, error) { - var stacks []string - re, err := regexp.Compile(clusterStackRegex) +// ListStackNames lists all stack names matching regExp. +func (c *StackCollection) ListStackNames(ctx context.Context, regExp string) ([]string, error) { + re, err := regexp.Compile(regExp) if err != nil { - return nil, errors.Wrap(err, "cannot list stacks") + return nil, fmt.Errorf("unexpected error compiling RegExp: %w", err) } - input := &cloudformation.ListStacksInput{ + paginator := cloudformation.NewListStacksPaginator(c.cloudformationAPI, &cloudformation.ListStacksInput{ StackStatusFilter: defaultStackStatusFilter(), - } - - paginator := cloudformation.NewListStacksPaginator(c.cloudformationAPI, input) - + }) + var stackNames []string for paginator.HasMorePages() { out, err := paginator.NextPage(ctx) if err != nil { @@ -489,12 +498,17 @@ func (c *StackCollection) ListClusterStackNames(ctx context.Context) ([]string, for _, s := range out.StackSummaries { if re.MatchString(*s.StackName) { - stacks = append(stacks, *s.StackName) + stackNames = append(stackNames, *s.StackName) } } } - return stacks, nil + return stackNames, nil +} + +// ListClusterStackNames gets all stack names matching regex +func (c *StackCollection) ListClusterStackNames(ctx context.Context) ([]string, error) { + return c.ListStackNames(ctx, clusterStackRegex) } // ListStacksWithStatuses gets all of CloudFormation stacks @@ -583,7 +597,7 @@ func matchesCluster(clusterName string, tags []types.Tag) bool { // DeleteStackBySpecSync sends a request to delete the stack, and waits until status is DELETE_COMPLETE; // any errors will be written to errs channel, assume completion when nil is written, do not expect -// more then one error value on the channel, it's closed immediately after it is written to +// more than one error value on the channel, it's closed immediately after it is written to func (c *StackCollection) DeleteStackBySpecSync(ctx context.Context, s *Stack, errs chan error) error { i, err := c.DeleteStackBySpec(ctx, s) if err != nil { diff --git a/pkg/cfn/manager/api_test.go b/pkg/cfn/manager/api_test.go index 98dda1783b..cf650271a3 100644 --- a/pkg/cfn/manager/api_test.go +++ b/pkg/cfn/manager/api_test.go @@ -135,7 +135,7 @@ var _ = Describe("StackCollection", func() { // 1) DescribeStacks // 2) CreateChangeSet // 3) DescribeChangeSetRequest (FAILED to abort early) - // 4) DescribeChangeSet (StatusReason contains "The submitted information didn't contain changes" to exit with noChangeError) + // 4) DescribeChangeSet (StatusReason contains "The submitted information didn't contain changes" to exit with NoChangeError) stackName := "eksctl-stack" changeSetName := "eksctl-changeset" @@ -168,7 +168,7 @@ var _ = Describe("StackCollection", func() { // Order of AWS SDK invocation // 1) DescribeStacks // 2) CreateChangeSet - // 3) DescribeChangeSet (StatusReason contains "The submitted information didn't contain changes" to exit with noChangeError) + // 3) DescribeChangeSet (StatusReason contains "The submitted information didn't contain changes" to exit with NoChangeError) stackName := "eksctl-stack" changeSetName := "eksctl-changeset" diff --git a/pkg/cfn/manager/delete_tasks.go b/pkg/cfn/manager/delete_tasks.go index 303e53f86a..90ab8b739c 100644 --- a/pkg/cfn/manager/delete_tasks.go +++ b/pkg/cfn/manager/delete_tasks.go @@ -29,6 +29,9 @@ type NewOIDCManager func() (*iamoidc.OpenIDConnectManager, error) // NewTasksToDeleteAddonIAM temporary type, to be removed after moving NewTasksToDeleteClusterWithNodeGroups to actions package type NewTasksToDeleteAddonIAM func(ctx context.Context, wait bool) (*tasks.TaskTree, error) +// NewTasksToDeletePodIdentityRoles temporary type, to be removed after moving NewTasksToDeleteClusterWithNodeGroups to actions package +type NewTasksToDeletePodIdentityRole func() (*tasks.TaskTree, error) + // NewTasksToDeleteClusterWithNodeGroups defines tasks required to delete the given cluster along with all of its resources func (c *StackCollection) NewTasksToDeleteClusterWithNodeGroups( ctx context.Context, @@ -37,6 +40,7 @@ func (c *StackCollection) NewTasksToDeleteClusterWithNodeGroups( clusterOperable bool, newOIDCManager NewOIDCManager, newTasksToDeleteAddonIAM NewTasksToDeleteAddonIAM, + newTasksToDeletePodIdentityRole NewTasksToDeletePodIdentityRole, cluster *ekstypes.Cluster, clientSetGetter kubernetes.ClientSetGetter, wait, force bool, @@ -75,6 +79,16 @@ func (c *StackCollection) NewTasksToDeleteClusterWithNodeGroups( taskTree.Append(deleteAddonIAMTasks) } + deletePodIdentityRoleTasks, err := newTasksToDeletePodIdentityRole() + if err != nil { + return nil, err + } + + if deletePodIdentityRoleTasks.Len() > 0 { + deletePodIdentityRoleTasks.IsSubTask = true + taskTree.Append(deletePodIdentityRoleTasks) + } + if clusterStack == nil { return nil, &StackNotFoundErr{ClusterName: c.spec.Metadata.Name} } diff --git a/pkg/cfn/manager/fakes/fake_stack_manager.go b/pkg/cfn/manager/fakes/fake_stack_manager.go index 4c95b38e23..a292e441bc 100644 --- a/pkg/cfn/manager/fakes/fake_stack_manager.go +++ b/pkg/cfn/manager/fakes/fake_stack_manager.go @@ -515,6 +515,20 @@ type FakeStackManager struct { result1 []manager.NodeGroupStack result2 error } + ListStackNamesStub func(context.Context, string) ([]string, error) + listStackNamesMutex sync.RWMutex + listStackNamesArgsForCall []struct { + arg1 context.Context + arg2 string + } + listStackNamesReturns struct { + result1 []string + result2 error + } + listStackNamesReturnsOnCall map[int]struct { + result1 []string + result2 error + } ListStacksStub func(context.Context) ([]*types.Stack, error) listStacksMutex sync.RWMutex listStacksArgsForCall []struct { @@ -592,6 +606,18 @@ type FakeStackManager struct { makeClusterStackNameReturnsOnCall map[int]struct { result1 string } + MustUpdateStackStub func(context.Context, manager.UpdateStackOptions) error + mustUpdateStackMutex sync.RWMutex + mustUpdateStackArgsForCall []struct { + arg1 context.Context + arg2 manager.UpdateStackOptions + } + mustUpdateStackReturns struct { + result1 error + } + mustUpdateStackReturnsOnCall map[int]struct { + result1 error + } NewManagedNodeGroupTaskStub func(context.Context, []*v1alpha5.ManagedNodeGroup, bool, vpc.Importer) *tasks.TaskTree newManagedNodeGroupTaskMutex sync.RWMutex newManagedNodeGroupTaskArgsForCall []struct { @@ -648,7 +674,7 @@ type FakeStackManager struct { newTasksToCreateIAMServiceAccountsReturnsOnCall map[int]struct { result1 *tasks.TaskTree } - NewTasksToDeleteClusterWithNodeGroupsStub func(context.Context, *types.Stack, []manager.NodeGroupStack, bool, manager.NewOIDCManager, manager.NewTasksToDeleteAddonIAM, *typesc.Cluster, kubernetes.ClientSetGetter, bool, bool, func(chan error, string) error) (*tasks.TaskTree, error) + NewTasksToDeleteClusterWithNodeGroupsStub func(context.Context, *types.Stack, []manager.NodeGroupStack, bool, manager.NewOIDCManager, manager.NewTasksToDeleteAddonIAM, manager.NewTasksToDeletePodIdentityRole, *typesc.Cluster, kubernetes.ClientSetGetter, bool, bool, func(chan error, string) error) (*tasks.TaskTree, error) newTasksToDeleteClusterWithNodeGroupsMutex sync.RWMutex newTasksToDeleteClusterWithNodeGroupsArgsForCall []struct { arg1 context.Context @@ -657,11 +683,12 @@ type FakeStackManager struct { arg4 bool arg5 manager.NewOIDCManager arg6 manager.NewTasksToDeleteAddonIAM - arg7 *typesc.Cluster - arg8 kubernetes.ClientSetGetter - arg9 bool + arg7 manager.NewTasksToDeletePodIdentityRole + arg8 *typesc.Cluster + arg9 kubernetes.ClientSetGetter arg10 bool - arg11 func(chan error, string) error + arg11 bool + arg12 func(chan error, string) error } newTasksToDeleteClusterWithNodeGroupsReturns struct { result1 *tasks.TaskTree @@ -3178,6 +3205,71 @@ func (fake *FakeStackManager) ListNodeGroupStacksWithStatusesReturnsOnCall(i int }{result1, result2} } +func (fake *FakeStackManager) ListStackNames(arg1 context.Context, arg2 string) ([]string, error) { + fake.listStackNamesMutex.Lock() + ret, specificReturn := fake.listStackNamesReturnsOnCall[len(fake.listStackNamesArgsForCall)] + fake.listStackNamesArgsForCall = append(fake.listStackNamesArgsForCall, struct { + arg1 context.Context + arg2 string + }{arg1, arg2}) + stub := fake.ListStackNamesStub + fakeReturns := fake.listStackNamesReturns + fake.recordInvocation("ListStackNames", []interface{}{arg1, arg2}) + fake.listStackNamesMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeStackManager) ListStackNamesCallCount() int { + fake.listStackNamesMutex.RLock() + defer fake.listStackNamesMutex.RUnlock() + return len(fake.listStackNamesArgsForCall) +} + +func (fake *FakeStackManager) ListStackNamesCalls(stub func(context.Context, string) ([]string, error)) { + fake.listStackNamesMutex.Lock() + defer fake.listStackNamesMutex.Unlock() + fake.ListStackNamesStub = stub +} + +func (fake *FakeStackManager) ListStackNamesArgsForCall(i int) (context.Context, string) { + fake.listStackNamesMutex.RLock() + defer fake.listStackNamesMutex.RUnlock() + argsForCall := fake.listStackNamesArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeStackManager) ListStackNamesReturns(result1 []string, result2 error) { + fake.listStackNamesMutex.Lock() + defer fake.listStackNamesMutex.Unlock() + fake.ListStackNamesStub = nil + fake.listStackNamesReturns = struct { + result1 []string + result2 error + }{result1, result2} +} + +func (fake *FakeStackManager) ListStackNamesReturnsOnCall(i int, result1 []string, result2 error) { + fake.listStackNamesMutex.Lock() + defer fake.listStackNamesMutex.Unlock() + fake.ListStackNamesStub = nil + if fake.listStackNamesReturnsOnCall == nil { + fake.listStackNamesReturnsOnCall = make(map[int]struct { + result1 []string + result2 error + }) + } + fake.listStackNamesReturnsOnCall[i] = struct { + result1 []string + result2 error + }{result1, result2} +} + func (fake *FakeStackManager) ListStacks(arg1 context.Context) ([]*types.Stack, error) { fake.listStacksMutex.Lock() ret, specificReturn := fake.listStacksReturnsOnCall[len(fake.listStacksArgsForCall)] @@ -3552,6 +3644,68 @@ func (fake *FakeStackManager) MakeClusterStackNameReturnsOnCall(i int, result1 s }{result1} } +func (fake *FakeStackManager) MustUpdateStack(arg1 context.Context, arg2 manager.UpdateStackOptions) error { + fake.mustUpdateStackMutex.Lock() + ret, specificReturn := fake.mustUpdateStackReturnsOnCall[len(fake.mustUpdateStackArgsForCall)] + fake.mustUpdateStackArgsForCall = append(fake.mustUpdateStackArgsForCall, struct { + arg1 context.Context + arg2 manager.UpdateStackOptions + }{arg1, arg2}) + stub := fake.MustUpdateStackStub + fakeReturns := fake.mustUpdateStackReturns + fake.recordInvocation("MustUpdateStack", []interface{}{arg1, arg2}) + fake.mustUpdateStackMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeStackManager) MustUpdateStackCallCount() int { + fake.mustUpdateStackMutex.RLock() + defer fake.mustUpdateStackMutex.RUnlock() + return len(fake.mustUpdateStackArgsForCall) +} + +func (fake *FakeStackManager) MustUpdateStackCalls(stub func(context.Context, manager.UpdateStackOptions) error) { + fake.mustUpdateStackMutex.Lock() + defer fake.mustUpdateStackMutex.Unlock() + fake.MustUpdateStackStub = stub +} + +func (fake *FakeStackManager) MustUpdateStackArgsForCall(i int) (context.Context, manager.UpdateStackOptions) { + fake.mustUpdateStackMutex.RLock() + defer fake.mustUpdateStackMutex.RUnlock() + argsForCall := fake.mustUpdateStackArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeStackManager) MustUpdateStackReturns(result1 error) { + fake.mustUpdateStackMutex.Lock() + defer fake.mustUpdateStackMutex.Unlock() + fake.MustUpdateStackStub = nil + fake.mustUpdateStackReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeStackManager) MustUpdateStackReturnsOnCall(i int, result1 error) { + fake.mustUpdateStackMutex.Lock() + defer fake.mustUpdateStackMutex.Unlock() + fake.MustUpdateStackStub = nil + if fake.mustUpdateStackReturnsOnCall == nil { + fake.mustUpdateStackReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.mustUpdateStackReturnsOnCall[i] = struct { + result1 error + }{result1} +} + func (fake *FakeStackManager) NewManagedNodeGroupTask(arg1 context.Context, arg2 []*v1alpha5.ManagedNodeGroup, arg3 bool, arg4 vpc.Importer) *tasks.TaskTree { var arg2Copy []*v1alpha5.ManagedNodeGroup if arg2 != nil { @@ -3828,7 +3982,7 @@ func (fake *FakeStackManager) NewTasksToCreateIAMServiceAccountsReturnsOnCall(i }{result1} } -func (fake *FakeStackManager) NewTasksToDeleteClusterWithNodeGroups(arg1 context.Context, arg2 *types.Stack, arg3 []manager.NodeGroupStack, arg4 bool, arg5 manager.NewOIDCManager, arg6 manager.NewTasksToDeleteAddonIAM, arg7 *typesc.Cluster, arg8 kubernetes.ClientSetGetter, arg9 bool, arg10 bool, arg11 func(chan error, string) error) (*tasks.TaskTree, error) { +func (fake *FakeStackManager) NewTasksToDeleteClusterWithNodeGroups(arg1 context.Context, arg2 *types.Stack, arg3 []manager.NodeGroupStack, arg4 bool, arg5 manager.NewOIDCManager, arg6 manager.NewTasksToDeleteAddonIAM, arg7 manager.NewTasksToDeletePodIdentityRole, arg8 *typesc.Cluster, arg9 kubernetes.ClientSetGetter, arg10 bool, arg11 bool, arg12 func(chan error, string) error) (*tasks.TaskTree, error) { var arg3Copy []manager.NodeGroupStack if arg3 != nil { arg3Copy = make([]manager.NodeGroupStack, len(arg3)) @@ -3843,18 +3997,19 @@ func (fake *FakeStackManager) NewTasksToDeleteClusterWithNodeGroups(arg1 context arg4 bool arg5 manager.NewOIDCManager arg6 manager.NewTasksToDeleteAddonIAM - arg7 *typesc.Cluster - arg8 kubernetes.ClientSetGetter - arg9 bool + arg7 manager.NewTasksToDeletePodIdentityRole + arg8 *typesc.Cluster + arg9 kubernetes.ClientSetGetter arg10 bool - arg11 func(chan error, string) error - }{arg1, arg2, arg3Copy, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11}) + arg11 bool + arg12 func(chan error, string) error + }{arg1, arg2, arg3Copy, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12}) stub := fake.NewTasksToDeleteClusterWithNodeGroupsStub fakeReturns := fake.newTasksToDeleteClusterWithNodeGroupsReturns - fake.recordInvocation("NewTasksToDeleteClusterWithNodeGroups", []interface{}{arg1, arg2, arg3Copy, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11}) + fake.recordInvocation("NewTasksToDeleteClusterWithNodeGroups", []interface{}{arg1, arg2, arg3Copy, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12}) fake.newTasksToDeleteClusterWithNodeGroupsMutex.Unlock() if stub != nil { - return stub(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11) + return stub(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12) } if specificReturn { return ret.result1, ret.result2 @@ -3868,17 +4023,17 @@ func (fake *FakeStackManager) NewTasksToDeleteClusterWithNodeGroupsCallCount() i return len(fake.newTasksToDeleteClusterWithNodeGroupsArgsForCall) } -func (fake *FakeStackManager) NewTasksToDeleteClusterWithNodeGroupsCalls(stub func(context.Context, *types.Stack, []manager.NodeGroupStack, bool, manager.NewOIDCManager, manager.NewTasksToDeleteAddonIAM, *typesc.Cluster, kubernetes.ClientSetGetter, bool, bool, func(chan error, string) error) (*tasks.TaskTree, error)) { +func (fake *FakeStackManager) NewTasksToDeleteClusterWithNodeGroupsCalls(stub func(context.Context, *types.Stack, []manager.NodeGroupStack, bool, manager.NewOIDCManager, manager.NewTasksToDeleteAddonIAM, manager.NewTasksToDeletePodIdentityRole, *typesc.Cluster, kubernetes.ClientSetGetter, bool, bool, func(chan error, string) error) (*tasks.TaskTree, error)) { fake.newTasksToDeleteClusterWithNodeGroupsMutex.Lock() defer fake.newTasksToDeleteClusterWithNodeGroupsMutex.Unlock() fake.NewTasksToDeleteClusterWithNodeGroupsStub = stub } -func (fake *FakeStackManager) NewTasksToDeleteClusterWithNodeGroupsArgsForCall(i int) (context.Context, *types.Stack, []manager.NodeGroupStack, bool, manager.NewOIDCManager, manager.NewTasksToDeleteAddonIAM, *typesc.Cluster, kubernetes.ClientSetGetter, bool, bool, func(chan error, string) error) { +func (fake *FakeStackManager) NewTasksToDeleteClusterWithNodeGroupsArgsForCall(i int) (context.Context, *types.Stack, []manager.NodeGroupStack, bool, manager.NewOIDCManager, manager.NewTasksToDeleteAddonIAM, manager.NewTasksToDeletePodIdentityRole, *typesc.Cluster, kubernetes.ClientSetGetter, bool, bool, func(chan error, string) error) { fake.newTasksToDeleteClusterWithNodeGroupsMutex.RLock() defer fake.newTasksToDeleteClusterWithNodeGroupsMutex.RUnlock() argsForCall := fake.newTasksToDeleteClusterWithNodeGroupsArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5, argsForCall.arg6, argsForCall.arg7, argsForCall.arg8, argsForCall.arg9, argsForCall.arg10, argsForCall.arg11 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5, argsForCall.arg6, argsForCall.arg7, argsForCall.arg8, argsForCall.arg9, argsForCall.arg10, argsForCall.arg11, argsForCall.arg12 } func (fake *FakeStackManager) NewTasksToDeleteClusterWithNodeGroupsReturns(result1 *tasks.TaskTree, result2 error) { @@ -4583,6 +4738,8 @@ func (fake *FakeStackManager) Invocations() map[string][][]interface{} { defer fake.listNodeGroupStacksMutex.RUnlock() fake.listNodeGroupStacksWithStatusesMutex.RLock() defer fake.listNodeGroupStacksWithStatusesMutex.RUnlock() + fake.listStackNamesMutex.RLock() + defer fake.listStackNamesMutex.RUnlock() fake.listStacksMutex.RLock() defer fake.listStacksMutex.RUnlock() fake.listStacksMatchingMutex.RLock() @@ -4595,6 +4752,8 @@ func (fake *FakeStackManager) Invocations() map[string][][]interface{} { defer fake.makeChangeSetNameMutex.RUnlock() fake.makeClusterStackNameMutex.RLock() defer fake.makeClusterStackNameMutex.RUnlock() + fake.mustUpdateStackMutex.RLock() + defer fake.mustUpdateStackMutex.RUnlock() fake.newManagedNodeGroupTaskMutex.RLock() defer fake.newManagedNodeGroupTaskMutex.RUnlock() fake.newTaskToDeleteUnownedNodeGroupMutex.RLock() diff --git a/pkg/cfn/manager/interface.go b/pkg/cfn/manager/interface.go index 3dc4ca91b5..b8fbe2f1b8 100644 --- a/pkg/cfn/manager/interface.go +++ b/pkg/cfn/manager/interface.go @@ -79,6 +79,7 @@ type StackManager interface { ListStacks(ctx context.Context) ([]*Stack, error) ListStacksWithStatuses(ctx context.Context, statusFilters ...cfntypes.StackStatus) ([]*Stack, error) ListStacksMatching(ctx context.Context, nameRegex string, statusFilters ...cfntypes.StackStatus) ([]*Stack, error) + ListStackNames(ctx context.Context, regExp string) ([]string, error) LookupCloudTrailEvents(ctx context.Context, i *Stack) ([]cttypes.Event, error) MakeChangeSetName(action string) string MakeClusterStackName() string @@ -86,7 +87,7 @@ type StackManager interface { NewTaskToDeleteUnownedNodeGroup(ctx context.Context, clusterName, nodegroup string, eksAPI awsapi.EKS, waitCondition *DeleteWaitCondition) tasks.Task NewTasksToCreateClusterWithNodeGroups(ctx context.Context, nodeGroups []*v1alpha5.NodeGroup, managedNodeGroups []*v1alpha5.ManagedNodeGroup, postClusterCreationTasks ...tasks.Task) *tasks.TaskTree NewTasksToCreateIAMServiceAccounts(serviceAccounts []*v1alpha5.ClusterIAMServiceAccount, oidc *iamoidc.OpenIDConnectManager, clientSetGetter kubernetes.ClientSetGetter) *tasks.TaskTree - NewTasksToDeleteClusterWithNodeGroups(ctx context.Context, clusterStack *Stack, nodeGroupStacks []NodeGroupStack, clusterOperable bool, newOIDCManager NewOIDCManager, newTasksToDeleteAddonIAM NewTasksToDeleteAddonIAM, cluster *ekstypes.Cluster, clientSetGetter kubernetes.ClientSetGetter, wait, force bool, cleanup func(chan error, string) error) (*tasks.TaskTree, error) + NewTasksToDeleteClusterWithNodeGroups(ctx context.Context, clusterStack *Stack, nodeGroupStacks []NodeGroupStack, clusterOperable bool, newOIDCManager NewOIDCManager, newTasksToDeleteAddonIAM NewTasksToDeleteAddonIAM, newTasksToDeletePodIdentityRole NewTasksToDeletePodIdentityRole, cluster *ekstypes.Cluster, clientSetGetter kubernetes.ClientSetGetter, wait, force bool, cleanup func(chan error, string) error) (*tasks.TaskTree, error) NewTasksToDeleteIAMServiceAccounts(ctx context.Context, serviceAccounts []string, clientSetGetter kubernetes.ClientSetGetter, wait bool) (*tasks.TaskTree, error) NewTasksToDeleteNodeGroups(stacks []NodeGroupStack, shouldDelete func(_ string) bool, wait bool, cleanup func(chan error, string) error) (*tasks.TaskTree, error) NewTasksToDeleteOIDCProviderWithIAMServiceAccounts(ctx context.Context, newOIDCManager NewOIDCManager, cluster *ekstypes.Cluster, clientSetGetter kubernetes.ClientSetGetter, force bool) (*tasks.TaskTree, error) @@ -96,4 +97,5 @@ type StackManager interface { StackStatusIsNotTransitional(s *Stack) bool UpdateNodeGroupStack(ctx context.Context, nodeGroupName, template string, wait bool) error UpdateStack(ctx context.Context, options UpdateStackOptions) error + MustUpdateStack(ctx context.Context, options UpdateStackOptions) error } diff --git a/pkg/cfn/manager/waiters.go b/pkg/cfn/manager/waiters.go index 25e43dab8c..e6c9220e3e 100644 --- a/pkg/cfn/manager/waiters.go +++ b/pkg/cfn/manager/waiters.go @@ -50,12 +50,13 @@ func (c *StackCollection) troubleshootStackFailureCause(ctx context.Context, i * } } -type noChangeError struct { - msg string +// NoChangeError represents an error for when a CloudFormation changeset contains no changes. +type NoChangeError struct { + Msg string } -func (e *noChangeError) Error() string { - return e.msg +func (e *NoChangeError) Error() string { + return e.Msg } // DoWaitUntilStackIsCreated blocks until the given stack's @@ -141,7 +142,7 @@ func (c *StackCollection) doWaitUntilChangeSetIsCreated(ctx context.Context, i * logger.Info("waiting for CloudFormation changeset %q for stack %q", changesetName, *i.StackName) if out.StatusReason != nil && strings.Contains(*out.StatusReason, "The submitted information didn't contain changes") { logger.Info("nothing to update") - return false, &noChangeError{*out.StatusReason} + return false, &NoChangeError{*out.StatusReason} } return defaultRetryer(ctx, in, out, err) } diff --git a/pkg/cfn/template/iam_helpers.go b/pkg/cfn/template/iam_helpers.go index 220cdd76bb..1d5070edf5 100644 --- a/pkg/cfn/template/iam_helpers.go +++ b/pkg/cfn/template/iam_helpers.go @@ -1,6 +1,9 @@ package template -import gfn "github.com/weaveworks/goformation/v4/cloudformation/types" +import ( + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + gfn "github.com/weaveworks/goformation/v4/cloudformation/types" +) // AttachPolicy attaches the specified policy document func (t *Template) AttachPolicy(name string, refRole *Value, policyDoc MapOfInterfaces) { @@ -63,7 +66,7 @@ func MakeAssumeRolePolicyDocumentForPodIdentity() MapOfInterfaces { "sts:TagSession", }, "Principal": map[string]string{ - "Service": "beta.pods.eks.aws.internal", + "Service": api.EKSServicePrincipal, }, }) } diff --git a/pkg/ctl/cmdutils/configfile.go b/pkg/ctl/cmdutils/configfile.go index 2343346cfb..74ed21e080 100644 --- a/pkg/ctl/cmdutils/configfile.go +++ b/pkg/ctl/cmdutils/configfile.go @@ -312,6 +312,9 @@ func NewCreateClusterLoader(cmd *Cmd, ngFilter *filter.NodeGroupFilter, ng *api. suggestion := fmt.Sprintf("please add `%s` addon to the config file", api.PodIdentityAgentAddon) return api.ErrPodIdentityAgentNotInstalled(suggestion) } + if err := validatePodIdentityAssociationsForConfig(clusterConfig, true); err != nil { + return err + } } return validateDryRun() diff --git a/pkg/ctl/cmdutils/pod_identity_association.go b/pkg/ctl/cmdutils/pod_identity_association.go index b9b2478407..7f374b9f54 100644 --- a/pkg/ctl/cmdutils/pod_identity_association.go +++ b/pkg/ctl/cmdutils/pod_identity_association.go @@ -1,6 +1,7 @@ package cmdutils import ( + "errors" "fmt" "k8s.io/apimachinery/pkg/util/sets" @@ -28,22 +29,17 @@ func NewCreatePodIdentityAssociationLoader(cmd *Cmd, podIdentityAssociation *api l.flagsIncompatibleWithConfigFile = sets.NewString(podIdentityAssociationFlagsIncompatibleWithConfigFile...) l.validateWithConfigFile = func() error { - if len(cmd.ClusterConfig.IAM.PodIdentityAssociations) == 0 { - return fmt.Errorf("at least one pod identity association is required") - } - return nil + return validatePodIdentityAssociationsForConfig(l.ClusterConfig, true) } l.validateWithoutConfigFile = func() error { - if l.ClusterConfig.Metadata.Name == "" { - return ErrMustBeSet(ClusterNameFlag(cmd)) - } - if podIdentityAssociation.Namespace == "" { - return ErrMustBeSet("--namespace") - } - if podIdentityAssociation.ServiceAccountName == "" { - return ErrMustBeSet("--service-account-name") + if err := validatePodIdentityAssociation(l, PodIdentityAssociationOptions{ + Namespace: podIdentityAssociation.Namespace, + ServiceAccountName: podIdentityAssociation.ServiceAccountName, + }); err != nil { + return err } + if podIdentityAssociation.RoleARN == "" && len(podIdentityAssociation.PermissionPolicyARNs) == 0 && !podIdentityAssociation.WellKnownPolicies.HasPolicy() { @@ -57,6 +53,7 @@ func NewCreatePodIdentityAssociationLoader(cmd *Cmd, podIdentityAssociation *api return fmt.Errorf("--well-known-policies cannot be specified when --role-arn is set") } } + l.Cmd.ClusterConfig.IAM.PodIdentityAssociations = []api.PodIdentityAssociation{*podIdentityAssociation} return nil } @@ -67,6 +64,8 @@ func NewCreatePodIdentityAssociationLoader(cmd *Cmd, podIdentityAssociation *api func NewGetPodIdentityAssociationLoader(cmd *Cmd, pia *api.PodIdentityAssociation) ClusterConfigLoader { l := newCommonClusterConfigLoader(cmd) + l.flagsIncompatibleWithConfigFile = sets.NewString("cluster") + l.validateWithoutConfigFile = func() error { if cmd.ClusterConfig.Metadata.Name == "" { return ErrMustBeSet(ClusterNameFlag(cmd)) @@ -76,5 +75,117 @@ func NewGetPodIdentityAssociationLoader(cmd *Cmd, pia *api.PodIdentityAssociatio } return nil } + + l.validateWithConfigFile = func() error { + if cmd.ClusterConfig.Metadata.Name == "" { + return ErrMustBeSet(ClusterNameFlag(cmd)) + } + return nil + } + + return l +} + +// PodIdentityAssociationOptions holds the options for deleting a pod identity association. +type PodIdentityAssociationOptions struct { + // Namespace is the namespace the service account belongs to. + Namespace string + // ServiceAccountName is the name of the Kubernetes ServiceAccount. + ServiceAccountName string +} + +func validatePodIdentityAssociation(l *commonClusterConfigLoader, options PodIdentityAssociationOptions) error { + if l.ClusterConfig.Metadata.Name == "" { + return ErrMustBeSet(ClusterNameFlag(l.Cmd)) + } + if options.Namespace == "" { + return errors.New("--namespace is required") + } + if options.ServiceAccountName == "" { + return errors.New("--service-account-name is required") + } + return nil +} + +func validatePodIdentityAssociationsForConfig(clusterConfig *api.ClusterConfig, isCreate bool) error { + if clusterConfig.IAM == nil || len(clusterConfig.IAM.PodIdentityAssociations) == 0 { + return errors.New("no iam.podIdentityAssociations specified in the config file") + } + + for i, pia := range clusterConfig.IAM.PodIdentityAssociations { + path := fmt.Sprintf("podIdentityAssociations[%d]", i) + if pia.Namespace == "" { + return fmt.Errorf("%s.namespace must be set", path) + } + if pia.ServiceAccountName == "" { + return fmt.Errorf("%s.serviceAccountName must be set", path) + } + + if !isCreate { + continue + } + + if pia.RoleARN == "" && + len(pia.PermissionPolicy) == 0 && + len(pia.PermissionPolicyARNs) == 0 && + !pia.WellKnownPolicies.HasPolicy() { + return fmt.Errorf("at least one of the following must be specified: %[1]s.roleARN, %[1]s.permissionPolicy, %[1]s.permissionPolicyARNs, %[1]s.wellKnownPolicies", path) + } + if pia.RoleARN != "" { + if len(pia.PermissionPolicy) > 0 { + return fmt.Errorf("%[1]s.permissionPolicy cannot be specified when %[1]s.roleARN is set", path) + } + if len(pia.PermissionPolicyARNs) > 0 { + return fmt.Errorf("%[1]s.permissionPolicyARNs cannot be specified when %[1]s.roleARN is set", path) + } + if pia.WellKnownPolicies.HasPolicy() { + return fmt.Errorf("%[1]s.wellKnownPolicies cannot be specified when %[1]s.roleARN is set", path) + } + } + } + + return nil +} + +// NewDeletePodIdentityAssociationLoader will load config or use flags for `eksctl delete podidentityassociation`. +func NewDeletePodIdentityAssociationLoader(cmd *Cmd, options PodIdentityAssociationOptions) ClusterConfigLoader { + l := newCommonClusterConfigLoader(cmd) + l.flagsIncompatibleWithConfigFile.Insert("namespace", "service-account-name") + + l.validateWithoutConfigFile = func() error { + return validatePodIdentityAssociation(l, options) + } + + l.validateWithConfigFile = func() error { + return validatePodIdentityAssociationsForConfig(l.ClusterConfig, false) + } + return l +} + +// UpdatePodIdentityAssociationOptions holds the options for updating a pod identity association. +type UpdatePodIdentityAssociationOptions struct { + PodIdentityAssociationOptions + // RoleARN is the IAM role ARN to be associated with the pod. + RoleARN string +} + +// NewUpdatePodIdentityAssociationLoader will load config or use flags for `eksctl update podidentityassociation`. +func NewUpdatePodIdentityAssociationLoader(cmd *Cmd, options UpdatePodIdentityAssociationOptions) ClusterConfigLoader { + l := newCommonClusterConfigLoader(cmd) + l.flagsIncompatibleWithConfigFile.Insert("namespace", "service-account-name", "role-arn") + + l.validateWithoutConfigFile = func() error { + if err := validatePodIdentityAssociation(l, options.PodIdentityAssociationOptions); err != nil { + return err + } + if options.RoleARN == "" { + return errors.New("--role-arn is required") + } + return nil + } + + l.validateWithConfigFile = func() error { + return validatePodIdentityAssociationsForConfig(l.ClusterConfig, false) + } return l } diff --git a/pkg/ctl/create/cluster.go b/pkg/ctl/create/cluster.go index a7d4720920..fa961d9d49 100644 --- a/pkg/ctl/create/cluster.go +++ b/pkg/ctl/create/cluster.go @@ -21,6 +21,7 @@ import ( "github.com/weaveworks/eksctl/pkg/actions/addon" "github.com/weaveworks/eksctl/pkg/actions/flux" "github.com/weaveworks/eksctl/pkg/actions/karpenter" + "github.com/weaveworks/eksctl/pkg/actions/podidentityassociation" api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" "github.com/weaveworks/eksctl/pkg/authconfigmap" "github.com/weaveworks/eksctl/pkg/cfn/manager" @@ -435,6 +436,16 @@ func doCreateCluster(cmd *cmdutils.Cmd, ngFilter *filter.NodeGroupFilter, params } } + if len(cfg.IAM.PodIdentityAssociations) > 0 { + if err := podidentityassociation.NewCreator( + cfg.Metadata.Name, + stackManager, + ctl.AWSProvider.EKS(), + ).CreatePodIdentityAssociations(ctx, cfg.IAM.PodIdentityAssociations); err != nil { + return err + } + } + // After we have the cluster config and all the nodes are done, we install Karpenter if necessary. if cfg.Karpenter != nil { config := kubeconfig.NewForKubectl(cfg, eks.GetUsername(ctl.Status.IAMRoleARN), params.AuthenticatorRoleARN, ctl.AWSProvider.Profile().Name) diff --git a/pkg/ctl/create/pod_identity_association_test.go b/pkg/ctl/create/pod_identity_association_test.go index 69c400db37..9c0e4d0d5f 100644 --- a/pkg/ctl/create/pod_identity_association_test.go +++ b/pkg/ctl/create/pod_identity_association_test.go @@ -31,11 +31,11 @@ var _ = Describe("create pod identity association", func() { }), Entry("missing required flag --namespace", createPodIdentityAssociationEntry{ args: []string{"--cluster", "test-cluster"}, - expectedErr: "--namespace must be set", + expectedErr: "--namespace is required", }), Entry("missing required flag --service-account-name", createPodIdentityAssociationEntry{ args: []string{"--cluster", "test-cluster", "--namespace", "test-namespace"}, - expectedErr: "--service-account-name must be set", + expectedErr: "--service-account-name is required", }), Entry("setting --cluster and --config-file at the same time", createPodIdentityAssociationEntry{ args: []string{"--cluster", "test-cluster", "--config-file", configFile}, diff --git a/pkg/ctl/delete/delete.go b/pkg/ctl/delete/delete.go index 1ad2351a1f..d55a38701a 100644 --- a/pkg/ctl/delete/delete.go +++ b/pkg/ctl/delete/delete.go @@ -16,6 +16,7 @@ func Command(flagGrouping *cmdutils.FlagGrouping) *cobra.Command { cmdutils.AddResourceCmd(flagGrouping, verbCmd, deleteIAMIdentityMappingCmd) cmdutils.AddResourceCmd(flagGrouping, verbCmd, deleteFargateProfile) cmdutils.AddResourceCmd(flagGrouping, verbCmd, deleteAddonCmd) + cmdutils.AddResourceCmd(flagGrouping, verbCmd, deletePodIdentityAssociation) return verbCmd } diff --git a/pkg/ctl/delete/pod_identity_association.go b/pkg/ctl/delete/pod_identity_association.go new file mode 100644 index 0000000000..ec9389a71b --- /dev/null +++ b/pkg/ctl/delete/pod_identity_association.go @@ -0,0 +1,78 @@ +package delete + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/weaveworks/eksctl/pkg/actions/podidentityassociation" + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + "github.com/weaveworks/eksctl/pkg/ctl/cmdutils" +) + +func deletePodIdentityAssociation(cmd *cmdutils.Cmd) { + cfg := api.NewClusterConfig() + cmd.ClusterConfig = cfg + + var options cmdutils.PodIdentityAssociationOptions + + cmd.SetDescription("podidentityassociation", "Delete pod identity associations", "") + + cmd.CobraCommand.RunE = func(_ *cobra.Command, args []string) error { + return doDeletePodIdentityAssociation(cmd, options) + } + + cmd.FlagSetGroup.InFlagSet("General", func(fs *pflag.FlagSet) { + cmdutils.AddClusterFlagWithDeprecated(fs, cfg.Metadata) + cmdutils.AddRegionFlag(fs, &cmd.ProviderConfig) + cmdutils.AddConfigFileFlag(fs, &cmd.ClusterConfigFile) + cmdutils.AddTimeoutFlag(fs, &cmd.ProviderConfig.WaitTimeout) + }) + + cmd.FlagSetGroup.InFlagSet("Pod Identity Association", func(fs *pflag.FlagSet) { + fs.StringVar(&options.Namespace, "namespace", "", "Namespace of the pod identity association") + fs.StringVar(&options.ServiceAccountName, "service-account-name", "", "Service account name of the pod identity association") + + }) + + cmdutils.AddCommonFlagsForAWS(cmd, &cmd.ProviderConfig, false) +} + +func doDeletePodIdentityAssociation(cmd *cmdutils.Cmd, options cmdutils.PodIdentityAssociationOptions) error { + if err := cmdutils.NewDeletePodIdentityAssociationLoader(cmd, options).Load(); err != nil { + return err + } + + cfg := cmd.ClusterConfig + ctx := context.Background() + ctl, err := cmd.NewProviderForExistingCluster(ctx) + if err != nil { + return err + } + + if cfg.Metadata.Name == "" { + return cmdutils.ErrMustBeSet(cmdutils.ClusterNameFlag(cmd)) + } + + if ok, err := ctl.CanOperate(cfg); !ok { + return err + } + + if cmd.ClusterConfigFile == "" { + cmd.ClusterConfig.IAM.PodIdentityAssociations = []api.PodIdentityAssociation{ + { + Namespace: options.Namespace, + ServiceAccountName: options.ServiceAccountName, + }, + } + } + + deleter := &podidentityassociation.Deleter{ + ClusterName: cfg.Metadata.Name, + StackDeleter: ctl.NewStackManager(cfg), + APIDeleter: ctl.AWSProvider.EKS(), + } + + return deleter.Delete(ctx, podidentityassociation.ToIdentifiers(cfg.IAM.PodIdentityAssociations)) +} diff --git a/pkg/ctl/update/pod_identity_association.go b/pkg/ctl/update/pod_identity_association.go new file mode 100644 index 0000000000..e93eaeed4d --- /dev/null +++ b/pkg/ctl/update/pod_identity_association.go @@ -0,0 +1,81 @@ +package update + +import ( + "context" + + "github.com/weaveworks/eksctl/pkg/actions/podidentityassociation" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + "github.com/weaveworks/eksctl/pkg/ctl/cmdutils" +) + +func updatePodIdentityAssociation(cmd *cmdutils.Cmd) { + cfg := api.NewClusterConfig() + cmd.ClusterConfig = cfg + + var options cmdutils.UpdatePodIdentityAssociationOptions + + cmd.SetDescription("podidentityassociation", "Update pod identity associations", "") + + cmd.CobraCommand.RunE = func(_ *cobra.Command, args []string) error { + return doUpdatePodIdentityAssociation(cmd, options) + } + + cmd.FlagSetGroup.InFlagSet("General", func(fs *pflag.FlagSet) { + cmdutils.AddClusterFlagWithDeprecated(fs, cfg.Metadata) + cmdutils.AddRegionFlag(fs, &cmd.ProviderConfig) + cmdutils.AddConfigFileFlag(fs, &cmd.ClusterConfigFile) + cmdutils.AddTimeoutFlag(fs, &cmd.ProviderConfig.WaitTimeout) + }) + + cmd.FlagSetGroup.InFlagSet("Pod Identity Association", func(fs *pflag.FlagSet) { + fs.StringVar(&options.Namespace, "namespace", "", "Namespace of the pod identity association") + fs.StringVar(&options.ServiceAccountName, "service-account-name", "", "Service account name of the pod identity association") + fs.StringVar(&options.RoleARN, "role-arn", "", "Service account name of the pod identity association") + + }) + + cmdutils.AddCommonFlagsForAWS(cmd, &cmd.ProviderConfig, false) +} + +func doUpdatePodIdentityAssociation(cmd *cmdutils.Cmd, options cmdutils.UpdatePodIdentityAssociationOptions) error { + if err := cmdutils.NewUpdatePodIdentityAssociationLoader(cmd, options).Load(); err != nil { + return err + } + + cfg := cmd.ClusterConfig + ctx := context.Background() + ctl, err := cmd.NewProviderForExistingCluster(ctx) + if err != nil { + return err + } + + if cfg.Metadata.Name == "" { + return cmdutils.ErrMustBeSet(cmdutils.ClusterNameFlag(cmd)) + } + + if ok, err := ctl.CanOperate(cfg); !ok { + return err + } + + if cmd.ClusterConfigFile == "" { + cmd.ClusterConfig.IAM.PodIdentityAssociations = []api.PodIdentityAssociation{ + { + Namespace: options.Namespace, + ServiceAccountName: options.ServiceAccountName, + RoleARN: options.RoleARN, + }, + } + } + + stackManager := ctl.NewStackManager(cfg) + updater := &podidentityassociation.Updater{ + ClusterName: cfg.Metadata.Name, + APIUpdater: ctl.AWSProvider.EKS(), + StackUpdater: stackManager, + } + return updater.Update(ctx, cfg.IAM.PodIdentityAssociations) +} diff --git a/pkg/ctl/update/update.go b/pkg/ctl/update/update.go index 6ebf27142f..68126cbbc6 100644 --- a/pkg/ctl/update/update.go +++ b/pkg/ctl/update/update.go @@ -14,6 +14,7 @@ func Command(flagGrouping *cmdutils.FlagGrouping) *cobra.Command { cmdutils.AddResourceCmd(flagGrouping, verbCmd, updateAddonCmd) cmdutils.AddResourceCmd(flagGrouping, verbCmd, updateIAMServiceAccountCmd) cmdutils.AddResourceCmd(flagGrouping, verbCmd, updateNodeGroupCmd) + cmdutils.AddResourceCmd(flagGrouping, verbCmd, updatePodIdentityAssociation) return verbCmd } diff --git a/pkg/eks/tasks.go b/pkg/eks/tasks.go index 4075049e56..f10b57dc06 100644 --- a/pkg/eks/tasks.go +++ b/pkg/eks/tasks.go @@ -11,7 +11,6 @@ import ( "github.com/weaveworks/eksctl/pkg/actions/iamidentitymapping" "github.com/weaveworks/eksctl/pkg/actions/identityproviders" - "github.com/weaveworks/eksctl/pkg/actions/podidentityassociation" "github.com/weaveworks/eksctl/pkg/windows" @@ -314,18 +313,6 @@ func (c *ClusterProvider) CreateExtraClusterConfigTasks(ctx context.Context, cfg }) } - if len(cfg.IAM.PodIdentityAssociations) > 0 { - piaTasks := tasks.TaskTree{ - Parallel: true, - } - piaTasks.Append(podidentityassociation.NewCreator( - cfg.Metadata.Name, - c.NewStackManager(cfg), - c.AWSProvider.EKS(), - ).CreateTasks(ctx, cfg.IAM.PodIdentityAssociations)) - newTasks.Append(&piaTasks) - } - if cfg.HasWindowsNodeGroup() { newTasks.Append(&WindowsIPAMTask{ Info: "enable Windows IP address management", diff --git a/pkg/utils/names/names.go b/pkg/utils/names/names.go index ed2d20cf8c..c57a807879 100644 --- a/pkg/utils/names/names.go +++ b/pkg/utils/names/names.go @@ -48,15 +48,6 @@ func ForFargateProfile(name string) string { return fmt.Sprintf("fp-%s", RandomName(length, chars)) } -// ForIAMRole returns the provided name if non-empty, or else generates -// a random name matching: pod-identity-role-[abcdef0123456789]{8} -func ForIAMRole(name string) string { - if name != "" { - return name - } - return fmt.Sprintf("pod-identity-role-%s", RandomName(8, "abcdef0123456789")) -} - // useNameOrGenerate picks one of the provided strings or generates a // new one using the provided generate function func useNameOrGenerate(a, b string, generate func() string) string {