diff --git a/docs/stackit_alpha_vpc.md b/docs/stackit_alpha_vpc.md index 7df257803..bd8aa7965 100644 --- a/docs/stackit_alpha_vpc.md +++ b/docs/stackit_alpha_vpc.md @@ -34,5 +34,6 @@ stackit alpha vpc [flags] * [stackit alpha vpc delete](./stackit_alpha_vpc_delete.md) - Deletes a VPC * [stackit alpha vpc describe](./stackit_alpha_vpc_describe.md) - Shows details of a VPC * [stackit alpha vpc list](./stackit_alpha_vpc_list.md) - Lists all VPC of a project +* [stackit alpha vpc region](./stackit_alpha_vpc_region.md) - Manages regional configurations of a VPC * [stackit alpha vpc update](./stackit_alpha_vpc_update.md) - Updates a VPC diff --git a/docs/stackit_alpha_vpc_region.md b/docs/stackit_alpha_vpc_region.md new file mode 100644 index 000000000..2e29c7ae9 --- /dev/null +++ b/docs/stackit_alpha_vpc_region.md @@ -0,0 +1,38 @@ +## stackit alpha vpc region + +Manages regional configurations of a VPC + +### Synopsis + +Manages regional configurations of a VPC. + +``` +stackit alpha vpc region [flags] +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc region" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit alpha vpc](./stackit_alpha_vpc.md) - Manages vpcs +* [stackit alpha vpc region create](./stackit_alpha_vpc_region_create.md) - Creates a regional configuration for a VPC +* [stackit alpha vpc region delete](./stackit_alpha_vpc_region_delete.md) - Deletes a regional configuration for a VPC +* [stackit alpha vpc region describe](./stackit_alpha_vpc_region_describe.md) - Describes a regional configuration for a VPC +* [stackit alpha vpc region list](./stackit_alpha_vpc_region_list.md) - Lists all configured regions for a VPC +* [stackit alpha vpc region update](./stackit_alpha_vpc_region_update.md) - Updates a regional configuration for a VPC + diff --git a/docs/stackit_alpha_vpc_region_create.md b/docs/stackit_alpha_vpc_region_create.md new file mode 100644 index 000000000..d4ea33c57 --- /dev/null +++ b/docs/stackit_alpha_vpc_region_create.md @@ -0,0 +1,45 @@ +## stackit alpha vpc region create + +Creates a regional configuration for a VPC + +### Synopsis + +Creates a regional configuration for a VPC. VPC can only be used in projects with enabled VPC. + +``` +stackit alpha vpc region create [flags] +``` + +### Examples + +``` + Create a regional configuration "eu02" for a VPC with ID "xxx" + $ stackit alpha vpc region create --vpc-id xxx --region eu02 + + Create a regional configuration with default DNS name servers, using the configured project and region + $ stackit alpha vpc region create --vpc-id xxx --ipv4-default-nameservers 8.8.8.8,8.8.4.4 +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc region create" + --ipv4-default-nameservers strings List of default DNS name server IPs + --vpc-id string VPC ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit alpha vpc region](./stackit_alpha_vpc_region.md) - Manages regional configurations of a VPC + diff --git a/docs/stackit_alpha_vpc_region_delete.md b/docs/stackit_alpha_vpc_region_delete.md new file mode 100644 index 000000000..03bb57ba0 --- /dev/null +++ b/docs/stackit_alpha_vpc_region_delete.md @@ -0,0 +1,44 @@ +## stackit alpha vpc region delete + +Deletes a regional configuration for a VPC + +### Synopsis + +Deletes a regional configuration for a VPC. + +``` +stackit alpha vpc region delete [flags] +``` + +### Examples + +``` + Delete the regional configuration "eu02" for a VPC with ID "xxx" + $ stackit alpha vpc region delete --vpc-id xxx --region eu02 + + Delete a regional configuration using the configured project and region + $ stackit alpha vpc region delete --vpc-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc region delete" + --vpc-id string VPC ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit alpha vpc region](./stackit_alpha_vpc_region.md) - Manages regional configurations of a VPC + diff --git a/docs/stackit_alpha_vpc_region_describe.md b/docs/stackit_alpha_vpc_region_describe.md new file mode 100644 index 000000000..ec69dc583 --- /dev/null +++ b/docs/stackit_alpha_vpc_region_describe.md @@ -0,0 +1,44 @@ +## stackit alpha vpc region describe + +Describes a regional configuration for a VPC + +### Synopsis + +Describes a regional configuration for a VPC. + +``` +stackit alpha vpc region describe [flags] +``` + +### Examples + +``` + Describe the regional configuration "eu02" for a VPC with ID "xxx" + $ stackit alpha vpc region describe --vpc-id xxx --region eu02 + + Describe a regional configuration in JSON format, using the configured project and region + $ stackit alpha vpc region describe --vpc-id xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc region describe" + --vpc-id string VPC ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit alpha vpc region](./stackit_alpha_vpc_region.md) - Manages regional configurations of a VPC + diff --git a/docs/stackit_alpha_vpc_region_list.md b/docs/stackit_alpha_vpc_region_list.md new file mode 100644 index 000000000..92c6f36fa --- /dev/null +++ b/docs/stackit_alpha_vpc_region_list.md @@ -0,0 +1,44 @@ +## stackit alpha vpc region list + +Lists all configured regions for a VPC + +### Synopsis + +Lists all configured regions for a VPC. + +``` +stackit alpha vpc region list [flags] +``` + +### Examples + +``` + List all configured regions for a VPC with ID "xxx" + $ stackit alpha vpc region list --vpc-id xxx + + List all configured regions for a VPC in JSON format + $ stackit alpha vpc region list --vpc-id xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc region list" + --vpc-id string VPC ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit alpha vpc region](./stackit_alpha_vpc_region.md) - Manages regional configurations of a VPC + diff --git a/docs/stackit_alpha_vpc_region_update.md b/docs/stackit_alpha_vpc_region_update.md new file mode 100644 index 000000000..3a60fc40c --- /dev/null +++ b/docs/stackit_alpha_vpc_region_update.md @@ -0,0 +1,45 @@ +## stackit alpha vpc region update + +Updates a regional configuration for a VPC + +### Synopsis + +Updates a regional configuration for a VPC. + +``` +stackit alpha vpc region update [flags] +``` + +### Examples + +``` + Update the default DNS name servers for a VPC with ID "xxx" in region "eu02" + $ stackit alpha vpc region update --vpc-id xxx --region eu02 --ipv4-default-nameservers 8.8.8.8,8.8.4.4 + + Clear the default DNS name servers, using the configured project and region + $ stackit alpha vpc region update --vpc-id xxx --ipv4-default-nameservers "" +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc region update" + --ipv4-default-nameservers strings List of default DNS name server IPs + --vpc-id string VPC ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit alpha vpc region](./stackit_alpha_vpc_region.md) - Manages regional configurations of a VPC + diff --git a/internal/cmd/alpha/vpc/region/create/create.go b/internal/cmd/alpha/vpc/region/create/create.go new file mode 100644 index 000000000..b903aeaac --- /dev/null +++ b/internal/cmd/alpha/vpc/region/create/create.go @@ -0,0 +1,151 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + vpcIdFlag = "vpc-id" + ipv4DefaultNameserversFlag = "ipv4-default-nameservers" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string + IPv4DefaultNameservers []string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a regional configuration for a VPC", + Long: "Creates a regional configuration for a VPC. VPC can only be used in projects with enabled VPC.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a regional configuration "eu02" for a VPC with ID "xxx"`, + `$ stackit alpha vpc region create --vpc-id xxx --region eu02`, + ), + examples.NewExample( + `Create a regional configuration with default DNS name servers, using the configured project and region`, + `$ stackit alpha vpc region create --vpc-id xxx --ipv4-default-nameservers 8.8.8.8,8.8.4.4`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + vpcLabel, err := iaasUtils.GetVPCName(ctx, apiClient.DefaultAPI, model.ProjectId, model.VpcId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get vpc name: %v", err) + } + if vpcLabel == "" { + vpcLabel = model.VpcId + } + + prompt := fmt.Sprintf("Are you sure you want to create the regional configuration %q for VPC %q?", model.Region, vpcLabel) + if err := params.Printer.PromptForConfirmation(prompt); err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient.DefaultAPI) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create vpc region: %w", err) + } + if !model.Async { + err := spinner.Run(params.Printer, "Create vpc region", func() error { + resp, err = wait.CreateVPCRegionWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.VpcId, model.Region).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for vpc region creation: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, model.Region, vpcLabel, resp) + }, + } + configureFlags(cmd) + + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().StringSlice(ipv4DefaultNameserversFlag, nil, "List of default DNS name server IPs") + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, vpcIdFlag)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + IPv4DefaultNameservers: flags.FlagToStringSliceValue(p, cmd, ipv4DefaultNameserversFlag), + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiCreateVPCRegionRequest { + payload := iaas.CreateVPCRegionPayload{} + if model.IPv4DefaultNameservers != nil { + payload.Ipv4 = &iaas.RegionalVPCIPv4{ + DefaultNameservers: model.IPv4DefaultNameservers, + } + } + + return apiClient.CreateVPCRegion(ctx, model.ProjectId, model.VpcId, model.Region).CreateVPCRegionPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, async bool, region, vpcLabel string, resp *iaas.RegionalVPC) error { + if resp == nil { + return fmt.Errorf("create vpc region response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + operationState := "Created" + if async { + operationState = "Triggered creation of" + } + + p.Outputf("%s region configuration for VPC %q.\nRegion: %s\n", operationState, vpcLabel, region) + + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/region/create/create_test.go b/internal/cmd/alpha/vpc/region/create/create_test.go new file mode 100644 index 000000000..35655b66f --- /dev/null +++ b/internal/cmd/alpha/vpc/region/create/create_test.go @@ -0,0 +1,246 @@ +package create + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var ( + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() +) + +func TestParseInput(t *testing.T) { + for _, tt := range []struct { + name string + flag string + value string + remove bool + args []string + valid bool + nameservers []string + }{ + { + name: "defaults", + valid: true, + }, + { + name: "nameservers", + flag: ipv4DefaultNameserversFlag, + value: "8.8.8.8,8.8.4.4", + valid: true, + nameservers: []string{"8.8.8.8", "8.8.4.4"}, + }, + { + name: "empty nameservers", + flag: ipv4DefaultNameserversFlag, + valid: true, + nameservers: []string{}, + }, + { + name: "missing project", + flag: globalflags.ProjectIdFlag, + remove: true, + }, + { + name: "invalid project", + flag: globalflags.ProjectIdFlag, + value: "invalid", + }, + { + name: "missing region", + flag: globalflags.RegionFlag, + remove: true, + }, + { + name: "missing vpc", + flag: vpcIdFlag, + remove: true, + }, + { + name: "invalid vpc", + flag: vpcIdFlag, + value: "invalid", + }, + { + name: "empty vpc", + flag: vpcIdFlag, + }, + { + name: "unexpected argument", + args: []string{"unexpected"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: "eu02", + vpcIdFlag: testVpcId, + } + if tt.flag != "" { + flagValues[tt.flag] = tt.value + if tt.remove { + delete(flagValues, tt.flag) + } + } + + var expected *inputModel + if tt.valid { + expected = &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + IPv4DefaultNameservers: tt.nameservers, + } + } + + testutils.TestParseInput(t, NewCmd, parseInput, expected, tt.args, flagValues, tt.valid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + buildRequestInputModelDefault := func(opts ...func(m *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + }, + VpcId: testVpcId, + IPv4DefaultNameservers: []string{"8.8.8.8", "8.8.4.4"}, + } + + for _, opt := range opts { + opt(model) + } + + return model + } + + buildRequestPayloadDefault := func(opts ...func(p *iaas.CreateVPCRegionPayload)) iaas.CreateVPCRegionPayload { + payload := iaas.CreateVPCRegionPayload{ + Ipv4: &iaas.RegionalVPCIPv4{ + DefaultNameservers: []string{"8.8.8.8", "8.8.4.4"}, + }, + } + + for _, opt := range opts { + opt(&payload) + } + + return payload + } + + tests := []struct { + description string + inputModel *inputModel + wantPayload iaas.CreateVPCRegionPayload + }{ + { + description: "default", + inputModel: buildRequestInputModelDefault(), + wantPayload: buildRequestPayloadDefault(), + }, + { + description: "nameservers is empty", + inputModel: buildRequestInputModelDefault(func(m *inputModel) { + m.IPv4DefaultNameservers = []string{} + }), + wantPayload: buildRequestPayloadDefault(func(p *iaas.CreateVPCRegionPayload) { + p.Ipv4.DefaultNameservers = []string{} + }), + }, + { + description: "nameservers is nil", + inputModel: buildRequestInputModelDefault(func(m *inputModel) { + m.IPv4DefaultNameservers = nil + }), + wantPayload: buildRequestPayloadDefault(func(p *iaas.CreateVPCRegionPayload) { + p.Ipv4 = nil + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + ctx := context.Background() + client := &iaas.DefaultAPIServiceMock{} + + want := client.CreateVPCRegion(ctx, testProjectId, testVpcId, "eu02").CreateVPCRegionPayload(tt.wantPayload) + got := buildRequest(ctx, tt.inputModel, client) + if diff := cmp.Diff(want, got, cmp.AllowUnexported(want), cmpopts.EquateComparable(ctx, iaas.DefaultAPIService{})); diff != "" { + t.Fatalf("request mismatch (-want +got): %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + for _, tt := range []struct { + name string + format string + async bool + resp *iaas.RegionalVPC + want string + wantErr bool + }{ + { + name: "nil", + wantErr: true, + }, + { + name: "created", + resp: &iaas.RegionalVPC{}, + want: "Created region configuration", + }, + { + name: "async", + async: true, + resp: &iaas.RegionalVPC{}, + want: "Triggered creation of", + }, + { + name: "json", + format: print.JSONOutputFormat, + resp: &iaas.RegionalVPC{ + Status: new("CREATED"), + }, + want: `"status": "CREATED"`, + }, + { + name: "yaml", + format: print.YAMLOutputFormat, + resp: &iaas.RegionalVPC{ + Status: new("CREATED"), + }, + want: "status: CREATED", + }, + } { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + + err := outputResult(params.Printer, tt.format, tt.async, "eu02", "my-vpc", tt.resp) + if (err != nil) != tt.wantErr { + t.Fatalf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + + if !strings.Contains(params.Out.String(), tt.want) { + t.Errorf("output %q does not contain %q", params.Out.String(), tt.want) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/region/delete/delete.go b/internal/cmd/alpha/vpc/region/delete/delete.go new file mode 100644 index 000000000..9b52e7d84 --- /dev/null +++ b/internal/cmd/alpha/vpc/region/delete/delete.go @@ -0,0 +1,128 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const vpcIdFlag = "vpc-id" + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Deletes a regional configuration for a VPC", + Long: "Deletes a regional configuration for a VPC.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Delete the regional configuration "eu02" for a VPC with ID "xxx"`, + `$ stackit alpha vpc region delete --vpc-id xxx --region eu02`, + ), + examples.NewExample( + `Delete a regional configuration using the configured project and region`, + `$ stackit alpha vpc region delete --vpc-id xxx`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + vpcLabel, err := iaasUtils.GetVPCName(ctx, apiClient.DefaultAPI, model.ProjectId, model.VpcId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get vpc name: %v", err) + } + if vpcLabel == "" { + vpcLabel = model.VpcId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the regional configuration %q for VPC %q?", model.Region, vpcLabel) + if err := params.Printer.PromptForConfirmation(prompt); err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient.DefaultAPI) + + if err := req.Execute(); err != nil { + return fmt.Errorf("delete vpc region: %w", err) + } + + if !model.Async { + err := spinner.Run(params.Printer, "Delete vpc region", func() error { + _, err := wait.DeleteVPCRegionWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.VpcId, model.Region).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for vpc region deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + + params.Printer.Outputf("%s region configuration for VPC %q.\nRegion: %s\n", operationState, vpcLabel, model.Region) + + return nil + }, + } + configureFlags(cmd) + + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, vpcIdFlag)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiDeleteVPCRegionRequest { + return apiClient.DeleteVPCRegion(ctx, model.ProjectId, model.VpcId, model.Region) +} diff --git a/internal/cmd/alpha/vpc/region/delete/delete_test.go b/internal/cmd/alpha/vpc/region/delete/delete_test.go new file mode 100644 index 000000000..43e07fb25 --- /dev/null +++ b/internal/cmd/alpha/vpc/region/delete/delete_test.go @@ -0,0 +1,116 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var ( + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() +) + +func TestParseInput(t *testing.T) { + for _, tt := range []struct { + name string + flag string + value string + remove bool + args []string + valid bool + }{ + { + name: "base", + valid: true, + }, + { + name: "missing project", + flag: globalflags.ProjectIdFlag, + remove: true, + }, + { + name: "invalid project", + flag: globalflags.ProjectIdFlag, + value: "invalid", + }, + { + name: "missing region", + flag: globalflags.RegionFlag, + remove: true, + }, + { + name: "missing vpc", + flag: vpcIdFlag, + remove: true, + }, + { + name: "invalid vpc", + flag: vpcIdFlag, + value: "invalid", + }, + { + name: "empty vpc", + flag: vpcIdFlag, + }, + { + name: "unexpected argument", + args: []string{"unexpected"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: "eu02", + vpcIdFlag: testVpcId, + } + if tt.flag != "" { + flagValues[tt.flag] = tt.value + if tt.remove { + delete(flagValues, tt.flag) + } + } + + var expected *inputModel + if tt.valid { + expected = &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + } + } + + testutils.TestParseInput(t, NewCmd, parseInput, expected, tt.args, flagValues, tt.valid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + ctx := context.Background() + client := &iaas.APIClient{ + DefaultAPI: &iaas.DefaultAPIService{}, + } + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + }, + VpcId: testVpcId, + } + + want := client.DefaultAPI.DeleteVPCRegion(ctx, testProjectId, testVpcId, "eu02") + got := buildRequest(ctx, model, client.DefaultAPI) + if diff := cmp.Diff(want, got, cmp.AllowUnexported(want), cmpopts.EquateComparable(ctx, iaas.DefaultAPIService{})); diff != "" { + t.Fatalf("request mismatch (-want +got): %s", diff) + } +} diff --git a/internal/cmd/alpha/vpc/region/describe/describe.go b/internal/cmd/alpha/vpc/region/describe/describe.go new file mode 100644 index 000000000..80b02573a --- /dev/null +++ b/internal/cmd/alpha/vpc/region/describe/describe.go @@ -0,0 +1,135 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const vpcIdFlag = "vpc-id" + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describes a regional configuration for a VPC", + Long: "Describes a regional configuration for a VPC.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Describe the regional configuration "eu02" for a VPC with ID "xxx"`, + `$ stackit alpha vpc region describe --vpc-id xxx --region eu02`, + ), + examples.NewExample( + `Describe a regional configuration in JSON format, using the configured project and region`, + `$ stackit alpha vpc region describe --vpc-id xxx --output-format json`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + vpcName, err := iaasUtils.GetVPCName(ctx, apiClient.DefaultAPI, model.ProjectId, model.VpcId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get vpc name: %v", err) + vpcName = "" + } + + req := buildRequest(ctx, model, apiClient.DefaultAPI) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe vpc region: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.Region, model.VpcId, vpcName, resp) + }, + } + configureFlags(cmd) + + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, vpcIdFlag)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiGetVPCRegionRequest { + return apiClient.GetVPCRegion(ctx, model.ProjectId, model.VpcId, model.Region) +} + +func outputResult(p *print.Printer, outputFormat, region, vpcId, vpcName string, resp *iaas.RegionalVPC) error { + if resp == nil { + return fmt.Errorf("describe vpc region response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + table := tables.NewTable() + table.AddRow("ID", vpcId) + table.AddSeparator() + if vpcName != "" { + table.AddRow("NAME", vpcName) + table.AddSeparator() + } + table.AddRow("REGION", region) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(resp.Status)) + if ipv4 := resp.Ipv4; ipv4 != nil { + table.AddSeparator() + table.AddRow("DNS NAME SERVERS", strings.Join(ipv4.DefaultNameservers, ",")) + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/region/describe/describe_test.go b/internal/cmd/alpha/vpc/region/describe/describe_test.go new file mode 100644 index 000000000..5d4740219 --- /dev/null +++ b/internal/cmd/alpha/vpc/region/describe/describe_test.go @@ -0,0 +1,196 @@ +package describe + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var ( + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() +) + +func TestParseInput(t *testing.T) { + for _, tt := range []struct { + name string + flag string + value string + remove bool + args []string + valid bool + }{ + { + name: "base", + valid: true, + }, + { + name: "missing project", + flag: globalflags.ProjectIdFlag, + remove: true, + }, + { + name: "invalid project", + flag: globalflags.ProjectIdFlag, + value: "invalid", + }, + { + name: "missing region", + flag: globalflags.RegionFlag, + remove: true, + }, + { + name: "missing vpc", + flag: vpcIdFlag, + remove: true, + }, + { + name: "invalid vpc", + flag: vpcIdFlag, + value: "invalid", + }, + { + name: "empty vpc", + flag: vpcIdFlag, + }, + { + name: "unexpected argument", + args: []string{"unexpected"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: "eu02", + vpcIdFlag: testVpcId, + } + if tt.flag != "" { + flagValues[tt.flag] = tt.value + if tt.remove { + delete(flagValues, tt.flag) + } + } + + var expected *inputModel + if tt.valid { + expected = &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + } + } + + testutils.TestParseInput(t, NewCmd, parseInput, expected, tt.args, flagValues, tt.valid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + ctx := context.Background() + client := &iaas.DefaultAPIServiceMock{} + + tests := []struct { + description string + inputModel *inputModel + want iaas.ApiGetVPCRegionRequest + }{ + { + description: "default", + inputModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + }, + VpcId: testVpcId, + }, + want: client.GetVPCRegion(ctx, testProjectId, testVpcId, "eu02"), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(ctx, tt.inputModel, client) + if diff := cmp.Diff(tt.want, got, cmp.AllowUnexported(tt.want), cmpopts.EquateComparable(ctx, iaas.DefaultAPIService{})); diff != "" { + t.Fatalf("request mismatch (-want +got): %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + resp := &iaas.RegionalVPC{ + Status: new("CREATED"), + Ipv4: &iaas.RegionalVPCIPv4{ + DefaultNameservers: []string{"8.8.8.8", "8.8.4.4"}, + }, + } + + for _, tt := range []struct { + name string + format string + vpcName string + resp *iaas.RegionalVPC + want []string + wantErr bool + }{ + { + name: "nil", + wantErr: true, + }, + { + name: "empty", + resp: &iaas.RegionalVPC{}, + want: []string{testVpcId, "eu02"}, + }, + { + name: "table", + vpcName: "my-vpc", + resp: resp, + want: []string{testVpcId, "my-vpc", "eu02", "CREATED", "8.8.8.8,8.8.4.4"}, + }, + { + name: "json", + format: print.JSONOutputFormat, + resp: resp, + want: []string{`"status": "CREATED"`, `"defaultNameservers"`}, + }, + { + name: "yaml", + format: print.YAMLOutputFormat, + resp: resp, + want: []string{"status: CREATED", "defaultNameservers:"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + + err := outputResult(params.Printer, tt.format, "eu02", testVpcId, tt.vpcName, tt.resp) + if (err != nil) != tt.wantErr { + t.Fatalf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + + for _, want := range tt.want { + if !strings.Contains(params.Out.String(), want) { + t.Errorf("output %q does not contain %q", params.Out.String(), want) + } + } + + if tt.format == "" && tt.vpcName == "" && strings.Contains(params.Out.String(), "NAME") { + t.Errorf("unexpected name row: %s", params.Out.String()) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/region/list/list.go b/internal/cmd/alpha/vpc/region/list/list.go new file mode 100644 index 000000000..61b9cf09c --- /dev/null +++ b/internal/cmd/alpha/vpc/region/list/list.go @@ -0,0 +1,140 @@ +package list + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + + "github.com/spf13/cobra" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const vpcIdFlag = "vpc-id" + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all configured regions for a VPC", + Long: "Lists all configured regions for a VPC.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all configured regions for a VPC with ID "xxx"`, + `$ stackit alpha vpc region list --vpc-id xxx`, + ), + examples.NewExample( + `List all configured regions for a VPC in JSON format`, + `$ stackit alpha vpc region list --vpc-id xxx --output-format json`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + vpcLabel, err := iaasUtils.GetVPCName(ctx, apiClient.DefaultAPI, model.ProjectId, model.VpcId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get vpc name: %v", err) + } + if vpcLabel == "" { + vpcLabel = model.VpcId + } + + req := buildRequest(ctx, model, apiClient.DefaultAPI) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list vpc regions: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, vpcLabel, resp) + }, + } + configureFlags(cmd) + + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, vpcIdFlag)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiListVPCRegionsRequest { + return apiClient.ListVPCRegions(ctx, model.ProjectId, model.VpcId) +} + +func outputResult(p *print.Printer, outputFormat, vpcLabel string, resp *iaas.RegionalVPCList) error { + if resp == nil { + return fmt.Errorf("list vpc regions response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + if len(resp.Regions) == 0 { + p.Outputf("No regions found for VPC %q\n", vpcLabel) + return nil + } + + regionIds := slices.Sorted(maps.Keys(resp.Regions)) + + table := tables.NewTable() + table.SetHeader("REGION", "STATUS", "DNS NAME SERVERS") + for _, region := range regionIds { + regionConfig := resp.Regions[region] + var dnsNames string + if ipv4 := regionConfig.Ipv4; ipv4 != nil { + dnsNames = strings.Join(ipv4.DefaultNameservers, ",") + } + + table.AddRow(region, utils.PtrString(regionConfig.Status), dnsNames) + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/region/list/list_test.go b/internal/cmd/alpha/vpc/region/list/list_test.go new file mode 100644 index 000000000..e5fee6961 --- /dev/null +++ b/internal/cmd/alpha/vpc/region/list/list_test.go @@ -0,0 +1,199 @@ +package list + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var ( + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() +) + +func TestParseInput(t *testing.T) { + for _, tt := range []struct { + name string + flag string + value string + remove bool + args []string + valid bool + }{ + { + name: "no region required", + valid: true, + }, + { + name: "missing project", + flag: globalflags.ProjectIdFlag, + remove: true, + }, + { + name: "invalid project", + flag: globalflags.ProjectIdFlag, + value: "invalid", + }, + { + name: "missing vpc", + flag: vpcIdFlag, + remove: true, + }, + { + name: "invalid vpc", + flag: vpcIdFlag, + value: "invalid", + }, + { + name: "empty vpc", + flag: vpcIdFlag, + }, + { + name: "unexpected argument", + args: []string{"unexpected"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + vpcIdFlag: testVpcId, + } + if tt.flag != "" { + flagValues[tt.flag] = tt.value + if tt.remove { + delete(flagValues, tt.flag) + } + } + + var expected *inputModel + if tt.valid { + expected = &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + } + } + + testutils.TestParseInput(t, NewCmd, parseInput, expected, tt.args, flagValues, tt.valid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + ctx := context.Background() + client := &iaas.DefaultAPIServiceMock{} + + tests := []struct { + description string + inputModel *inputModel + want iaas.ApiListVPCRegionsRequest + }{ + { + description: "default", + inputModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + VpcId: testVpcId, + }, + want: client.ListVPCRegions(ctx, testProjectId, testVpcId), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(ctx, tt.inputModel, client) + if diff := cmp.Diff(tt.want, got, cmp.AllowUnexported(tt.want), cmpopts.EquateComparable(ctx, iaas.DefaultAPIService{})); diff != "" { + t.Fatalf("request mismatch (-want +got): %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + resp := &iaas.RegionalVPCList{ + Regions: map[string]iaas.RegionalVPC{ + "eu02": { + Status: new("CREATED"), + Ipv4: &iaas.RegionalVPCIPv4{ + DefaultNameservers: []string{"8.8.8.8"}, + }, + }, + "eu01": {}, + }, + } + + for _, tt := range []struct { + name string + format string + resp *iaas.RegionalVPCList + want []string + wantErr bool + }{ + { + name: "nil", + wantErr: true, + }, + { + name: "empty", + resp: &iaas.RegionalVPCList{}, + want: []string{`No regions found for VPC "my-vpc"`}, + }, + { + name: "table", + resp: resp, + want: []string{"eu01", "eu02", "CREATED", "8.8.8.8"}, + }, + { + name: "json", + format: print.JSONOutputFormat, + resp: resp, + want: []string{`"regions"`, `"eu02"`, `"status": "CREATED"`}, + }, + { + name: "yaml", + format: print.YAMLOutputFormat, + resp: resp, + want: []string{"regions:", "eu02:", "status: CREATED"}, + }, + { + name: "empty json", + format: print.JSONOutputFormat, + resp: &iaas.RegionalVPCList{ + Regions: map[string]iaas.RegionalVPC{}, + }, + want: []string{`"regions": {}`}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + + err := outputResult(params.Printer, tt.format, "my-vpc", tt.resp) + if (err != nil) != tt.wantErr { + t.Fatalf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + + for _, want := range tt.want { + if !strings.Contains(params.Out.String(), want) { + t.Errorf("output %q does not contain %q", params.Out.String(), want) + } + } + + if tt.name == "table" && strings.Index(params.Out.String(), "eu01") > strings.Index(params.Out.String(), "eu02") { + t.Errorf("regions are not sorted: %s", params.Out.String()) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/region/region.go b/internal/cmd/alpha/vpc/region/region.go new file mode 100644 index 000000000..59a641180 --- /dev/null +++ b/internal/cmd/alpha/vpc/region/region.go @@ -0,0 +1,35 @@ +package region + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/region/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/region/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/region/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/region/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/region/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "region", + Short: "Manages regional configurations of a VPC", + Long: "Manages regional configurations of a VPC.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) +} diff --git a/internal/cmd/alpha/vpc/region/update/update.go b/internal/cmd/alpha/vpc/region/update/update.go new file mode 100644 index 000000000..e83950bff --- /dev/null +++ b/internal/cmd/alpha/vpc/region/update/update.go @@ -0,0 +1,134 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaasalpha/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + vpcIdFlag = "vpc-id" + ipv4DefaultNameserversFlag = "ipv4-default-nameservers" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string + IPv4DefaultNameservers []string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates a regional configuration for a VPC", + Long: "Updates a regional configuration for a VPC.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Update the default DNS name servers for a VPC with ID "xxx" in region "eu02"`, + `$ stackit alpha vpc region update --vpc-id xxx --region eu02 --ipv4-default-nameservers 8.8.8.8,8.8.4.4`, + ), + examples.NewExample( + `Clear the default DNS name servers, using the configured project and region`, + `$ stackit alpha vpc region update --vpc-id xxx --ipv4-default-nameservers ""`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + vpcLabel, err := iaasUtils.GetVPCName(ctx, apiClient.DefaultAPI, model.ProjectId, model.VpcId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get vpc name: %v", err) + } + if vpcLabel == "" { + vpcLabel = model.VpcId + } + + prompt := fmt.Sprintf("Are you sure you want to update the regional configuration %q for VPC %q?", model.Region, vpcLabel) + if err := params.Printer.PromptForConfirmation(prompt); err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient.DefaultAPI) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update vpc region: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.Region, vpcLabel, resp) + }, + } + configureFlags(cmd) + + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().StringSlice(ipv4DefaultNameserversFlag, nil, "List of default DNS name server IPs") + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, vpcIdFlag, ipv4DefaultNameserversFlag)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + IPv4DefaultNameservers: flags.FlagToStringSliceValue(p, cmd, ipv4DefaultNameserversFlag), + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiUpdateVPCRegionRequest { + payload := iaas.UpdateVPCRegionPayload{ + Ipv4: &iaas.RegionalVPCIPv4{ + DefaultNameservers: model.IPv4DefaultNameservers, + }, + } + + return apiClient.UpdateVPCRegion(ctx, model.ProjectId, model.VpcId, model.Region).UpdateVPCRegionPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat, region, vpcLabel string, resp *iaas.RegionalVPC) error { + if resp == nil { + return fmt.Errorf("update vpc region response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Updated region configuration for VPC %q.\nRegion: %s\n", vpcLabel, region) + + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/region/update/update_test.go b/internal/cmd/alpha/vpc/region/update/update_test.go new file mode 100644 index 000000000..58195deaa --- /dev/null +++ b/internal/cmd/alpha/vpc/region/update/update_test.go @@ -0,0 +1,232 @@ +package update + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var ( + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() +) + +func TestParseInput(t *testing.T) { + for _, tt := range []struct { + name string + flag string + value string + remove bool + args []string + valid bool + clear bool + }{ + { + name: "nameservers", + valid: true, + }, + { + name: "clear nameservers", + flag: ipv4DefaultNameserversFlag, + valid: true, + clear: true, + }, + { + name: "nothing to update", + flag: ipv4DefaultNameserversFlag, + remove: true, + }, + { + name: "missing project", + flag: globalflags.ProjectIdFlag, + remove: true, + }, + { + name: "invalid project", + flag: globalflags.ProjectIdFlag, + value: "invalid", + }, + { + name: "missing region", + flag: globalflags.RegionFlag, + remove: true, + }, + { + name: "missing vpc", + flag: vpcIdFlag, + remove: true, + }, + { + name: "invalid vpc", + flag: vpcIdFlag, + value: "invalid", + }, + { + name: "empty vpc", + flag: vpcIdFlag, + }, + { + name: "unexpected argument", + args: []string{"unexpected"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: "eu02", + vpcIdFlag: testVpcId, + ipv4DefaultNameserversFlag: "8.8.8.8,8.8.4.4", + } + if tt.flag != "" { + flagValues[tt.flag] = tt.value + if tt.remove { + delete(flagValues, tt.flag) + } + } + + var expected *inputModel + if tt.valid { + expected = &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + IPv4DefaultNameservers: []string{"8.8.8.8", "8.8.4.4"}, + } + if tt.clear { + expected.IPv4DefaultNameservers = []string{} + } + } + + testutils.TestParseInput(t, NewCmd, parseInput, expected, tt.args, flagValues, tt.valid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + buildRequestInputModelDefault := func(opts ...func(m *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + }, + VpcId: testVpcId, + IPv4DefaultNameservers: []string{"8.8.8.8", "8.8.4.4"}, + } + + for _, opt := range opts { + opt(model) + } + + return model + } + + buildRequestPayloadDefault := func(opts ...func(p *iaas.UpdateVPCRegionPayload)) iaas.UpdateVPCRegionPayload { + payload := iaas.UpdateVPCRegionPayload{ + Ipv4: &iaas.RegionalVPCIPv4{ + DefaultNameservers: []string{"8.8.8.8", "8.8.4.4"}, + }, + } + + for _, opt := range opts { + opt(&payload) + } + + return payload + } + + tests := []struct { + description string + inputModel *inputModel + wantPayload iaas.UpdateVPCRegionPayload + }{ + { + description: "default", + inputModel: buildRequestInputModelDefault(), + wantPayload: buildRequestPayloadDefault(), + }, + { + description: "nameservers is empty", + inputModel: buildRequestInputModelDefault(func(m *inputModel) { + m.IPv4DefaultNameservers = []string{} + }), + wantPayload: buildRequestPayloadDefault(func(p *iaas.UpdateVPCRegionPayload) { + p.Ipv4.DefaultNameservers = []string{} + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + ctx := context.Background() + client := &iaas.DefaultAPIServiceMock{} + + want := client.UpdateVPCRegion(ctx, testProjectId, testVpcId, "eu02").UpdateVPCRegionPayload(tt.wantPayload) + got := buildRequest(ctx, tt.inputModel, client) + if diff := cmp.Diff(want, got, cmp.AllowUnexported(want), cmpopts.EquateComparable(ctx, iaas.DefaultAPIService{})); diff != "" { + t.Fatalf("request mismatch (-want +got): %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + for _, tt := range []struct { + name string + format string + resp *iaas.RegionalVPC + want string + wantErr bool + }{ + { + name: "nil", + wantErr: true, + }, + { + name: "updated", + resp: &iaas.RegionalVPC{}, + want: "Updated region configuration", + }, + { + name: "json", + format: print.JSONOutputFormat, + resp: &iaas.RegionalVPC{ + Status: new("UPDATED"), + }, + want: `"status": "UPDATED"`, + }, + { + name: "yaml", + format: print.YAMLOutputFormat, + resp: &iaas.RegionalVPC{ + Status: new("UPDATED"), + }, + want: "status: UPDATED", + }, + } { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + + err := outputResult(params.Printer, tt.format, "eu02", "my-vpc", tt.resp) + if (err != nil) != tt.wantErr { + t.Fatalf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + + if !strings.Contains(params.Out.String(), tt.want) { + t.Errorf("output %q does not contain %q", params.Out.String(), tt.want) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/vpc.go b/internal/cmd/alpha/vpc/vpc.go index a9209b56c..ff311d4fe 100644 --- a/internal/cmd/alpha/vpc/vpc.go +++ b/internal/cmd/alpha/vpc/vpc.go @@ -7,6 +7,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/region" "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" @@ -31,4 +32,5 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(describe.NewCmd(params)) cmd.AddCommand(list.NewCmd(params)) cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(region.NewCmd(params)) }