From a4b812962dc1c62ac19b1d2c3d62f49656904e48 Mon Sep 17 00:00:00 2001 From: rrp-bot Date: Thu, 26 Mar 2026 21:31:45 +0000 Subject: [PATCH 1/6] feat: add cluster deploy command for parallel IAM and VPC deployment Adds a new `rosactl cluster deploy` command that creates both IAM and VPC CloudFormation stacks concurrently using goroutines, reducing total deployment time compared to running them sequentially. Usage: rosactl cluster deploy CLUSTER_NAME \ --oidc-issuer-url https://... \ --region us-east-1 \ --availability-zones us-east-1a,us-east-1b,us-east-1c Co-Authored-By: Claude Sonnet 4.6 --- internal/commands/cluster/cluster.go | 19 +++ internal/commands/cluster/deploy.go | 173 +++++++++++++++++++++++++++ internal/commands/root.go | 2 + 3 files changed, 194 insertions(+) create mode 100644 internal/commands/cluster/cluster.go create mode 100644 internal/commands/cluster/deploy.go mode change 100644 => 100755 internal/commands/root.go 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..a2e935d --- /dev/null +++ b/internal/commands/cluster/deploy.go @@ -0,0 +1,173 @@ +package cluster + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/openshift-online/rosa-regional-platform-cli/internal/crypto" + "github.com/openshift-online/rosa-regional-platform-cli/internal/services/clusteriam" + "github.com/openshift-online/rosa-regional-platform-cli/internal/services/clustervpc" + "github.com/spf13/cobra" +) + +type deployOptions struct { + clusterName string + region string + oidcIssuerURL string + vpcCidr string + publicSubnetCidrs string + privateSubnetCidrs string + availabilityZones string + singleNatGateway bool +} + +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] + return runDeploy(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)") + cmd.Flags().StringVar(&opts.availabilityZones, "availability-zones", "", "Comma-separated availability zones, 3 required (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().BoolVar(&opts.singleNatGateway, "single-nat-gateway", true, "Use single NAT gateway (true=cost savings, false=HA per-AZ)") + + cmd.MarkFlagRequired("oidc-issuer-url") + cmd.MarkFlagRequired("region") + cmd.MarkFlagRequired("availability-zones") + + return cmd +} + +type deployResult struct { + name string + err error +} + +func runDeploy(ctx context.Context, opts *deployOptions) error { + if !strings.HasPrefix(opts.oidcIssuerURL, "https://") { + return fmt.Errorf("OIDC issuer URL must start with https://") + } + + azs := strings.Split(opts.availabilityZones, ",") + if len(azs) < 3 { + return fmt.Errorf("at least 3 availability zones are required, got %d", len(azs)) + } + + publicSubnets := strings.Split(opts.publicSubnetCidrs, ",") + privateSubnets := strings.Split(opts.privateSubnetCidrs, ",") + + fmt.Printf("Deploying cluster resources for: %s\n", opts.clusterName) + fmt.Printf(" Region: %s\n", opts.region) + fmt.Printf(" OIDC Issuer: %s\n", opts.oidcIssuerURL) + fmt.Printf(" VPC CIDR: %s\n", opts.vpcCidr) + fmt.Printf(" Availability Zones: %s\n", opts.availabilityZones) + fmt.Println() + + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(opts.region)) + if err != nil { + return fmt.Errorf("failed to load AWS config: %w", err) + } + + results := make(chan deployResult, 2) + var wg sync.WaitGroup + + // IAM deployment goroutine + wg.Add(1) + go func() { + defer wg.Done() + + fmt.Println("[IAM] Fetching TLS thumbprint from OIDC issuer...") + thumbprint, err := crypto.GetOIDCThumbprint(ctx, opts.oidcIssuerURL) + if err != nil { + results <- deployResult{"IAM", fmt.Errorf("failed to fetch TLS thumbprint: %w", err)} + return + } + + iamReq := &clusteriam.CreateIAMRequest{ + ClusterName: opts.clusterName, + OIDCIssuerURL: opts.oidcIssuerURL, + OIDCThumbprint: thumbprint, + AWSConfig: cfg, + } + + fmt.Printf("[IAM] Creating CloudFormation stack: rosa-%s-iam\n", opts.clusterName) + resp, err := clusteriam.CreateIAM(ctx, iamReq) + if err != nil { + results <- deployResult{"IAM", err} + return + } + + fmt.Printf("[IAM] Done. Stack ID: %s\n", resp.StackID) + results <- deployResult{"IAM", nil} + }() + + // VPC deployment goroutine + wg.Add(1) + go func() { + defer wg.Done() + + vpcReq := &clustervpc.CreateVPCRequest{ + ClusterName: opts.clusterName, + VpcCidr: opts.vpcCidr, + PublicSubnetCidrs: publicSubnets, + PrivateSubnetCidrs: privateSubnets, + AvailabilityZones: azs, + SingleNatGateway: opts.singleNatGateway, + AWSConfig: cfg, + } + + fmt.Printf("[VPC] Creating CloudFormation stack: rosa-%s-vpc\n", opts.clusterName) + resp, err := clustervpc.CreateVPC(ctx, vpcReq) + if err != nil { + results <- deployResult{"VPC", err} + return + } + + fmt.Printf("[VPC] Done. Stack ID: %s\n", resp.StackID) + results <- deployResult{"VPC", nil} + }() + + 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/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()) From 1148056817bdb197ad0114dc98183c0a95e260dd Mon Sep 17 00:00:00 2001 From: rrp-bot Date: Thu, 26 Mar 2026 21:40:35 +0000 Subject: [PATCH 2/6] refactor: eliminate duplicated flag definitions in cluster deploy Export CreateOptions, AddFlags, and RunCreate from the clusteriam and clustervpc command packages so the deploy command composes them directly instead of maintaining a second copy of every flag. Adding or changing a flag on cluster-iam create or cluster-vpc create now automatically applies to cluster deploy with no extra work. Co-Authored-By: Claude Sonnet 4.6 --- internal/commands/cluster/deploy.go | 112 +++++++------------------ internal/commands/clusteriam/create.go | 51 ++++++----- internal/commands/clustervpc/create.go | 73 +++++++++------- 3 files changed, 101 insertions(+), 135 deletions(-) mode change 100644 => 100755 internal/commands/clusteriam/create.go mode change 100644 => 100755 internal/commands/clustervpc/create.go diff --git a/internal/commands/cluster/deploy.go b/internal/commands/cluster/deploy.go index a2e935d..80e0532 100644 --- a/internal/commands/cluster/deploy.go +++ b/internal/commands/cluster/deploy.go @@ -6,22 +6,19 @@ import ( "strings" "sync" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/openshift-online/rosa-regional-platform-cli/internal/crypto" - "github.com/openshift-online/rosa-regional-platform-cli/internal/services/clusteriam" - "github.com/openshift-online/rosa-regional-platform-cli/internal/services/clustervpc" + 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 - oidcIssuerURL string - vpcCidr string - publicSubnetCidrs string - privateSubnetCidrs string - availabilityZones string - singleNatGateway bool + clusterName string + region string + iam cmdiam.CreateOptions + vpc cmdvpc.CreateOptions } func newDeployCommand() *cobra.Command { @@ -47,16 +44,16 @@ Example: }, } - cmd.Flags().StringVar(&opts.oidcIssuerURL, "oidc-issuer-url", "", "OIDC issuer URL from Management Cluster (required)") + // --region is shared between IAM and VPC; add it once here. cmd.Flags().StringVar(&opts.region, "region", "", "AWS region (required)") - cmd.Flags().StringVar(&opts.availabilityZones, "availability-zones", "", "Comma-separated availability zones, 3 required (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().BoolVar(&opts.singleNatGateway, "single-nat-gateway", true, "Use single NAT gateway (true=cost savings, false=HA per-AZ)") + cmd.MarkFlagRequired("region") + + // Delegate flag registration to each sub-command package so this list + // never diverges from the individual create commands. + cmdiam.AddFlags(cmd, &opts.iam) + cmdvpc.AddFlags(cmd, &opts.vpc) cmd.MarkFlagRequired("oidc-issuer-url") - cmd.MarkFlagRequired("region") cmd.MarkFlagRequired("availability-zones") return cmd @@ -68,87 +65,34 @@ type deployResult struct { } func runDeploy(ctx context.Context, opts *deployOptions) error { - if !strings.HasPrefix(opts.oidcIssuerURL, "https://") { - return fmt.Errorf("OIDC issuer URL must start with https://") - } - - azs := strings.Split(opts.availabilityZones, ",") - if len(azs) < 3 { - return fmt.Errorf("at least 3 availability zones are required, got %d", len(azs)) - } - - publicSubnets := strings.Split(opts.publicSubnetCidrs, ",") - privateSubnets := strings.Split(opts.privateSubnetCidrs, ",") + // 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.oidcIssuerURL) - fmt.Printf(" VPC CIDR: %s\n", opts.vpcCidr) - fmt.Printf(" Availability Zones: %s\n", opts.availabilityZones) + 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() - - cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(opts.region)) - if err != nil { - return fmt.Errorf("failed to load AWS config: %w", err) - } results := make(chan deployResult, 2) var wg sync.WaitGroup - // IAM deployment goroutine wg.Add(1) go func() { defer wg.Done() - - fmt.Println("[IAM] Fetching TLS thumbprint from OIDC issuer...") - thumbprint, err := crypto.GetOIDCThumbprint(ctx, opts.oidcIssuerURL) - if err != nil { - results <- deployResult{"IAM", fmt.Errorf("failed to fetch TLS thumbprint: %w", err)} - return - } - - iamReq := &clusteriam.CreateIAMRequest{ - ClusterName: opts.clusterName, - OIDCIssuerURL: opts.oidcIssuerURL, - OIDCThumbprint: thumbprint, - AWSConfig: cfg, - } - - fmt.Printf("[IAM] Creating CloudFormation stack: rosa-%s-iam\n", opts.clusterName) - resp, err := clusteriam.CreateIAM(ctx, iamReq) - if err != nil { - results <- deployResult{"IAM", err} - return - } - - fmt.Printf("[IAM] Done. Stack ID: %s\n", resp.StackID) - results <- deployResult{"IAM", nil} + results <- deployResult{"IAM", cmdiam.RunCreate(ctx, &opts.iam)} }() - // VPC deployment goroutine wg.Add(1) go func() { defer wg.Done() - - vpcReq := &clustervpc.CreateVPCRequest{ - ClusterName: opts.clusterName, - VpcCidr: opts.vpcCidr, - PublicSubnetCidrs: publicSubnets, - PrivateSubnetCidrs: privateSubnets, - AvailabilityZones: azs, - SingleNatGateway: opts.singleNatGateway, - AWSConfig: cfg, - } - - fmt.Printf("[VPC] Creating CloudFormation stack: rosa-%s-vpc\n", opts.clusterName) - resp, err := clustervpc.CreateVPC(ctx, vpcReq) - if err != nil { - results <- deployResult{"VPC", err} - return - } - - fmt.Printf("[VPC] Done. Stack ID: %s\n", resp.StackID) - results <- deployResult{"VPC", nil} + results <- deployResult{"VPC", cmdvpc.RunCreate(ctx, &opts.vpc)} }() go func() { diff --git a/internal/commands/clusteriam/create.go b/internal/commands/clusteriam/create.go old mode 100644 new mode 100755 index 178f3ac..a09811b --- a/internal/commands/clusteriam/create.go +++ b/internal/commands/clusteriam/create.go @@ -11,14 +11,23 @@ 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. +// The shared --region flag is intentionally excluded; callers add it once. +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,13 +47,13 @@ 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] + 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.Flags().StringVar(&opts.Region, "region", "", "AWS region (required)") cmd.MarkFlagRequired("oidc-issuer-url") cmd.MarkFlagRequired("region") @@ -52,26 +61,28 @@ Example: 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 } // 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 +90,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..435d50b --- a/internal/commands/clustervpc/create.go +++ b/internal/commands/clustervpc/create.go @@ -10,18 +10,31 @@ 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. +// The shared --region flag is intentionally excluded; callers add it once. +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 +61,57 @@ 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] + 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)") + AddFlags(cmd, opts) + cmd.Flags().StringVar(&opts.Region, "region", "", "AWS region (required)") cmd.MarkFlagRequired("region") 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 { + 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() From 04cb4adee8f29b31654990a11ceaea0a4ef87a36 Mon Sep 17 00:00:00 2001 From: rrp-bot Date: Thu, 26 Mar 2026 21:43:48 +0000 Subject: [PATCH 3/6] refactor: use root persistent --region flag instead of per-command redefinitions --region is already defined as a persistent flag on the root command, making it available to every subcommand automatically. Remove the per-command local flag registrations and read the inherited value in each RunE instead. Validation (empty region) is done in RunCreate/runDeploy since marking an inherited persistent flag required per-command would affect all commands. Co-Authored-By: Claude Sonnet 4.6 --- internal/commands/cluster/deploy.go | 10 ++++++---- internal/commands/clusteriam/create.go | 10 +++++++--- internal/commands/clustervpc/create.go | 11 +++++++---- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/internal/commands/cluster/deploy.go b/internal/commands/cluster/deploy.go index 80e0532..a31966b 100644 --- a/internal/commands/cluster/deploy.go +++ b/internal/commands/cluster/deploy.go @@ -40,16 +40,14 @@ Example: 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) }, } - // --region is shared between IAM and VPC; add it once here. - cmd.Flags().StringVar(&opts.region, "region", "", "AWS region (required)") - cmd.MarkFlagRequired("region") - // 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) @@ -65,6 +63,10 @@ type deployResult struct { } 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 diff --git a/internal/commands/clusteriam/create.go b/internal/commands/clusteriam/create.go index a09811b..9bc15a2 100755 --- a/internal/commands/clusteriam/create.go +++ b/internal/commands/clusteriam/create.go @@ -21,7 +21,8 @@ type CreateOptions struct { } // AddFlags registers the IAM-specific flags on cmd bound to opts. -// The shared --region flag is intentionally excluded; callers add it once. +// --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)") } @@ -48,15 +49,14 @@ Example: Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.ClusterName = args[0] + opts.Region, _ = cmd.Flags().GetString("region") return RunCreate(cmd.Context(), opts) }, } AddFlags(cmd, opts) - cmd.Flags().StringVar(&opts.Region, "region", "", "AWS region (required)") cmd.MarkFlagRequired("oidc-issuer-url") - cmd.MarkFlagRequired("region") return cmd } @@ -69,6 +69,10 @@ func RunCreate(ctx context.Context, opts *CreateOptions) error { return err } + if opts.Region == "" { + return fmt.Errorf("--region is required") + } + // Validate OIDC issuer URL if !strings.HasPrefix(opts.OIDCIssuerURL, "https://") { return fmt.Errorf("OIDC issuer URL must start with https://") diff --git a/internal/commands/clustervpc/create.go b/internal/commands/clustervpc/create.go index 435d50b..273e829 100755 --- a/internal/commands/clustervpc/create.go +++ b/internal/commands/clustervpc/create.go @@ -24,7 +24,8 @@ type CreateOptions struct { } // AddFlags registers the VPC-specific flags on cmd bound to opts. -// The shared --region flag is intentionally excluded; callers add it once. +// --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") @@ -62,14 +63,12 @@ Example: Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.ClusterName = args[0] + opts.Region, _ = cmd.Flags().GetString("region") return RunCreate(cmd.Context(), opts) }, } AddFlags(cmd, opts) - cmd.Flags().StringVar(&opts.Region, "region", "", "AWS region (required)") - - cmd.MarkFlagRequired("region") return cmd } @@ -77,6 +76,10 @@ Example: // 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) From 50b4274938e530912aaff5c8cfa75cbbb98ab2db Mon Sep 17 00:00:00 2001 From: rrp-bot Date: Thu, 26 Mar 2026 21:45:10 +0000 Subject: [PATCH 4/6] fix: cancel sibling deployment on first failure Add context.WithCancel so that if IAM or VPC creation fails the other operation's context is cancelled, aborting it at the next AWS SDK call rather than running it to completion unnecessarily. Co-Authored-By: Claude Sonnet 4.6 --- internal/commands/cluster/deploy.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/commands/cluster/deploy.go b/internal/commands/cluster/deploy.go index a31966b..5287d84 100644 --- a/internal/commands/cluster/deploy.go +++ b/internal/commands/cluster/deploy.go @@ -82,19 +82,32 @@ func runDeploy(ctx context.Context, opts *deployOptions) error { 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() - results <- deployResult{"IAM", cmdiam.RunCreate(ctx, &opts.iam)} + err := cmdiam.RunCreate(ctx, &opts.iam) + if err != nil { + cancel() + } + results <- deployResult{"IAM", err} }() wg.Add(1) go func() { defer wg.Done() - results <- deployResult{"VPC", cmdvpc.RunCreate(ctx, &opts.vpc)} + err := cmdvpc.RunCreate(ctx, &opts.vpc) + if err != nil { + cancel() + } + results <- deployResult{"VPC", err} }() go func() { From 689e4d1c5cff496b52138299b501746c6d23d3ca Mon Sep 17 00:00:00 2001 From: rrp-bot Date: Thu, 26 Mar 2026 21:48:27 +0000 Subject: [PATCH 5/6] test: add localstack integration test for cluster deploy parallel command Adds a Ginkgo spec that exercises `rosactl cluster deploy` against LocalStack, verifying that both the IAM and VPC CloudFormation stacks are created by a single command invocation. Follows the same conventions as the existing IAM/VPC tests: - Accepts CREATE_COMPLETE or CREATE_FAILED (LocalStack resource limitations) - Skips gracefully in offline environments where the OIDC thumbprint TLS connection cannot reach s3.amazonaws.com, which also triggers context cancellation of the VPC goroutine Co-Authored-By: Claude Sonnet 4.6 --- test/localstack/localstack_test.go | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) mode change 100644 => 100755 test/localstack/localstack_test.go 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") From 7b52ca655743f74724355ed0ee768b301a70c398 Mon Sep 17 00:00:00 2001 From: rrp-bot Date: Thu, 26 Mar 2026 22:46:20 +0000 Subject: [PATCH 6/6] fix: initialize repositoryName in lambda test BeforeEach repositoryName was declared in the var block but never assigned before being passed to CreateRepository, causing ECR to reject it with InvalidParameterException on every Lambda test run. Co-Authored-By: Claude Sonnet 4.6 --- test/localstack/lambda_test.go | 1 + 1 file changed, 1 insertion(+) mode change 100644 => 100755 test/localstack/lambda_test.go 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,