Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions internal/commands/cluster/cluster.go
Original file line number Diff line number Diff line change
@@ -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
}
132 changes: 132 additions & 0 deletions internal/commands/cluster/deploy.go
Original file line number Diff line number Diff line change
@@ -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
}
57 changes: 36 additions & 21 deletions internal/commands/clusteriam/create.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -38,62 +48,67 @@ 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)
}
fmt.Printf(" Thumbprint: %s\n", thumbprint)
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()

Expand Down
80 changes: 47 additions & 33 deletions internal/commands/clustervpc/create.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Comment on lines +26 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Don't advertise --availability-zones as optional in the shared helper.

AddFlags now feeds cluster deploy too, where --availability-zones is required. Reusing the current description will make rosactl cluster deploy --help contradict the command's actual contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/commands/clustervpc/create.go` around lines 26 - 34, The flag
description in AddFlags incorrectly advertises "--availability-zones" as
optional/auto-detected which is not true for all callers; update the StringVar
call for opts.AvailabilityZones in AddFlags to use a neutral description like
"Comma-separated availability zones" (remove "(optional, auto-detected if
empty)"), and let specific commands that require the flag override or mark it
required in their own command setup rather than relying on this shared helper.

}

func newCreateCommand() *cobra.Command {
opts := &createOptions{}
opts := &CreateOptions{}

cmd := &cobra.Command{
Use: "create CLUSTER_NAME",
Expand All @@ -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)
}
Comment on lines +78 to 93

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate ClusterName before the VPC workflow starts.

internal/commands/clusteriam/create.go:67-70 rejects invalid names up front, but RunCreate here only checks Region. In the parallel deploy flow, that can turn one bad cluster name into inconsistent IAM/VPC behavior instead of one deterministic validation failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/commands/clustervpc/create.go` around lines 78 - 93, RunCreate
currently only validates Region; add the same upfront ClusterName validation
used in internal/commands/clusteriam/create.go so invalid cluster names are
rejected before any VPC work begins. In RunCreate (function RunCreate,
CreateOptions struct usage), check opts.ClusterName at the top and call the
shared validation helper (or extract the validation logic from
clusteriam/create.go) to return an error when the name is invalid, ensuring the
VPC workflow never starts for bad names.


// 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()

Expand Down
Loading