diff --git a/cmd/akamai/create.go b/cmd/akamai/create.go index 67ea6d756..a3430f71c 100644 --- a/cmd/akamai/create.go +++ b/cmd/akamai/create.go @@ -26,7 +26,7 @@ import ( ) func createAkamai(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "akamai") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderAkamai) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get flags: %w", err) @@ -34,9 +34,9 @@ func createAkamai(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(25) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - return fmt.Errorf("catalog validation failed: %w", err) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider, cliFlags.DNSProvider) diff --git a/cmd/aws/command.go b/cmd/aws/command.go index a7283b51a..a444e4356 100644 --- a/cmd/aws/command.go +++ b/cmd/aws/command.go @@ -16,29 +16,6 @@ import ( ) var ( - // Create - alertsEmailFlag string - ciFlag bool - cloudRegionFlag string - clusterNameFlag string - clusterTypeFlag string - dnsProviderFlag string - githubOrgFlag string - gitlabGroupFlag string - gitProviderFlag string - gitProtocolFlag string - gitopsTemplateURLFlag string - gitopsTemplateBranchFlag string - domainNameFlag string - subdomainNameFlag string - useTelemetryFlag bool - ecrFlag bool - nodeTypeFlag string - nodeCountFlag string - installCatalogApps string - installKubefirstProFlag bool - amiType string - // Supported argument arrays supportedDNSProviders = []string{"aws", "cloudflare"} supportedGitProviders = []string{"github", "gitlab"} @@ -70,7 +47,6 @@ func NewCommand() *cobra.Command { // wire up new commands awsCmd.AddCommand(Create(), Destroy(), Quota(), RootCredentials()) - return awsCmd } @@ -86,29 +62,30 @@ func Create() *cobra.Command { awsDefaults := constants.GetCloudDefaults().Aws // todo review defaults and update descriptions - createCmd.Flags().StringVar(&alertsEmailFlag, "alerts-email", "", "email address for let's encrypt certificate notifications (required)") + createCmd.Flags().String("alerts-email", "", "email address for let's encrypt certificate notifications (required)") createCmd.MarkFlagRequired("alerts-email") - createCmd.Flags().BoolVar(&ciFlag, "ci", false, "if running kubefirst in ci, set this flag to disable interactive features") - createCmd.Flags().StringVar(&cloudRegionFlag, "cloud-region", "us-east-1", "the aws region to provision infrastructure in") - createCmd.Flags().StringVar(&clusterNameFlag, "cluster-name", "kubefirst", "the name of the cluster to create") - createCmd.Flags().StringVar(&clusterTypeFlag, "cluster-type", "mgmt", "the type of cluster to create (i.e. mgmt|workload)") - createCmd.Flags().StringVar(&nodeCountFlag, "node-count", awsDefaults.NodeCount, "the node count for the cluster") - createCmd.Flags().StringVar(&nodeTypeFlag, "node-type", awsDefaults.InstanceSize, "the instance size of the cluster to create") - createCmd.Flags().StringVar(&dnsProviderFlag, "dns-provider", "aws", fmt.Sprintf("the dns provider - one of: %q", supportedDNSProviders)) - createCmd.Flags().StringVar(&subdomainNameFlag, "subdomain", "", "the subdomain to use for DNS records (Cloudflare)") - createCmd.Flags().StringVar(&domainNameFlag, "domain-name", "", "the Route53/Cloudflare hosted zone name to use for DNS records (i.e. your-domain.com|subdomain.your-domain.com) (required)") + createCmd.Flags().Bool("ci", false, "if running kubefirst in ci, set this flag to disable interactive features") + createCmd.Flags().String("cloud-region", "us-east-1", "the aws region to provision infrastructure in") + createCmd.Flags().String("cluster-name", "kubefirst", "the name of the cluster to create") + createCmd.Flags().String("cluster-type", "mgmt", "the type of cluster to create (i.e. mgmt|workload)") + createCmd.Flags().String("node-count", awsDefaults.NodeCount, "the node count for the cluster") + createCmd.Flags().String("node-type", awsDefaults.InstanceSize, "the instance size of the cluster to create") + createCmd.Flags().String("dns-provider", "aws", fmt.Sprintf("the dns provider - one of: %q", supportedDNSProviders)) + createCmd.Flags().String("subdomain", "", "the subdomain to use for DNS records (Cloudflare)") + createCmd.Flags().String("domain-name", "", "the Route53/Cloudflare hosted zone name to use for DNS records (i.e. your-domain.com|subdomain.your-domain.com) (required)") createCmd.MarkFlagRequired("domain-name") - createCmd.Flags().StringVar(&gitProviderFlag, "git-provider", "github", fmt.Sprintf("the git provider - one of: %q", supportedGitProviders)) - createCmd.Flags().StringVar(&gitProtocolFlag, "git-protocol", "ssh", fmt.Sprintf("the git protocol - one of: %q", supportedGitProtocolOverride)) - createCmd.Flags().StringVar(&githubOrgFlag, "github-org", "", "the GitHub organization for the new gitops and metaphor repositories - required if using github") - createCmd.Flags().StringVar(&gitlabGroupFlag, "gitlab-group", "", "the GitLab group for the new gitops and metaphor projects - required if using gitlab") - createCmd.Flags().StringVar(&gitopsTemplateBranchFlag, "gitops-template-branch", "", "the branch to clone for the gitops-template repository") - createCmd.Flags().StringVar(&gitopsTemplateURLFlag, "gitops-template-url", "https://github.com/konstructio/gitops-template.git", "the fully qualified url to the gitops-template repository to clone") - createCmd.Flags().StringVar(&installCatalogApps, "install-catalog-apps", "", "comma separated values to install after provision") - createCmd.Flags().BoolVar(&useTelemetryFlag, "use-telemetry", true, "whether to emit telemetry") - createCmd.Flags().BoolVar(&ecrFlag, "ecr", false, "whether or not to use ecr vs the git provider") - createCmd.Flags().BoolVar(&installKubefirstProFlag, "install-kubefirst-pro", true, "whether or not to install kubefirst pro") - createCmd.Flags().StringVar(&amiType, "ami-type", "AL2_x86_64", fmt.Sprintf("the ami type for node group - one of: %q", getSupportedAMITypes())) + createCmd.Flags().String("git-provider", "github", fmt.Sprintf("the git provider - one of: %q", supportedGitProviders)) + createCmd.Flags().String("git-protocol", "ssh", fmt.Sprintf("the git protocol - one of: %q", supportedGitProtocolOverride)) + createCmd.Flags().String("github-org", "", "the GitHub organization for the new gitops and metaphor repositories - required if using github") + createCmd.Flags().String("gitlab-group", "", "the GitLab group for the new gitops and metaphor projects - required if using gitlab") + createCmd.Flags().String("gitops-template-branch", "", "the branch to clone for the gitops-template repository") + createCmd.Flags().String("gitops-template-url", "https://github.com/konstructio/gitops-template.git", "the fully qualified url to the gitops-template repository to clone") + createCmd.Flags().String("install-catalog-apps", "", "comma separated values to install after provision") + createCmd.Flags().Bool("use-telemetry", true, "whether to emit telemetry") + createCmd.Flags().Bool("ecr", false, "whether or not to use ecr vs the git provider") + createCmd.Flags().Bool("install-kubefirst-pro", true, "whether or not to install kubefirst pro") + createCmd.Flags().String("ami-type", "AL2_x86_64", fmt.Sprintf("the ami type for node group - one of: %q", getSupportedAMITypes())) + createCmd.Flags().String("kubernetes-admin-role-arn", "", "the role arn with Kubernetes Admin privileges to use for the creation flow") return createCmd } @@ -141,7 +118,7 @@ func Quota() *cobra.Command { RunE: evalAwsQuota, } - quotaCmd.Flags().StringVar(&cloudRegionFlag, "cloud-region", "us-east-1", "the aws region to provision infrastructure in") + quotaCmd.Flags().String("cloud-region", "us-east-1", "the aws region to provision infrastructure in") return quotaCmd } diff --git a/cmd/aws/create.go b/cmd/aws/create.go index d39a8e440..af5933299 100644 --- a/cmd/aws/create.go +++ b/cmd/aws/create.go @@ -8,18 +8,27 @@ package aws import ( "context" + "encoding/json" + "errors" "fmt" + "net/http" "os" "slices" "strings" + "time" "github.com/aws/aws-sdk-go-v2/aws" + awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts/types" internalssh "github.com/konstructio/kubefirst-api/pkg/ssh" pkg "github.com/konstructio/kubefirst-api/pkg/utils" + internalaws "github.com/konstructio/kubefirst/internal/aws" "github.com/konstructio/kubefirst/internal/catalog" "github.com/konstructio/kubefirst/internal/cluster" "github.com/konstructio/kubefirst/internal/gitShim" @@ -32,18 +41,22 @@ import ( "github.com/spf13/viper" ) +const ( + maxRetries = 20 + baseDelay = 2 * time.Second +) + func createAws(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "aws") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderAWS) if err != nil { - progress.Error(err.Error()) - return nil + return fmt.Errorf("failed to get flags: %w", err) } progress.DisplayLogHints(40) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - return fmt.Errorf("invalid catalog apps: %w", err) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } ctx := cmd.Context() @@ -53,35 +66,48 @@ func createAws(cmd *cobra.Command, _ []string) error { return fmt.Errorf("unable to load AWS SDK config: %w", err) } - err = ValidateProvidedFlags(ctx, cfg, cliFlags.GitProvider, cliFlags.AMIType, cliFlags.NodeType) + err = ValidateProvidedFlags(ctx, cfg, cliFlags.DNSProvider, cliFlags.GitProvider, cliFlags.AMIType, cliFlags.NodeType) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to validate provided flags: %w", err) } + stsClient := sts.NewFromConfig(cfg) + iamClient := iam.NewFromConfig(cfg) + checker, err := internalaws.NewChecker(ctx) + if err != nil { + return fmt.Errorf("failed to perform aws checks: %w", err) + } + + accessKeyID := viper.Get("kubefirst.state-store-creds.access-key-id") + secretAccessKeyID := viper.Get("kubefirst.state-store-creds.secret-access-key-id") + sessionToken := viper.Get("kubefirst.state-store-creds.token") + + if accessKeyID == nil || secretAccessKeyID == nil && sessionToken == nil { + creds, err := convertLocalCredsToSession(ctx, stsClient, iamClient, checker, cliFlags.KubeAdminRoleARN, cliFlags.ClusterName) + if err != nil { + progress.Error(err.Error()) + return fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + + viper.Set("kubefirst.state-store-creds.access-key-id", creds.AccessKeyId) + viper.Set("kubefirst.state-store-creds.secret-access-key-id", creds.SecretAccessKey) + viper.Set("kubefirst.state-store-creds.token", creds.SessionToken) + if err := viper.WriteConfig(); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + } + utilities.CreateK1ClusterDirectory(cliFlags.ClusterName) // If cluster setup is complete, return clusterSetupComplete := viper.GetBool("kubefirst-checks.cluster-install-complete") if clusterSetupComplete { - err = fmt.Errorf("this cluster install process has already completed successfully") + err = errors.New("this cluster install process has already completed successfully") progress.Error(err.Error()) return nil } - creds, err := getSessionCredentials(ctx, cfg.Credentials) - if err != nil { - progress.Error(err.Error()) - return fmt.Errorf("failed to retrieve AWS credentials: %w", err) - } - - viper.Set("kubefirst.state-store-creds.access-key-id", creds.AccessKeyID) - viper.Set("kubefirst.state-store-creds.secret-access-key-id", creds.SecretAccessKey) - viper.Set("kubefirst.state-store-creds.token", creds.SessionToken) - if err := viper.WriteConfig(); err != nil { - return fmt.Errorf("failed to write config: %w", err) - } - gitAuth, err := gitShim.ValidateGitCredentials(cliFlags.GitProvider, cliFlags.GithubOrg, cliFlags.GitlabGroup) if err != nil { progress.Error(err.Error()) @@ -134,11 +160,11 @@ func createAws(cmd *cobra.Command, _ []string) error { return nil } -func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, gitProvider, amiType, nodeType string) error { +func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, dnsProvider, gitProvider, amiType, nodeType string) error { progress.AddStep("Validate provided flags") // Validate required environment variables for dns provider - if dnsProviderFlag == "cloudflare" { + if dnsProvider == "cloudflare" { if os.Getenv("CF_API_TOKEN") == "" { return fmt.Errorf("your CF_API_TOKEN environment variable is not set. Please set and try again") } @@ -173,14 +199,234 @@ func ValidateProvidedFlags(ctx context.Context, cfg aws.Config, gitProvider, ami return nil } -func getSessionCredentials(ctx context.Context, cp aws.CredentialsProvider) (*aws.Credentials, error) { - // Retrieve credentials - creds, err := cp.Retrieve(ctx) +const ( + sessionDuration = int32(43200) // We want at least 6 hours (21,600 seconds) +) + +var wantedPermissions = []string{ + "eks:*", + "ec2:*", + "s3:*", + "iam:*", + "dynamodb:*", + "kms:*", + "logs:*", + "application-autoscaling:*", +} + +type stsClienter interface { + GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) + AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) +} + +// iamClienter is an interface for IAM operations (CreateRole, AttachRolePolicy, etc.). +type iamClienter interface { + CreatePolicy(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) + CreateRole(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) + AttachRolePolicy(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) + GetRole(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) + GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) +} + +type checkerClienter interface { + CanRoleDoAction(ctx context.Context, roleArn string, actions []string) (bool, error) +} + +func convertLocalCredsToSession(ctx context.Context, stsClient stsClienter, iamClient iamClienter, checker checkerClienter, roleArn, clusterName string) (*types.Credentials, error) { + // Check who we are currently (to ensure you're properly authenticated) + callerIdentity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return nil, fmt.Errorf("failed to get caller identity: %w", err) + } + + // If ARN is empty, create a new role with kubernetes admin permissions + if roleArn == "" { + createdArn, err := createKubernetesAdminRole(ctx, clusterName, iamClient, checker, callerIdentity) + if err != nil { + return nil, fmt.Errorf("failed to create a new role for EKS clusters: %w", err) + } + roleArn = createdArn + } + + // Check if the currently provided role can perform EKS cluster creation + // with all the sub-requirements to actually make a cluster. + canCreateCluster, err := checker.CanRoleDoAction(ctx, roleArn, wantedPermissions) + if err != nil { + return nil, fmt.Errorf("failed to check if role %q can create EKS cluster: %w", roleArn, err) + } + if !canCreateCluster { + return nil, fmt.Errorf("role %q does not have permission to create EKS clusters; required permissions: %s", roleArn, wantedPermissions) + } + + // Check if the currently provided role can perform EKS cluster creation + // with all the sub-requirements to actually make a cluster. + canCreateCluster, err = checker.CanRoleDoAction(ctx, *callerIdentity.Arn, []string{"iam:AssumeRole"}) + if err != nil { + return nil, fmt.Errorf("failed to check if user %q can assume role: %w", roleArn, err) + } + if !canCreateCluster { + return nil, fmt.Errorf("user %q does not have permission to assume roles", roleArn) + } + + // Create a session name (some unique identifier) + sessionName := fmt.Sprintf("kubefirst-session-%s", *callerIdentity.UserId) + + creds, err := assumeRoleWithRetry(ctx, stsClient, roleArn, sessionName) + if err != nil { + return nil, fmt.Errorf("failed to assume role %s: %w", roleArn, err) + } + + // // Return the credentials + return creds, nil +} + +// AdditionalRolePolicies is a slice of policy ARNs you want to attach +// to every new "Kubernetes Admin" role created by the function below. +var AdditionalRolePolicies = []string{ + "arn:aws:iam::aws:policy/AmazonEKSServicePolicy", + "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController", + // Put your extra policies here, e.g.: + // "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess", +} + +func createKubernetesAdminRole(ctx context.Context, clusterName string, iamClient iamClienter, checker checkerClienter, callerIdentity *sts.GetCallerIdentityOutput) (string, error) { + if callerIdentity.Arn == nil { + return "", errors.New("caller identity ARN is nil") + } + + // Verify that the current caller has permission to create IAM roles + wantedRolePermissions := []string{"iam:CreateRole", "iam:AssumeRole", "iam:AttachRolePolicy"} + canPerformActions, err := checker.CanRoleDoAction(ctx, aws.ToString(callerIdentity.Arn), wantedRolePermissions) + if err != nil { + return "", fmt.Errorf("failed to check permission to create a new role: %w", err) + } + if !canPerformActions { + return "", fmt.Errorf("caller %q does not have the required permissions: %s", aws.ToString(callerIdentity.Arn), wantedRolePermissions) + } + + // Build a custom policy that allows EKS operations + permissionPolicy := map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{{ + "Effect": "Allow", + "Action": wantedPermissions, + "Resource": "*", + }}, + } + + // Encode the policy as JSON + permissionPolicyBytes, err := json.Marshal(permissionPolicy) + if err != nil { + return "", fmt.Errorf("failed to marshal permission policy JSON: %w", err) + } + + // Policy name + policyName := fmt.Sprintf("KubefirstKubernetesAdminPolicy-%s", clusterName) + + // Check if the IAM policy exists + cp, err := iamClient.GetPolicy(ctx, &iam.GetPolicyInput{PolicyArn: aws.String(fmt.Sprintf("arn:aws:iam::%s:policy/%s", *callerIdentity.Account, policyName))}) + if err != nil { + var newError *awshttp.ResponseError + if errors.As(err, &newError) && newError.HTTPStatusCode() != http.StatusNotFound { + return "", fmt.Errorf("failed to get policy %q: %w", policyName, err) + } + } + + if err == nil && cp.Policy != nil { + return "", fmt.Errorf("policy %q already exists: please delete the policy and try again", policyName) + } + + // Create the policy in IAM + cpo, err := iamClient.CreatePolicy(ctx, &iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(string(permissionPolicyBytes)), + Description: aws.String("Policy that allows creating EKS clusters"), + }) + if err != nil { + return "", fmt.Errorf("failed to create policy %q: %w", policyName, err) + } + + // Build a trust policy that allows the current caller to assume the new role. + trustPolicy := map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{{ + "Effect": "Allow", + "Principal": map[string]interface{}{ + // Instead of callerIdentity.Arn, we could do: + // "AWS": fmt.Sprintf("arn:aws:iam::%s:root", accountId) + // to allow any principal in your account to assume. + "AWS": aws.ToString(callerIdentity.Arn), + }, + "Action": "sts:AssumeRole", + }}, + } + + // Encode the trust policy as JSON + trustPolicyBytes, err := json.Marshal(trustPolicy) if err != nil { - return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) + return "", fmt.Errorf("failed to marshal trust policy JSON: %w", err) } - return &creds, nil + // Check if the IAM role exists + roleName := fmt.Sprintf("KubefirstKubernetesAdminRole-%s", clusterName) + + // Check if a role with this name already exists + role, err := iamClient.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)}) + if err != nil { + var newError *awshttp.ResponseError + if errors.As(err, &newError) && newError.HTTPStatusCode() != http.StatusNotFound { + return "", fmt.Errorf("failed to get role %q: %w", roleName, err) + } + } + + if err == nil && role.Role != nil { + return "", fmt.Errorf("role %q already exists: please delete the role and try again", roleName) + } + + // Create the IAM role + createOut, err := iamClient.CreateRole(ctx, &iam.CreateRoleInput{ + RoleName: aws.String(roleName), + AssumeRolePolicyDocument: aws.String(string(trustPolicyBytes)), + MaxSessionDuration: aws.Int32(sessionDuration), + Description: aws.String("Role that can create EKS clusters"), + }) + if err != nil { + return "", fmt.Errorf("failed to create role %q: %w", roleName, err) + } + + // Attach a policy that lets this role create EKS clusters. + // The AWS-managed policy "AmazonEKSClusterPolicy" covers cluster creation & management. + // Real usage often requires more policies for VPC, node groups, etc. + _, err = iamClient.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ + RoleName: createOut.Role.RoleName, + PolicyArn: aws.String("arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"), + }) + if err != nil { + return "", fmt.Errorf("failed to attach AmazonEKSClusterPolicy to role %q: %w", roleName, err) + } + + // Attach a custom policy that allows EKS operations + _, err = iamClient.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ + RoleName: createOut.Role.RoleName, + PolicyArn: cpo.Policy.Arn, + }) + if err != nil { + return "", fmt.Errorf("failed to attach custom policy %q to role %q: %w", policyName, roleName, err) + } + + // Attach any additional role policies from the package-level slice + for _, policyArn := range AdditionalRolePolicies { + _, err := iamClient.AttachRolePolicy(ctx, &iam.AttachRolePolicyInput{ + RoleName: createOut.Role.RoleName, + PolicyArn: aws.String(policyArn), + }) + if err != nil { + return "", fmt.Errorf("failed to attach policy %q to role %q: %w", policyArn, roleName, err) + } + } + + // Return the new role ARN + return aws.ToString(createOut.Role.Arn), nil } func validateAMIType(ctx context.Context, amiType, nodeType string, ssmClient ssmClienter, ec2Client ec2Clienter, paginator paginator) error { @@ -274,3 +520,21 @@ func getSupportedInstanceTypes(ctx context.Context, p paginator, architecture st } return instanceTypes, nil } + +func assumeRoleWithRetry(ctx context.Context, stsClient stsClienter, roleArn, sessionName string) (*types.Credentials, error) { + for attempt := 1; attempt <= maxRetries; attempt++ { + output, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ + RoleArn: aws.String(roleArn), + RoleSessionName: aws.String(sessionName), + DurationSeconds: aws.Int32(sessionDuration), + }) + if err == nil { + return output.Credentials, nil + } + + delay := time.Duration(attempt*attempt) * baseDelay + time.Sleep(delay) + } + + return nil, fmt.Errorf("failed to assume role") +} diff --git a/cmd/aws/create_test.go b/cmd/aws/create_test.go index a608e7648..7dfe199d4 100644 --- a/cmd/aws/create_test.go +++ b/cmd/aws/create_test.go @@ -8,50 +8,225 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/aws-sdk-go-v2/service/ssm" ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts/types" "github.com/stretchr/testify/require" ) +type mockStsClient struct { + FnGetCallerIdentity func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) + FnAssumeRole func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) +} + +func (m *mockStsClient) GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + if m.FnGetCallerIdentity != nil { + return m.FnGetCallerIdentity(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockStsClient) AssumeRole(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + if m.FnAssumeRole != nil { + return m.FnAssumeRole(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +type mockIamClient struct { + FnCreateRole func(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) + FnAttachRolePolicy func(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) + FnCreatePolicy func(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) + FnGetPolicy func(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) + FnGetRole func(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) +} + +func (m *mockIamClient) CreateRole(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) { + if m.FnCreateRole != nil { + return m.FnCreateRole(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockIamClient) AttachRolePolicy(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { + if m.FnAttachRolePolicy != nil { + return m.FnAttachRolePolicy(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockIamClient) CreatePolicy(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) { + if m.FnCreatePolicy != nil { + return m.FnCreatePolicy(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockIamClient) GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { + if m.FnGetPolicy != nil { + return m.FnGetPolicy(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +func (m *mockIamClient) GetRole(ctx context.Context, params *iam.GetRoleInput, optFns ...func(*iam.Options)) (*iam.GetRoleOutput, error) { + if m.FnGetRole != nil { + return m.FnGetRole(ctx, params, optFns...) + } + + return nil, errors.New("not implemented") +} + +type mockChecker struct { + FnCanRoleDoAction func(ctx context.Context, roleArn string, actions []string) (bool, error) +} + +func (m *mockChecker) CanRoleDoAction(ctx context.Context, roleArn string, actions []string) (bool, error) { + if m.FnCanRoleDoAction != nil { + return m.FnCanRoleDoAction(ctx, roleArn, actions) + } + return false, errors.New("not implemented") +} + func TestValidateCredentials(t *testing.T) { + ctx := context.Background() + tests := []struct { - name string - creds aws.Credentials - err error - expectedErr error + name string + roleARN string + mockStsClient *mockStsClient + mockIamClient *mockIamClient // only required if roleARN is empty + mockChecker *mockChecker + wantErr bool + expectedUserId string + expectedSessionId string }{ { - name: "valid credentials", - creds: aws.Credentials{ - AccessKeyID: "test-access-key-id", - SecretAccessKey: "test-secret-access-key", - SessionToken: "test-session-token", + name: "successful conversion", + roleARN: "arn:aws:iam::123456789012:role/example-role", + mockStsClient: &mockStsClient{ + FnGetCallerIdentity: func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &sts.GetCallerIdentityOutput{ + UserId: aws.String("user-123"), + Arn: aws.String("arn:aws:iam::123456789012:user/user-123"), + }, nil + }, + FnAssumeRole: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + if *params.RoleArn != "arn:aws:iam::123456789012:role/example-role" { + t.Fatalf("unexpected role ARN: %s", *params.RoleArn) + } + + return &sts.AssumeRoleOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("access-key-id"), + SecretAccessKey: aws.String("secret-access-key"), + SessionToken: aws.String("session-token"), + }, + }, nil + }, + }, + mockChecker: &mockChecker{ + FnCanRoleDoAction: func(ctx context.Context, roleArn string, actions []string) (bool, error) { + return true, nil + }, + }, + expectedUserId: "user-123", + expectedSessionId: "kubefirst-session-user-123", + }, + { + name: "failed to get caller identity", + roleARN: "arn:aws:iam::123456789012:role/example-role", + mockStsClient: &mockStsClient{ + FnGetCallerIdentity: func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return nil, errors.New("failed to get caller identity") + }, + }, + wantErr: true, + }, + { + name: "role does not have create cluster permission", + roleARN: "arn:aws:iam::123456789012:role/example-role", + mockStsClient: &mockStsClient{ + FnGetCallerIdentity: func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &sts.GetCallerIdentityOutput{ + UserId: aws.String("user-123"), + }, nil + }, }, - err: nil, + mockChecker: &mockChecker{ + FnCanRoleDoAction: func(ctx context.Context, roleArn string, actions []string) (bool, error) { + return false, nil + }, + }, + wantErr: true, }, { - name: "failed to retrieve credentials", - creds: aws.Credentials{}, - err: errors.New("failed to retrieve credentials"), + name: "role ARN is empty", + roleARN: "", + mockIamClient: &mockIamClient{ + FnCreateRole: func(ctx context.Context, params *iam.CreateRoleInput, optFns ...func(*iam.Options)) (*iam.CreateRoleOutput, error) { + return &iam.CreateRoleOutput{ + Role: &iamTypes.Role{ + Arn: aws.String("arn:aws:iam::123456789012:role/example-role"), + }, + }, nil + }, + FnAttachRolePolicy: func(ctx context.Context, params *iam.AttachRolePolicyInput, optFns ...func(*iam.Options)) (*iam.AttachRolePolicyOutput, error) { + return &iam.AttachRolePolicyOutput{}, nil + }, + FnCreatePolicy: func(ctx context.Context, params *iam.CreatePolicyInput, optFns ...func(*iam.Options)) (*iam.CreatePolicyOutput, error) { + return &iam.CreatePolicyOutput{ + Policy: &iamTypes.Policy{ + Arn: aws.String("arn:aws:iam::123456789012:policy/example-policy"), + }, + }, nil + }, + }, + mockStsClient: &mockStsClient{ + FnGetCallerIdentity: func(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &sts.GetCallerIdentityOutput{ + UserId: aws.String("user-123"), + }, nil + }, + FnAssumeRole: func(ctx context.Context, params *sts.AssumeRoleInput, optFns ...func(*sts.Options)) (*sts.AssumeRoleOutput, error) { + if *params.RoleArn != "arn:aws:iam::123456789012:role/example-role" { + t.Fatalf("unexpected role ARN: %s", *params.RoleArn) + } + + return &sts.AssumeRoleOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("access-key-id"), + SecretAccessKey: aws.String("secret-access-key"), + SessionToken: aws.String("session-token"), + }, + }, nil + }, + }, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mockProvider := &mockCredentialsProvider{ - fnRetrieve: func(ctx context.Context) (aws.Credentials, error) { - return tt.creds, tt.err - }, - } - - creds, err := getSessionCredentials(context.Background(), mockProvider) - - if tt.err != nil { - require.ErrorContains(t, err, tt.err.Error()) + clusterName := "foobar" + credentials, err := convertLocalCredsToSession(ctx, tt.mockStsClient, tt.mockIamClient, tt.mockChecker, tt.roleARN, clusterName) + if tt.wantErr { + require.Error(t, err) } else { - require.NotNil(t, creds) require.NoError(t, err) - require.Equal(t, tt.creds, *creds) + require.NotNil(t, credentials) + require.Equal(t, "access-key-id", *credentials.AccessKeyId) + require.Equal(t, "secret-access-key", *credentials.SecretAccessKey) + require.Equal(t, "session-token", *credentials.SessionToken) } }) } @@ -424,23 +599,6 @@ func TestValidateAMIType(t *testing.T) { } } -// Begin Mock definitions -type mockCredentialsProvider struct { - fnRetrieve func(ctx context.Context) (aws.Credentials, error) -} - -func (m *mockCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { - if m.fnRetrieve == nil { - return aws.Credentials{}, errors.New("not implemented") - } - - creds, err := m.fnRetrieve(ctx) - if err != nil { - return aws.Credentials{}, err - } - return creds, nil -} - type mockSSMClient struct { fnGetParameter func(ctx context.Context, input *ssm.GetParameterInput, opts ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) } diff --git a/cmd/azure/create.go b/cmd/azure/create.go index 696dacda7..9a9a799e8 100644 --- a/cmd/azure/create.go +++ b/cmd/azure/create.go @@ -37,7 +37,7 @@ var envvarSecrets = []string{ } func createAzure(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "azure") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderAzure) if err != nil { progress.Error(err.Error()) return nil @@ -45,10 +45,9 @@ func createAzure(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(20) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - progress.Error(err.Error()) - return nil + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/cmd/civo/create.go b/cmd/civo/create.go index a8e42aa1d..34532f138 100644 --- a/cmd/civo/create.go +++ b/cmd/civo/create.go @@ -26,7 +26,7 @@ import ( ) func createCivo(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "civo") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderCivo) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get CLI flags: %w", err) @@ -34,9 +34,9 @@ func createCivo(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(15) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - return fmt.Errorf("catalog apps validation failed: %w", err) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider, cliFlags.DNSProvider) diff --git a/cmd/digitalocean/create.go b/cmd/digitalocean/create.go index 8453d100b..4da9ae0f0 100644 --- a/cmd/digitalocean/create.go +++ b/cmd/digitalocean/create.go @@ -7,7 +7,6 @@ See the LICENSE file for more details. package digitalocean import ( - "errors" "fmt" "os" "strings" @@ -27,7 +26,7 @@ import ( ) func createDigitalocean(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "digitalocean") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderDigitalOcean) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get CLI flags: %w", err) @@ -35,13 +34,9 @@ func createDigitalocean(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(20) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) if err != nil { - return fmt.Errorf("catalog validation error: %w", err) - } - - if !isValid { - return errors.New("catalog did not pass a validation check") + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider, cliFlags.DNSProvider) diff --git a/cmd/google/create.go b/cmd/google/create.go index 1c19b19f9..fbfb38c25 100644 --- a/cmd/google/create.go +++ b/cmd/google/create.go @@ -27,7 +27,7 @@ import ( ) func createGoogle(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "google") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderGoogle) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get flags: %w", err) @@ -35,9 +35,9 @@ func createGoogle(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(20) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) - if !isValid { - return fmt.Errorf("catalog apps validation failed: %w", err) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + if err != nil { + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/cmd/k3d/create.go b/cmd/k3d/create.go index aab1cb307..9481e2cd6 100644 --- a/cmd/k3d/create.go +++ b/cmd/k3d/create.go @@ -64,7 +64,7 @@ func runK3d(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to get ci flag: %w", err) } - cliFlags, err := utilities.GetFlags(cmd, "k3d") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderK3d) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get flags: %w", err) @@ -73,15 +73,11 @@ func runK3d(cmd *cobra.Command, _ []string) error { utilities.CreateK1ClusterDirectory(cliFlags.ClusterName) utils.DisplayLogHints() - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) if err != nil { return fmt.Errorf("failed to validate catalog apps: %w", err) } - if !isValid { - return errors.New("catalog apps validation failed") - } - switch cliFlags.GitProvider { case "github": key, err := internalssh.GetHostKey("github.com") diff --git a/cmd/k3s/create.go b/cmd/k3s/create.go index 027bcf0c2..218e9ffb8 100644 --- a/cmd/k3s/create.go +++ b/cmd/k3s/create.go @@ -29,7 +29,7 @@ import ( ) func createK3s(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "k3s") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderK3s) if err != nil { progress.Error(err.Error()) return fmt.Errorf("error collecting flags: %w", err) @@ -37,13 +37,9 @@ func createK3s(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(20) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) if err != nil { - return fmt.Errorf("validation of catalog apps failed: %w", err) - } - - if !isValid { - return errors.New("catalog validation failed") + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider) diff --git a/cmd/vultr/create.go b/cmd/vultr/create.go index 7f27f1897..60806c55b 100644 --- a/cmd/vultr/create.go +++ b/cmd/vultr/create.go @@ -7,7 +7,6 @@ See the LICENSE file for more details. package vultr import ( - "errors" "fmt" "os" "strings" @@ -27,7 +26,7 @@ import ( ) func createVultr(cmd *cobra.Command, _ []string) error { - cliFlags, err := utilities.GetFlags(cmd, "vultr") + cliFlags, err := utilities.GetFlags(cmd, utilities.CloudProviderVultr) if err != nil { progress.Error(err.Error()) return fmt.Errorf("failed to get flags: %w", err) @@ -35,13 +34,9 @@ func createVultr(cmd *cobra.Command, _ []string) error { progress.DisplayLogHints(15) - isValid, catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) + catalogApps, err := catalog.ValidateCatalogApps(cliFlags.InstallCatalogApps) if err != nil { - return fmt.Errorf("catalog apps validation failed: %w", err) - } - - if !isValid { - return errors.New("catalog validation failed") + return fmt.Errorf("failed to validate catalog apps: %w", err) } err = ValidateProvidedFlags(cliFlags.GitProvider, cliFlags.DNSProvider) diff --git a/internal/aws/aws.go b/internal/aws/aws.go new file mode 100644 index 000000000..7242a82ca --- /dev/null +++ b/internal/aws/aws.go @@ -0,0 +1,65 @@ +package aws + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/iam" +) + +type iamClienter interface { + SimulatePrincipalPolicy(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) +} + +// Checker is a struct that holds an IAM client +// to check if a role can perform a given action +type Checker struct { + IAMClient iamClienter +} + +// NewChecker creates a new AWSChecker instance +func NewChecker(ctx context.Context) (*Checker, error) { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + iamClient := iam.NewFromConfig(cfg) + return &Checker{IAMClient: iamClient}, nil +} + +// CanRoleDoAction calls the IAM Policy Simulator for a given set of permissions +// For example, to know if a role can create an EKS cluster, you can call this function +// with the role ARN and the action "eks:CreateCluster": +// +// canCreateCluster, err := CanRoleDoAction(ctx, "arn:aws:iam::123456789012:role/MyRole", []string{"eks:CreateCluster"}) +func (a *Checker) CanRoleDoAction(ctx context.Context, roleArn string, actions []string) (bool, error) { + simulateOutput, err := a.IAMClient.SimulatePrincipalPolicy(ctx, &iam.SimulatePrincipalPolicyInput{ + PolicySourceArn: aws.String(roleArn), + ActionNames: actions, + ResourceArns: []string{"*"}, // or a more specific ARN if you want + }) + if err != nil { + return false, fmt.Errorf("policy simulation error: %w", err) + } + + // Track allowed actions + allowedActions := make(map[string]bool) + + // Evaluate simulation results for each action + for _, res := range simulateOutput.EvaluationResults { + if res.EvalActionName != nil && res.EvalDecision == "allowed" { + allowedActions[*res.EvalActionName] = true + } + } + + // Ensure all actions are allowed + for _, action := range actions { + if !allowedActions[action] { + return false, nil + } + } + + return true, nil +} diff --git a/internal/aws/aws_test.go b/internal/aws/aws_test.go new file mode 100644 index 000000000..e481fb614 --- /dev/null +++ b/internal/aws/aws_test.go @@ -0,0 +1,127 @@ +package aws + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/stretchr/testify/require" +) + +type mockIamClient struct { + FnSimulatePrincipalPolicy func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) +} + +func (m *mockIamClient) SimulatePrincipalPolicy(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + if m.FnSimulatePrincipalPolicy != nil { + return m.FnSimulatePrincipalPolicy(ctx, params, optFns...) + } + return nil, errors.New("not implemented") +} + +func TestCanRoleDoAction(t *testing.T) { + tests := []struct { + name string + mockFn func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) + roleArn string + actions []string + expectedResult bool + expectedError error + }{ + { + name: "all actions allowed", + mockFn: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return &iam.SimulatePrincipalPolicyOutput{ + EvaluationResults: []types.EvaluationResult{ + { + EvalActionName: aws.String("eks:CreateCluster"), + EvalDecision: "allowed", + }, + { + EvalActionName: aws.String("s3:PutObject"), + EvalDecision: "allowed", + }, + }, + }, nil + }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster", "s3:PutObject"}, + expectedResult: true, + expectedError: nil, + }, + { + name: "missing action", + mockFn: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return &iam.SimulatePrincipalPolicyOutput{ + EvaluationResults: []types.EvaluationResult{ + { + EvalActionName: aws.String("eks:CreateCluster"), + EvalDecision: "allowed", + }, + { + EvalActionName: aws.String("s3:PutObject"), + EvalDecision: "allowed", + }, + }, + }, nil + }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster", "s3:PutObject", "s3:GetObject"}, + expectedResult: false, + expectedError: nil, + }, + { + name: "some actions not allowed", + mockFn: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return &iam.SimulatePrincipalPolicyOutput{ + EvaluationResults: []types.EvaluationResult{ + { + EvalActionName: aws.String("eks:CreateCluster"), + EvalDecision: "allowed", + }, + { + EvalActionName: aws.String("s3:PutObject"), + EvalDecision: "explicitDeny", + }, + }, + }, nil + }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster", "s3:PutObject"}, + expectedResult: false, + expectedError: nil, + }, + { + name: "simulate policy error", + mockFn: func(ctx context.Context, params *iam.SimulatePrincipalPolicyInput, optFns ...func(*iam.Options)) (*iam.SimulatePrincipalPolicyOutput, error) { + return nil, errors.New("simulation failed") + }, + roleArn: "arn:aws:iam::123456789012:role/MyRole", + actions: []string{"eks:CreateCluster"}, + expectedResult: false, + expectedError: errors.New("policy simulation error: simulation failed"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockClient := &mockIamClient{ + FnSimulatePrincipalPolicy: test.mockFn, + } + checker := &Checker{IAMClient: mockClient} + result, err := checker.CanRoleDoAction(context.Background(), test.roleArn, test.actions) + + if test.expectedError != nil { + require.Error(t, err) + require.EqualError(t, err, test.expectedError.Error()) + } else { + require.NoError(t, err) + } + + require.Equal(t, test.expectedResult, result) + }) + } +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 1cd7e9d88..23b0512e0 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -14,10 +14,7 @@ import ( "strings" git "github.com/google/go-github/v52/github" - apiTypes "github.com/konstructio/kubefirst-api/pkg/types" - - "github.com/rs/zerolog/log" "gopkg.in/yaml.v3" ) @@ -36,71 +33,69 @@ func NewGitHub() *git.Client { return git.NewClient(nil) } -func ReadActiveApplications() (apiTypes.GitopsCatalogApps, error) { +func ReadActiveApplications() (*apiTypes.GitopsCatalogApps, error) { gh := GitHubClient{ Client: NewGitHub(), } activeContent, err := gh.ReadGitopsCatalogRepoContents() if err != nil { - return apiTypes.GitopsCatalogApps{}, fmt.Errorf("error retrieving gitops catalog repository content: %w", err) + return nil, fmt.Errorf("error retrieving gitops catalog repository content: %w", err) } index, err := gh.ReadGitopsCatalogIndex(activeContent) if err != nil { - return apiTypes.GitopsCatalogApps{}, fmt.Errorf("error retrieving gitops catalog index content: %w", err) + return nil, fmt.Errorf("error retrieving gitops catalog index content: %w", err) } var out apiTypes.GitopsCatalogApps err = yaml.Unmarshal(index, &out) if err != nil { - return apiTypes.GitopsCatalogApps{}, fmt.Errorf("error retrieving gitops catalog applications: %w", err) + return nil, fmt.Errorf("error retrieving gitops catalog applications: %w", err) } - return out, nil + return &out, nil } -func ValidateCatalogApps(catalogApps string) (bool, []apiTypes.GitopsCatalogApp, error) { - items := strings.Split(catalogApps, ",") - - gitopsCatalogapps := []apiTypes.GitopsCatalogApp{} +func ValidateCatalogApps(catalogApps string) ([]apiTypes.GitopsCatalogApp, error) { if catalogApps == "" { - return true, gitopsCatalogapps, nil + // No catalog apps to install + return nil, nil } apps, err := ReadActiveApplications() if err != nil { - log.Error().Msgf("error getting gitops catalog applications: %s", err) - return false, gitopsCatalogapps, err + return nil, err } + items := strings.Split(catalogApps, ",") + gitopsCatalogapps := make([]apiTypes.GitopsCatalogApp, 0, len(items)) for _, app := range items { found := false + for _, catalogApp := range apps.Apps { if app == catalogApp.Name { found = true - if catalogApp.SecretKeys != nil { - for _, secret := range catalogApp.SecretKeys { - secretValue := os.Getenv(secret.Env) - - if secretValue == "" { - return false, gitopsCatalogapps, fmt.Errorf("your %q environment variable is not set for %q catalog application. Please set and try again", secret.Env, app) - } - - secret.Value = secretValue + for pos, secret := range catalogApp.SecretKeys { + secretValue := os.Getenv(secret.Env) + if secretValue == "" { + return nil, fmt.Errorf("your %q environment variable is not set for %q catalog application. Please set and try again", secret.Env, app) } + + secret.Value = secretValue + catalogApp.SecretKeys[pos] = secret } - if catalogApp.ConfigKeys != nil { - for _, config := range catalogApp.ConfigKeys { - configValue := os.Getenv(config.Env) - if configValue == "" { - return false, gitopsCatalogapps, fmt.Errorf("your %q environment variable is not set for %q catalog application. Please set and try again", config.Env, app) - } - config.Value = configValue + for pos, config := range catalogApp.ConfigKeys { + configValue := os.Getenv(config.Env) + if configValue == "" { + return nil, fmt.Errorf("your %q environment variable is not set for %q catalog application. Please set and try again", config.Env, app) } + + config.Value = configValue + catalogApp.ConfigKeys[pos] = config } gitopsCatalogapps = append(gitopsCatalogapps, catalogApp) @@ -108,12 +103,13 @@ func ValidateCatalogApps(catalogApps string) (bool, []apiTypes.GitopsCatalogApp, break } } + if !found { - return false, gitopsCatalogapps, fmt.Errorf("catalog app is not supported: %q", app) + return nil, fmt.Errorf("catalog app is not supported: %q", app) } } - return true, gitopsCatalogapps, nil + return gitopsCatalogapps, nil } func (gh *GitHubClient) ReadGitopsCatalogRepoContents() ([]*git.RepositoryContent, error) { diff --git a/internal/types/flags.go b/internal/types/flags.go index 584b88482..70c7720f7 100644 --- a/internal/types/flags.go +++ b/internal/types/flags.go @@ -36,4 +36,5 @@ type CliFlags struct { K3sServersArgs []string InstallKubefirstPro bool AMIType string + KubeAdminRoleARN string } diff --git a/internal/utilities/flags.go b/internal/utilities/flags.go index 302f1fbd6..acee4ff14 100644 --- a/internal/utilities/flags.go +++ b/internal/utilities/flags.go @@ -16,7 +16,44 @@ import ( "github.com/spf13/viper" ) -func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) { +type cloudProvider int + +func (c cloudProvider) String() string { + switch c { + case CloudProviderAWS: + return "aws" + case CloudProviderAzure: + return "azure" + case CloudProviderGoogle: + return "google" + case CloudProviderK3s: + return "k3s" + case CloudProviderAkamai: + return "akamai" + case CloudProviderCivo: + return "civo" + case CloudProviderDigitalOcean: + return "digitalocean" + case CloudProviderVultr: + return "vultr" + default: + return "" + } +} + +const ( + CloudProviderAWS cloudProvider = iota + 1 + CloudProviderAzure + CloudProviderGoogle + CloudProviderK3s + CloudProviderK3d + CloudProviderAkamai + CloudProviderCivo + CloudProviderDigitalOcean + CloudProviderVultr +) + +func GetFlags(cmd *cobra.Command, cloudProvider cloudProvider) (types.CliFlags, error) { cliFlags := types.CliFlags{} alertsEmailFlag, err := cmd.Flags().GetString("alerts-email") @@ -123,7 +160,7 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) return cliFlags, fmt.Errorf("failed to get install-kubefirst-pro flag: %w", err) } - if cloudProvider == "aws" { + if cloudProvider == CloudProviderAWS { ecrFlag, err := cmd.Flags().GetBool("ecr") if err != nil { progress.Error(err.Error()) @@ -137,9 +174,15 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) return cliFlags, fmt.Errorf("failed to get ami type: %w", err) } cliFlags.AMIType = amiType + + kubernetesAdminRoleArn, err := cmd.Flags().GetString("kubernetes-admin-role-arn") + if err != nil { + return cliFlags, fmt.Errorf("failed to get kubernetes-admin-role-arn flag: %w", err) + } + cliFlags.KubeAdminRoleARN = kubernetesAdminRoleArn } - if cloudProvider == "azure" { + if cloudProvider == CloudProviderAzure { dnsAzureResourceGroup, err := cmd.Flags().GetString("dns-azure-resource-group") if err != nil { progress.Error(err.Error()) @@ -148,7 +191,7 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) cliFlags.DNSAzureRG = dnsAzureResourceGroup } - if cloudProvider == "google" { + if cloudProvider == CloudProviderGoogle { googleProject, err := cmd.Flags().GetString("google-project") if err != nil { progress.Error(err.Error()) @@ -158,7 +201,7 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) cliFlags.GoogleProject = googleProject } - if cloudProvider == "k3s" { + if cloudProvider == CloudProviderK3s { k3sServersPrivateIps, err := cmd.Flags().GetStringSlice("servers-private-ips") if err != nil { progress.Error(err.Error()) @@ -208,7 +251,7 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) cliFlags.GitopsTemplateBranch = gitopsTemplateBranchFlag cliFlags.GitopsTemplateURL = gitopsTemplateURLFlag cliFlags.UseTelemetry = useTelemetryFlag - cliFlags.CloudProvider = cloudProvider + cliFlags.CloudProvider = cloudProvider.String() cliFlags.NodeType = nodeTypeFlag cliFlags.NodeCount = nodeCountFlag cliFlags.InstallCatalogApps = installCatalogAppsFlag @@ -222,13 +265,15 @@ func GetFlags(cmd *cobra.Command, cloudProvider string) (types.CliFlags, error) viper.Set("flags.git-protocol", cliFlags.GitProtocol) viper.Set("flags.cloud-region", cliFlags.CloudRegion) viper.Set("kubefirst.cloud-provider", cloudProvider) - if cloudProvider == "k3s" { + + if cloudProvider == CloudProviderK3s { viper.Set("flags.servers-private-ips", cliFlags.K3sServersPrivateIPs) viper.Set("flags.servers-public-ips", cliFlags.K3sServersPublicIPs) viper.Set("flags.ssh-user", cliFlags.K3sSSHUser) viper.Set("flags.ssh-privatekey", cliFlags.K3sSSHPrivateKey) viper.Set("flags.servers-args", cliFlags.K3sServersArgs) } + if err := viper.WriteConfig(); err != nil { return cliFlags, fmt.Errorf("failed to write configuration: %w", err) }