diff --git a/internal/commands/cluster/cluster.go b/internal/commands/cluster/cluster.go new file mode 100644 index 0000000..2c29594 --- /dev/null +++ b/internal/commands/cluster/cluster.go @@ -0,0 +1,19 @@ +package cluster + +import "github.com/spf13/cobra" + +// NewClusterCommand creates the cluster command +func NewClusterCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "cluster", + Short: "Manage cluster resources", + Long: `Manage combined cluster resources for ROSA hosted clusters. + +This command group provides operations that coordinate multiple resource types +(IAM and VPC) for a hosted cluster.`, + } + + cmd.AddCommand(newDeployCommand()) + + return cmd +} diff --git a/internal/commands/cluster/deploy.go b/internal/commands/cluster/deploy.go new file mode 100644 index 0000000..5287d84 --- /dev/null +++ b/internal/commands/cluster/deploy.go @@ -0,0 +1,132 @@ +package cluster + +import ( + "context" + "fmt" + "strings" + "sync" + + cmdiam "github.com/openshift-online/rosa-regional-platform-cli/internal/commands/clusteriam" + cmdvpc "github.com/openshift-online/rosa-regional-platform-cli/internal/commands/clustervpc" + "github.com/spf13/cobra" +) + +// deployOptions composes the individual command option types so that flag +// definitions are owned exactly once — in the clusteriam and clustervpc +// packages — and automatically inherited here. +type deployOptions struct { + clusterName string + region string + iam cmdiam.CreateOptions + vpc cmdvpc.CreateOptions +} + +func newDeployCommand() *cobra.Command { + opts := &deployOptions{} + + cmd := &cobra.Command{ + Use: "deploy CLUSTER_NAME", + Short: "Deploy cluster IAM and VPC resources in parallel", + Long: `Deploy both IAM and VPC resources for a ROSA hosted cluster simultaneously. + +This command runs the cluster-iam and cluster-vpc create operations in parallel, +reducing total deployment time compared to running them sequentially. + +Example: + rosactl cluster deploy my-cluster \ + --oidc-issuer-url https://d1234.cloudfront.net/my-cluster \ + --region us-east-1 \ + --availability-zones us-east-1a,us-east-1b,us-east-1c`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.clusterName = args[0] + opts.region, _ = cmd.Flags().GetString("region") + return runDeploy(cmd.Context(), opts) + }, + } + + // Delegate flag registration to each sub-command package so this list + // never diverges from the individual create commands. + // --region is inherited from the root persistent flag; no need to add it here. + cmdiam.AddFlags(cmd, &opts.iam) + cmdvpc.AddFlags(cmd, &opts.vpc) + + cmd.MarkFlagRequired("oidc-issuer-url") + cmd.MarkFlagRequired("availability-zones") + + return cmd +} + +type deployResult struct { + name string + err error +} + +func runDeploy(ctx context.Context, opts *deployOptions) error { + if opts.region == "" { + return fmt.Errorf("--region is required") + } + + // Wire shared fields into the sub-options before dispatch. + opts.iam.ClusterName = opts.clusterName + opts.iam.Region = opts.region + opts.vpc.ClusterName = opts.clusterName + opts.vpc.Region = opts.region + + fmt.Printf("Deploying cluster resources for: %s\n", opts.clusterName) + fmt.Printf(" Region: %s\n", opts.region) + fmt.Printf(" OIDC Issuer: %s\n", opts.iam.OIDCIssuerURL) + fmt.Printf(" VPC CIDR: %s\n", opts.vpc.VpcCidr) + fmt.Printf(" Availability Zones: %s\n", opts.vpc.AvailabilityZones) + fmt.Println() + fmt.Println("Running IAM and VPC deployments in parallel...") + fmt.Println() + + // Use a cancellable context so that if one deployment fails the other is + // aborted rather than running to completion unnecessarily. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + results := make(chan deployResult, 2) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + err := cmdiam.RunCreate(ctx, &opts.iam) + if err != nil { + cancel() + } + results <- deployResult{"IAM", err} + }() + + wg.Add(1) + go func() { + defer wg.Done() + err := cmdvpc.RunCreate(ctx, &opts.vpc) + if err != nil { + cancel() + } + results <- deployResult{"VPC", err} + }() + + go func() { + wg.Wait() + close(results) + }() + + var errs []string + for result := range results { + if result.err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", result.name, result.err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("deployment failed:\n %s", strings.Join(errs, "\n ")) + } + + fmt.Println() + fmt.Println("Cluster deployment complete. IAM and VPC resources are ready.") + return nil +} diff --git a/internal/commands/clusteriam/create.go b/internal/commands/clusteriam/create.go old mode 100644 new mode 100755 index 178f3ac..9bc15a2 --- a/internal/commands/clusteriam/create.go +++ b/internal/commands/clusteriam/create.go @@ -11,14 +11,24 @@ import ( "github.com/spf13/cobra" ) -type createOptions struct { - clusterName string - oidcIssuerURL string - region string +// CreateOptions holds options for the cluster-iam create command. +// It is exported so that composite commands (e.g. cluster deploy) can reuse +// the same flag definitions without duplicating them. +type CreateOptions struct { + ClusterName string + OIDCIssuerURL string + Region string +} + +// AddFlags registers the IAM-specific flags on cmd bound to opts. +// --region is intentionally excluded: it is a persistent root-level flag +// inherited by all commands. +func AddFlags(cmd *cobra.Command, opts *CreateOptions) { + cmd.Flags().StringVar(&opts.OIDCIssuerURL, "oidc-issuer-url", "", "OIDC issuer URL from Management Cluster (required)") } func newCreateCommand() *cobra.Command { - opts := &createOptions{} + opts := &CreateOptions{} cmd := &cobra.Command{ Use: "create CLUSTER_NAME", @@ -38,40 +48,45 @@ Example: --region us-east-1`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.clusterName = args[0] - return runCreate(cmd.Context(), opts) + opts.ClusterName = args[0] + opts.Region, _ = cmd.Flags().GetString("region") + return RunCreate(cmd.Context(), opts) }, } - cmd.Flags().StringVar(&opts.oidcIssuerURL, "oidc-issuer-url", "", "OIDC issuer URL from Management Cluster (required)") - cmd.Flags().StringVar(&opts.region, "region", "", "AWS region (required)") + AddFlags(cmd, opts) cmd.MarkFlagRequired("oidc-issuer-url") - cmd.MarkFlagRequired("region") return cmd } -func runCreate(ctx context.Context, opts *createOptions) error { +// RunCreate executes the IAM creation workflow. It is exported so that +// composite commands can invoke it directly. +func RunCreate(ctx context.Context, opts *CreateOptions) error { // Validate cluster name - if err := validateClusterName(opts.clusterName); err != nil { + if err := validateClusterName(opts.ClusterName); err != nil { return err } + if opts.Region == "" { + return fmt.Errorf("--region is required") + } + // Validate OIDC issuer URL - if !strings.HasPrefix(opts.oidcIssuerURL, "https://") { + if !strings.HasPrefix(opts.OIDCIssuerURL, "https://") { return fmt.Errorf("OIDC issuer URL must start with https://") } fmt.Println("🔐 Creating cluster IAM resources...") - fmt.Printf(" Cluster: %s\n", opts.clusterName) - fmt.Printf(" OIDC Issuer: %s\n", opts.oidcIssuerURL) - fmt.Printf(" Region: %s\n", opts.region) + fmt.Printf(" Cluster: %s\n", opts.ClusterName) + fmt.Printf(" OIDC Issuer: %s\n", opts.OIDCIssuerURL) + fmt.Printf(" Region: %s\n", opts.Region) fmt.Println() // Fetch TLS thumbprint fmt.Println("🔍 Fetching TLS thumbprint from OIDC issuer...") - thumbprint, err := crypto.GetOIDCThumbprint(ctx, opts.oidcIssuerURL) + thumbprint, err := crypto.GetOIDCThumbprint(ctx, opts.OIDCIssuerURL) if err != nil { return fmt.Errorf("failed to fetch TLS thumbprint: %w", err) } @@ -79,21 +94,21 @@ func runCreate(ctx context.Context, opts *createOptions) error { fmt.Println() // Load AWS config - cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(opts.region)) + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(opts.Region)) if err != nil { return fmt.Errorf("failed to load AWS config: %w", err) } // Create service request req := &clusteriam.CreateIAMRequest{ - ClusterName: opts.clusterName, - OIDCIssuerURL: opts.oidcIssuerURL, + ClusterName: opts.ClusterName, + OIDCIssuerURL: opts.OIDCIssuerURL, OIDCThumbprint: thumbprint, AWSConfig: cfg, } fmt.Println("📄 Preparing IAM CloudFormation operation...") - fmt.Printf("☁️ Creating or updating CloudFormation stack: rosa-%s-iam\n", opts.clusterName) + fmt.Printf("☁️ Creating or updating CloudFormation stack: rosa-%s-iam\n", opts.ClusterName) fmt.Println(" This may take several minutes...") fmt.Println() diff --git a/internal/commands/clustervpc/create.go b/internal/commands/clustervpc/create.go old mode 100644 new mode 100755 index fb8b424..273e829 --- a/internal/commands/clustervpc/create.go +++ b/internal/commands/clustervpc/create.go @@ -10,18 +10,32 @@ import ( "github.com/spf13/cobra" ) -type createOptions struct { - clusterName string - region string - vpcCidr string - publicSubnetCidrs string - privateSubnetCidrs string - availabilityZones string - singleNatGateway bool +// CreateOptions holds options for the cluster-vpc create command. +// It is exported so that composite commands (e.g. cluster deploy) can reuse +// the same flag definitions without duplicating them. +type CreateOptions struct { + ClusterName string + Region string + VpcCidr string + PublicSubnetCidrs string + PrivateSubnetCidrs string + AvailabilityZones string + SingleNatGateway bool +} + +// AddFlags registers the VPC-specific flags on cmd bound to opts. +// --region is intentionally excluded: it is a persistent root-level flag +// inherited by all commands. +func AddFlags(cmd *cobra.Command, opts *CreateOptions) { + cmd.Flags().StringVar(&opts.VpcCidr, "vpc-cidr", "10.0.0.0/16", "CIDR block for the VPC") + cmd.Flags().StringVar(&opts.PublicSubnetCidrs, "public-subnet-cidrs", "10.0.101.0/24,10.0.102.0/24,10.0.103.0/24", "Comma-separated public subnet CIDRs") + cmd.Flags().StringVar(&opts.PrivateSubnetCidrs, "private-subnet-cidrs", "10.0.0.0/19,10.0.32.0/19,10.0.64.0/19", "Comma-separated private subnet CIDRs") + cmd.Flags().StringVar(&opts.AvailabilityZones, "availability-zones", "", "Comma-separated availability zones (optional, auto-detected if empty)") + cmd.Flags().BoolVar(&opts.SingleNatGateway, "single-nat-gateway", true, "Use single NAT gateway (true=cost savings, false=HA per-AZ)") } func newCreateCommand() *cobra.Command { - opts := &createOptions{} + opts := &CreateOptions{} cmd := &cobra.Command{ Use: "create CLUSTER_NAME", @@ -48,59 +62,59 @@ Example: --single-nat-gateway=false`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.clusterName = args[0] - return runCreate(cmd.Context(), opts) + opts.ClusterName = args[0] + opts.Region, _ = cmd.Flags().GetString("region") + return RunCreate(cmd.Context(), opts) }, } - cmd.Flags().StringVar(&opts.region, "region", "", "AWS region (required)") - cmd.Flags().StringVar(&opts.vpcCidr, "vpc-cidr", "10.0.0.0/16", "CIDR block for the VPC") - cmd.Flags().StringVar(&opts.publicSubnetCidrs, "public-subnet-cidrs", "10.0.101.0/24,10.0.102.0/24,10.0.103.0/24", "Comma-separated public subnet CIDRs") - cmd.Flags().StringVar(&opts.privateSubnetCidrs, "private-subnet-cidrs", "10.0.0.0/19,10.0.32.0/19,10.0.64.0/19", "Comma-separated private subnet CIDRs") - cmd.Flags().StringVar(&opts.availabilityZones, "availability-zones", "", "Comma-separated availability zones (optional, auto-detected if empty)") - cmd.Flags().BoolVar(&opts.singleNatGateway, "single-nat-gateway", true, "Use single NAT gateway (true=cost savings, false=HA per-AZ)") - - cmd.MarkFlagRequired("region") + AddFlags(cmd, opts) return cmd } -func runCreate(ctx context.Context, opts *createOptions) error { - fmt.Printf("🌐 Creating cluster VPC resources for: %s\n", opts.clusterName) - fmt.Printf(" Region: %s\n", opts.region) - fmt.Printf(" VPC CIDR: %s\n", opts.vpcCidr) - fmt.Printf(" Single NAT Gateway: %t\n", opts.singleNatGateway) +// RunCreate executes the VPC creation workflow. It is exported so that +// composite commands can invoke it directly. +func RunCreate(ctx context.Context, opts *CreateOptions) error { + if opts.Region == "" { + return fmt.Errorf("--region is required") + } + + fmt.Printf("🌐 Creating cluster VPC resources for: %s\n", opts.ClusterName) + fmt.Printf(" Region: %s\n", opts.Region) + fmt.Printf(" VPC CIDR: %s\n", opts.VpcCidr) + fmt.Printf(" Single NAT Gateway: %t\n", opts.SingleNatGateway) fmt.Println() // Load AWS config - cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(opts.region)) + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(opts.Region)) if err != nil { return fmt.Errorf("failed to load AWS config: %w", err) } // Parse CIDR lists - publicSubnets := strings.Split(opts.publicSubnetCidrs, ",") - privateSubnets := strings.Split(opts.privateSubnetCidrs, ",") + publicSubnets := strings.Split(opts.PublicSubnetCidrs, ",") + privateSubnets := strings.Split(opts.PrivateSubnetCidrs, ",") // Parse availability zones if provided var azs []string - if opts.availabilityZones != "" { - azs = strings.Split(opts.availabilityZones, ",") + if opts.AvailabilityZones != "" { + azs = strings.Split(opts.AvailabilityZones, ",") } // Create service request req := &clustervpc.CreateVPCRequest{ - ClusterName: opts.clusterName, - VpcCidr: opts.vpcCidr, + ClusterName: opts.ClusterName, + VpcCidr: opts.VpcCidr, PublicSubnetCidrs: publicSubnets, PrivateSubnetCidrs: privateSubnets, AvailabilityZones: azs, - SingleNatGateway: opts.singleNatGateway, + SingleNatGateway: opts.SingleNatGateway, AWSConfig: cfg, } fmt.Println("📄 Loading CloudFormation template...") - fmt.Printf("☁️ Creating CloudFormation stack: rosa-%s-vpc\n", opts.clusterName) + fmt.Printf("☁️ Creating CloudFormation stack: rosa-%s-vpc\n", opts.ClusterName) fmt.Println(" This may take several minutes...") fmt.Println() diff --git a/internal/commands/root.go b/internal/commands/root.go old mode 100644 new mode 100755 index d687a3a..68a167d --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -5,6 +5,7 @@ import ( "os" "github.com/openshift-online/rosa-regional-platform-cli/internal/commands/bootstrap" + "github.com/openshift-online/rosa-regional-platform-cli/internal/commands/cluster" "github.com/openshift-online/rosa-regional-platform-cli/internal/commands/clusteriam" "github.com/openshift-online/rosa-regional-platform-cli/internal/commands/clustervpc" "github.com/openshift-online/rosa-regional-platform-cli/internal/commands/handler" @@ -34,6 +35,7 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output") rootCmd.AddCommand(bootstrap.NewBootstrapCommand()) + rootCmd.AddCommand(cluster.NewClusterCommand()) rootCmd.AddCommand(clusteriam.NewClusterIAMCommand()) rootCmd.AddCommand(clustervpc.NewClusterVPCCommand()) rootCmd.AddCommand(handler.NewHandlerCommand()) diff --git a/test/localstack/lambda_test.go b/test/localstack/lambda_test.go old mode 100644 new mode 100755 index 1f019b1..4f0ec42 --- a/test/localstack/lambda_test.go +++ b/test/localstack/lambda_test.go @@ -57,6 +57,7 @@ var _ = Describe("Lambda Handler LocalStack Integration", func() { // Generate unique test cluster name testCluster = fmt.Sprintf("lambda-test-%d", time.Now().Unix()) functionName = fmt.Sprintf("rosa-lambda-test-%d", time.Now().Unix()) + repositoryName = fmt.Sprintf("rosa-lambda-repo-%d", time.Now().Unix()) // Create AWS clients pointing to LocalStack cfg, err := config.LoadDefaultConfig(ctx, diff --git a/test/localstack/localstack_test.go b/test/localstack/localstack_test.go old mode 100644 new mode 100755 index 511caee..e97f73b --- a/test/localstack/localstack_test.go +++ b/test/localstack/localstack_test.go @@ -300,6 +300,77 @@ var _ = Describe("rosactl LocalStack Integration", func() { }, SpecTimeout(180*time.Second)) }) + Describe("Cluster Deploy (parallel)", func() { + It("should deploy both IAM and VPC stacks in a single command", func(ctx SpecContext) { + iamStackName := fmt.Sprintf("rosa-%s-iam", testCluster) + vpcStackName := fmt.Sprintf("rosa-%s-vpc", testCluster) + + By("Running rosactl cluster deploy command") + deployCmd := exec.CommandContext(ctx, binaryPath, "cluster", "deploy", testCluster, + "--region", awsRegion, + "--oidc-issuer-url", "https://test-oidc.s3.amazonaws.com", + "--availability-zones", "us-east-1a,us-east-1b,us-east-1c", + "--single-nat-gateway=false", + ) + deployCmd.Env = append(os.Environ(), + fmt.Sprintf("AWS_ENDPOINT_URL=%s", localstackURL), + fmt.Sprintf("AWS_REGION=%s", awsRegion), + ) + + deploySession, err := gexec.Start(deployCmd, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + + // Both CloudFormation stacks run in parallel; allow enough time for both. + Eventually(deploySession, 120*time.Second).Should(gexec.Exit()) + + output := string(deploySession.Out.Contents()) + errOutput := string(deploySession.Err.Contents()) + GinkgoWriter.Printf("\nCLI Deploy Output:\n%s\n", output) + if errOutput != "" { + GinkgoWriter.Printf("\nCLI Deploy Errors:\n%s\n", errOutput) + } + + Expect(output).To(ContainSubstring("Deploying cluster resources")) + + // VPC does not require a network round-trip for thumbprint fetching, so + // it should always be attempted. If it is absent the IAM thumbprint fetch + // failed and its context cancellation aborted VPC before it started — + // acceptable in offline environments. + By("Verifying VPC stack was created") + vpcResult, err := cfnClient.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{ + StackName: aws.String(vpcStackName), + }) + if err != nil && strings.Contains(err.Error(), "does not exist") { + Skip("VPC stack not created — IAM thumbprint failure likely cancelled VPC via context (expected in offline environments)") + } + Expect(err).NotTo(HaveOccurred(), "VPC stack should exist after deploy command") + Expect(vpcResult.Stacks).To(HaveLen(1)) + vpcStatus := string(vpcResult.Stacks[0].StackStatus) + GinkgoWriter.Printf("VPC Stack status: %s\n", vpcStatus) + // Accept CREATE_COMPLETE or CREATE_FAILED (LocalStack NAT Gateway limitation) + Expect(vpcStatus).To(Or(Equal("CREATE_COMPLETE"), Equal("CREATE_FAILED"))) + + By("Verifying IAM stack was created") + iamResult, err := cfnClient.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{ + StackName: aws.String(iamStackName), + }) + if err != nil && strings.Contains(err.Error(), "does not exist") { + Skip("IAM stack not created — OIDC thumbprint fetch likely failed in LocalStack (expected in offline environments)") + } + Expect(err).NotTo(HaveOccurred(), "IAM stack should exist after deploy command") + Expect(iamResult.Stacks).To(HaveLen(1)) + iamStatus := string(iamResult.Stacks[0].StackStatus) + GinkgoWriter.Printf("IAM Stack status: %s\n", iamStatus) + // Accept CREATE_COMPLETE or CREATE_FAILED (LocalStack AWS-managed policy limitation) + Expect(iamStatus).To(Or( + Equal("CREATE_COMPLETE"), + Equal("CREATE_FAILED"), + Equal("UPDATE_IN_PROGRESS"), + )) + + }, SpecTimeout(180*time.Second)) + }) + Describe("Stack Listing", func() { It("should list CloudFormation stacks", func(ctx SpecContext) { By("Creating a test stack")