diff --git a/docs/stackit_alpha_vpc.md b/docs/stackit_alpha_vpc.md index 7dcf577eb..6a7e8c82f 100644 --- a/docs/stackit_alpha_vpc.md +++ b/docs/stackit_alpha_vpc.md @@ -36,5 +36,6 @@ stackit alpha vpc [flags] * [stackit alpha vpc list](./stackit_alpha_vpc_list.md) - Lists all VPC of a project * [stackit alpha vpc network-range](./stackit_alpha_vpc_network-range.md) - Provides functionality for network ranges in VPC * [stackit alpha vpc region](./stackit_alpha_vpc_region.md) - Manages regional configurations of a VPC +* [stackit alpha vpc routing-table](./stackit_alpha_vpc_routing-table.md) - Manages VPC routing-tables * [stackit alpha vpc update](./stackit_alpha_vpc_update.md) - Updates a VPC diff --git a/docs/stackit_alpha_vpc_routing-table.md b/docs/stackit_alpha_vpc_routing-table.md new file mode 100644 index 000000000..ce4e664cc --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table.md @@ -0,0 +1,38 @@ +## stackit alpha vpc routing-table + +Manages VPC routing-tables + +### Synopsis + +Manages routing-tables for a VPC in a region. + +``` +stackit alpha vpc routing-table [flags] +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc routing-table" +``` + +### 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 routing-table create](./stackit_alpha_vpc_routing-table_create.md) - Creates a VPC routing-table +* [stackit alpha vpc routing-table delete](./stackit_alpha_vpc_routing-table_delete.md) - Deletes a VPC routing-table +* [stackit alpha vpc routing-table describe](./stackit_alpha_vpc_routing-table_describe.md) - Describes a VPC routing-table +* [stackit alpha vpc routing-table list](./stackit_alpha_vpc_routing-table_list.md) - Lists VPC routing-tables +* [stackit alpha vpc routing-table update](./stackit_alpha_vpc_routing-table_update.md) - Updates a VPC routing-table + diff --git a/docs/stackit_alpha_vpc_routing-table_create.md b/docs/stackit_alpha_vpc_routing-table_create.md new file mode 100644 index 000000000..82d701c14 --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_create.md @@ -0,0 +1,49 @@ +## stackit alpha vpc routing-table create + +Creates a VPC routing-table + +### Synopsis + +Creates a routing-table for a VPC in a region. VPC can only be used in projects with enabled VPC. + +``` +stackit alpha vpc routing-table create [flags] +``` + +### Examples + +``` + Create a routing-table with name "rt" for VPC "xxx" in region "eu02" + $ stackit alpha vpc routing-table create --vpc-id xxx --region eu02 --name rt + + Create a routing-table with labels and dynamic routes disabled, using the configured project and region + $ stackit alpha vpc routing-table create --vpc-id xxx --name rt --labels env=dev --dynamic-routes=false +``` + +### Options + +``` + --description string Description of the routing-table + --dynamic-routes If set to false, prevents dynamic routes from propagating to the routing-table (default true) + -h, --help Help for "stackit alpha vpc routing-table create" + --labels stringToString Key=value labels (default []) + --name string Name of the routing-table + --system-routes If set to false, disables automatic system routes (default true) + --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 routing-table](./stackit_alpha_vpc_routing-table.md) - Manages VPC routing-tables + diff --git a/docs/stackit_alpha_vpc_routing-table_delete.md b/docs/stackit_alpha_vpc_routing-table_delete.md new file mode 100644 index 000000000..6f6d19ecf --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_delete.md @@ -0,0 +1,41 @@ +## stackit alpha vpc routing-table delete + +Deletes a VPC routing-table + +### Synopsis + +Deletes a routing-table for a VPC in a region. + +``` +stackit alpha vpc routing-table delete ROUTING_TABLE_ID [flags] +``` + +### Examples + +``` + Delete routing-table "xxx" for VPC "yyy" in region "eu02" + $ stackit alpha vpc routing-table delete xxx --vpc-id yyy --region eu02 +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc routing-table 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 routing-table](./stackit_alpha_vpc_routing-table.md) - Manages VPC routing-tables + diff --git a/docs/stackit_alpha_vpc_routing-table_describe.md b/docs/stackit_alpha_vpc_routing-table_describe.md new file mode 100644 index 000000000..13363a505 --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_describe.md @@ -0,0 +1,44 @@ +## stackit alpha vpc routing-table describe + +Describes a VPC routing-table + +### Synopsis + +Describes a routing-table for a VPC in a region. + +``` +stackit alpha vpc routing-table describe ROUTING_TABLE_ID [flags] +``` + +### Examples + +``` + Describe routing-table "xxx" for VPC "yyy" in region "eu02" + $ stackit alpha vpc routing-table describe xxx --vpc-id yyy --region eu02 + + Describe a routing-table in JSON format, using the configured project and region + $ stackit alpha vpc routing-table describe xxx --vpc-id yyy --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc routing-table 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 routing-table](./stackit_alpha_vpc_routing-table.md) - Manages VPC routing-tables + diff --git a/docs/stackit_alpha_vpc_routing-table_list.md b/docs/stackit_alpha_vpc_routing-table_list.md new file mode 100644 index 000000000..b6b7aaf9c --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_list.md @@ -0,0 +1,47 @@ +## stackit alpha vpc routing-table list + +Lists VPC routing-tables + +### Synopsis + +Lists routing-tables for a VPC in a region. + +``` +stackit alpha vpc routing-table list [flags] +``` + +### Examples + +``` + List routing-tables for VPC "xxx" in region "eu02" + $ stackit alpha vpc routing-table list --vpc-id xxx --region eu02 + + List up to ten routing-tables with labels, using the configured project and region + $ stackit alpha vpc routing-table list --vpc-id xxx --label-selector env=dev --limit 10 +``` + +### Options + +``` + --filter string Filter resources by fields. A subset of expr-lang is supported. See https://expr-lang.org/docs/language-definition for usage details + -h, --help Help for "stackit alpha vpc routing-table list" + --label-selector string Filter by label + --limit int Maximum number of entries to 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 routing-table](./stackit_alpha_vpc_routing-table.md) - Manages VPC routing-tables + diff --git a/docs/stackit_alpha_vpc_routing-table_update.md b/docs/stackit_alpha_vpc_routing-table_update.md new file mode 100644 index 000000000..56291df75 --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_update.md @@ -0,0 +1,49 @@ +## stackit alpha vpc routing-table update + +Updates a VPC routing-table + +### Synopsis + +Updates a routing-table for a VPC in a region. + +``` +stackit alpha vpc routing-table update ROUTING_TABLE_ID [flags] +``` + +### Examples + +``` + Update the name of routing-table "xxx" for VPC "yyy" in region "eu02" + $ stackit alpha vpc routing-table update xxx --vpc-id yyy --region eu02 --name rt + + Disable dynamic routes, using the configured project and region + $ stackit alpha vpc routing-table update xxx --vpc-id yyy --dynamic-routes=false +``` + +### Options + +``` + --description string Description of the routing-table + --dynamic-routes If set to false, prevents dynamic routes from propagating to the routing-table + -h, --help Help for "stackit alpha vpc routing-table update" + --labels stringToString Key=value labels (default []) + --name string Name of the routing-table + --system-routes If set to false, disables automatic system routes + --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 routing-table](./stackit_alpha_vpc_routing-table.md) - Manages VPC routing-tables + diff --git a/internal/cmd/alpha/vpc/routingtable/create/create.go b/internal/cmd/alpha/vpc/routingtable/create/create.go new file mode 100644 index 000000000..5166e209b --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/create/create.go @@ -0,0 +1,153 @@ +package create + +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" + nameFlag = "name" + descriptionFlag = "description" + labelFlag = "labels" + dynamicRoutesFlag = "dynamic-routes" + systemRoutesFlag = "system-routes" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string + Name string + Description *string + Labels map[string]any + DynamicRoutes bool + SystemRoutes bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a VPC routing-table", + Long: "Creates a routing-table for a VPC in a region. VPC can only be used in projects with enabled VPC.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a routing-table with name "rt" for VPC "xxx" in region "eu02"`, + `$ stackit alpha vpc routing-table create --vpc-id xxx --region eu02 --name rt`, + ), + examples.NewExample( + `Create a routing-table with labels and dynamic routes disabled, using the configured project and region`, + `$ stackit alpha vpc routing-table create --vpc-id xxx --name rt --labels env=dev --dynamic-routes=false`, + ), + ), + 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) + vpcLabel = model.VpcId + } else if vpcLabel == "" { + vpcLabel = model.VpcId + } + + prompt := fmt.Sprintf("Are you sure you want to create the routing-table %q for VPC %q in region %q?", model.Name, vpcLabel, model.Region) + 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 routing-table: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().String(nameFlag, "", "Name of the routing-table") + cmd.Flags().String(descriptionFlag, "", "Description of the routing-table") + cmd.Flags().StringToString(labelFlag, nil, "Key=value labels") + cmd.Flags().Bool(dynamicRoutesFlag, true, "If set to false, prevents dynamic routes from propagating to the routing-table") + cmd.Flags().Bool(systemRoutesFlag, true, "If set to false, disables automatic system routes") + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, vpcIdFlag, nameFlag)) +} + +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), + Name: flags.FlagToStringValue(p, cmd, nameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToAny(p, cmd, labelFlag), + DynamicRoutes: flags.FlagToBoolValue(p, cmd, dynamicRoutesFlag), + SystemRoutes: flags.FlagToBoolValue(p, cmd, systemRoutesFlag), + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiAddVPCRoutingTableRequest { + payload := iaas.AddVPCRoutingTablePayload{ + Name: model.Name, + Description: model.Description, + Labels: model.Labels, + DynamicRoutes: new(model.DynamicRoutes), + SystemRoutes: new(model.SystemRoutes), + } + + return apiClient.AddVPCRoutingTable(ctx, model.ProjectId, model.VpcId, model.Region).AddVPCRoutingTablePayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, resp *iaas.VPCRoutingTable) error { + if resp == nil { + return fmt.Errorf("create vpc routing-table response is nil") + } + if resp.Id == nil || *resp.Id == "" { + return fmt.Errorf("create vpc routing-table id is empty") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Created routing-table with ID %q.\n", *resp.Id) + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/routingtable/create/create_test.go b/internal/cmd/alpha/vpc/routingtable/create/create_test.go new file mode 100644 index 000000000..3bfb99da3 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/create/create_test.go @@ -0,0 +1,331 @@ +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() + testRoutingTableId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: "eu02", + vpcIdFlag: testVpcId, + nameFlag: "rt", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + Name: "rt", + DynamicRoutes: true, + SystemRoutes: true, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixturePayload(mods ...func(*iaas.AddVPCRoutingTablePayload)) iaas.AddVPCRoutingTablePayload { + payload := iaas.AddVPCRoutingTablePayload{ + Name: "rt", + DynamicRoutes: new(true), + SystemRoutes: new(true), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + args []string + flagValues map[string]string + want *inputModel + }{ + { + name: "defaults", + flagValues: fixtureFlagValues(), + want: fixtureInputModel(), + }, + { + name: "description", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[descriptionFlag] = "my routing table" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Description = new("my routing table") + }), + }, + { + name: "empty description", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[descriptionFlag] = "" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Description = new("") + }), + }, + { + name: "labels", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[labelFlag] = "env=dev,team=network" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Labels = map[string]any{ + "env": "dev", + "team": "network", + } + }), + }, + { + name: "empty labels", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[labelFlag] = "" + }), + }, + { + name: "disable dynamic routes", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[dynamicRoutesFlag] = "false" + }), + want: fixtureInputModel(func(m *inputModel) { + m.DynamicRoutes = false + }), + }, + { + name: "disable system routes", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[systemRoutesFlag] = "false" + }), + want: fixtureInputModel(func(m *inputModel) { + m.SystemRoutes = false + }), + }, + { + name: "disable both route types", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[dynamicRoutesFlag] = "false" + f[systemRoutesFlag] = "false" + }), + want: fixtureInputModel(func(m *inputModel) { + m.DynamicRoutes = false + m.SystemRoutes = false + }), + }, + { + name: "missing name", + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, nameFlag) + }), + }, + { + name: "missing project", + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.ProjectIdFlag) + }), + }, + { + name: "invalid project", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[globalflags.ProjectIdFlag] = "invalid" + }), + }, + { + name: "missing region", + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.RegionFlag) + }), + }, + { + name: "missing vpc", + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, vpcIdFlag) + }), + }, + { + name: "invalid vpc", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "invalid" + }), + }, + { + name: "empty vpc", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "" + }), + }, + { + name: "invalid labels", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[labelFlag] = "invalid" + }), + }, + { + name: "invalid boolean", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[dynamicRoutesFlag] = "invalid" + }), + }, + { + name: "unexpected argument", + args: []string{"unexpected"}, + flagValues: fixtureFlagValues(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.want, tt.args, tt.flagValues, tt.want != nil) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + name string + model *inputModel + wantPayload iaas.AddVPCRoutingTablePayload + }{ + { + name: "all fields", + model: fixtureInputModel(func(m *inputModel) { + m.Description = new("description") + m.Labels = map[string]any{"env": "dev"} + }), + wantPayload: fixturePayload(func(p *iaas.AddVPCRoutingTablePayload) { + p.Description = new("description") + p.Labels = map[string]any{"env": "dev"} + }), + }, + { + name: "empty labels", + model: fixtureInputModel(func(m *inputModel) { + m.Labels = map[string]any{} + }), + wantPayload: fixturePayload(func(p *iaas.AddVPCRoutingTablePayload) { + p.Labels = map[string]any{} + }), + }, + { + name: "omitted optional fields", + model: fixtureInputModel(), + wantPayload: fixturePayload(), + }, + { + name: "disabled routes", + model: fixtureInputModel(func(m *inputModel) { + m.DynamicRoutes = false + m.SystemRoutes = false + }), + wantPayload: fixturePayload(func(p *iaas.AddVPCRoutingTablePayload) { + p.DynamicRoutes = new(false) + p.SystemRoutes = new(false) + }), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + client := &iaas.DefaultAPIServiceMock{} + + want := client.AddVPCRoutingTable(ctx, testProjectId, testVpcId, "eu02").AddVPCRoutingTablePayload(tt.wantPayload) + got := buildRequest(ctx, tt.model, 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.VPCRoutingTable + want string + wantErr bool + }{ + { + name: "nil", + wantErr: true, + }, + { + name: "missing id", + resp: &iaas.VPCRoutingTable{}, + wantErr: true, + }, + { + name: "empty id", + resp: &iaas.VPCRoutingTable{ + Id: new(""), + }, + wantErr: true, + }, + { + name: "created", + resp: &iaas.VPCRoutingTable{ + Id: &testRoutingTableId, + }, + want: "Created routing-table with ID \"" + testRoutingTableId + "\".", + }, + { + name: "json", + format: print.JSONOutputFormat, + resp: &iaas.VPCRoutingTable{ + Id: &testRoutingTableId, + Name: "rt", + }, + want: `"name": "rt"`, + }, + { + name: "yaml", + format: print.YAMLOutputFormat, + resp: &iaas.VPCRoutingTable{ + Id: &testRoutingTableId, + Name: "rt", + }, + want: "name: rt", + }, + } { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + + err := outputResult(params.Printer, tt.format, 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/routingtable/delete/delete.go b/internal/cmd/alpha/vpc/routingtable/delete/delete.go new file mode 100644 index 000000000..0da6f53f9 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/delete/delete.go @@ -0,0 +1,119 @@ +package delete + +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" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + vpcIdFlag = "vpc-id" + routingTableIdArg = "ROUTING_TABLE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", routingTableIdArg), + Short: "Deletes a VPC routing-table", + Long: "Deletes a routing-table for a VPC in a region.", + Args: args.SingleArg(routingTableIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete routing-table "xxx" for VPC "yyy" in region "eu02"`, + `$ stackit alpha vpc routing-table delete xxx --vpc-id yyy --region eu02`, + ), + ), + 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) + vpcLabel = model.VpcId + } else if vpcLabel == "" { + vpcLabel = model.VpcId + } + + routingTableLabel, err := iaasUtils.GetVPCRoutingTableName(ctx, apiClient.DefaultAPI, model.ProjectId, model.VpcId, model.Region, model.RoutingTableId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get VPC routing-table name: %v", err) + routingTableLabel = model.RoutingTableId + } else if routingTableLabel == "" { + routingTableLabel = model.RoutingTableId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the routing-table %q for VPC %q in region %q?", routingTableLabel, vpcLabel, model.Region) + 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 routing-table: %w", err) + } + + params.Printer.Info("Deleted routing-table %q.\n", routingTableLabel) + 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, inputArgs []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), + RoutingTableId: inputArgs[0], + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiDeleteVPCRoutingTableRequest { + return apiClient.DeleteVPCRoutingTable(ctx, model.ProjectId, model.VpcId, model.Region, model.RoutingTableId) +} diff --git a/internal/cmd/alpha/vpc/routingtable/delete/delete_test.go b/internal/cmd/alpha/vpc/routingtable/delete/delete_test.go new file mode 100644 index 000000000..3de701fac --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/delete/delete_test.go @@ -0,0 +1,157 @@ +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() + testRoutingTableId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: "eu02", + vpcIdFlag: testVpcId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + RoutingTableId: testRoutingTableId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + args []string + flagValues map[string]string + valid bool + expected *inputModel + }{ + { + name: "base", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(), + valid: true, + expected: fixtureInputModel(), + }, + { + name: "missing project", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.ProjectIdFlag) + }), + }, + { + name: "invalid project", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[globalflags.ProjectIdFlag] = "invalid" + }), + }, + { + name: "missing region", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.RegionFlag) + }), + }, + { + name: "missing vpc", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, vpcIdFlag) + }), + }, + { + name: "invalid vpc", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "invalid" + }), + }, + { + name: "empty vpc", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "" + }), + }, + { + name: "missing routing-table id", + args: []string{}, + flagValues: fixtureFlagValues(), + }, + { + name: "invalid routing-table id", + args: []string{"invalid"}, + flagValues: fixtureFlagValues(), + }, + { + name: "empty routing-table id", + args: []string{""}, + flagValues: fixtureFlagValues(), + }, + { + name: "too many arguments", + args: []string{testRoutingTableId, testRoutingTableId}, + flagValues: fixtureFlagValues(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expected, tt.args, tt.flagValues, tt.valid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + ctx := context.Background() + client := &iaas.DefaultAPIServiceMock{} + + for _, tt := range []struct { + name string + model *inputModel + want iaas.ApiDeleteVPCRoutingTableRequest + }{ + { + name: "default", + model: fixtureInputModel(), + want: client.DeleteVPCRoutingTable(ctx, testProjectId, testVpcId, "eu02", testRoutingTableId), + }, + } { + t.Run(tt.name, func(t *testing.T) { + got := buildRequest(ctx, tt.model, 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) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/routingtable/describe/describe.go b/internal/cmd/alpha/vpc/routingtable/describe/describe.go new file mode 100644 index 000000000..d0a6a3350 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/describe/describe.go @@ -0,0 +1,143 @@ +package describe + +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" + "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" + routingTableIdArg = "ROUTING_TABLE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", routingTableIdArg), + Short: "Describes a VPC routing-table", + Long: "Describes a routing-table for a VPC in a region.", + Args: args.SingleArg(routingTableIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe routing-table "xxx" for VPC "yyy" in region "eu02"`, + `$ stackit alpha vpc routing-table describe xxx --vpc-id yyy --region eu02`, + ), + examples.NewExample( + `Describe a routing-table in JSON format, using the configured project and region`, + `$ stackit alpha vpc routing-table describe xxx --vpc-id yyy --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 + } + + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe vpc routing-table: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, 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, inputArgs []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), + RoutingTableId: inputArgs[0], + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiGetVPCRoutingTableRequest { + return apiClient.GetVPCRoutingTable(ctx, model.ProjectId, model.VpcId, model.Region, model.RoutingTableId) +} + +func outputResult(p *print.Printer, outputFormat string, resp *iaas.VPCRoutingTable) error { + if resp == nil { + return fmt.Errorf("describe vpc routing-table response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(resp.Id)) + table.AddSeparator() + table.AddRow("NAME", resp.Name) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(resp.Description)) + table.AddSeparator() + + if len(resp.Labels) > 0 { + var labels []string + for _, key := range slices.Sorted(maps.Keys(resp.Labels)) { + labels = append(labels, fmt.Sprintf("%s: %v", key, resp.Labels[key])) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + table.AddSeparator() + } + + table.AddRow("SYSTEM ROUTES", utils.PtrString(resp.SystemRoutes)) + table.AddSeparator() + table.AddRow("DYNAMIC ROUTES", utils.PtrString(resp.DynamicRoutes)) + table.AddSeparator() + table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(resp.CreatedAt)) + table.AddSeparator() + table.AddRow("UPDATED AT", utils.ConvertTimePToDateTimeString(resp.UpdatedAt)) + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/routingtable/describe/describe_test.go b/internal/cmd/alpha/vpc/routingtable/describe/describe_test.go new file mode 100644 index 000000000..42023672b --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/describe/describe_test.go @@ -0,0 +1,231 @@ +package describe + +import ( + "context" + "strings" + "testing" + "time" + + "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() + testRoutingTableId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: "eu02", + vpcIdFlag: testVpcId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + RoutingTableId: testRoutingTableId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + args []string + flagValues map[string]string + valid bool + expected *inputModel + }{ + { + name: "base", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(), + valid: true, + expected: fixtureInputModel(), + }, + { + name: "missing project", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.ProjectIdFlag) + }), + }, + { + name: "invalid project", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[globalflags.ProjectIdFlag] = "invalid" + }), + }, + { + name: "missing region", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.RegionFlag) + }), + }, + { + name: "missing vpc", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, vpcIdFlag) + }), + }, + { + name: "invalid vpc", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "invalid" + }), + }, + { + name: "empty vpc", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "" + }), + }, + { + name: "missing routing-table id", + args: []string{}, + flagValues: fixtureFlagValues(), + }, + { + name: "invalid routing-table id", + args: []string{"invalid"}, + flagValues: fixtureFlagValues(), + }, + { + name: "empty routing-table id", + args: []string{""}, + flagValues: fixtureFlagValues(), + }, + { + name: "too many arguments", + args: []string{testRoutingTableId, testRoutingTableId}, + flagValues: fixtureFlagValues(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expected, tt.args, tt.flagValues, tt.valid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + ctx := context.Background() + client := &iaas.DefaultAPIServiceMock{} + + for _, tt := range []struct { + name string + model *inputModel + want iaas.ApiGetVPCRoutingTableRequest + }{ + { + name: "default", + model: fixtureInputModel(), + want: client.GetVPCRoutingTable(ctx, testProjectId, testVpcId, "eu02", testRoutingTableId), + }, + } { + t.Run(tt.name, func(t *testing.T) { + got := buildRequest(ctx, tt.model, 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) { + createdAt := time.Date(2026, time.October, 1, 12, 0, 0, 0, time.UTC) + resp := &iaas.VPCRoutingTable{ + Id: &testRoutingTableId, + Name: "rt", + Description: new("my routing table"), + Labels: map[string]any{ + "team": "network", + "env": "dev", + }, + DynamicRoutes: new(false), + SystemRoutes: new(true), + CreatedAt: &createdAt, + UpdatedAt: &createdAt, + } + + for _, tt := range []struct { + name string + format string + resp *iaas.VPCRoutingTable + want []string + wantErr bool + }{ + { + name: "nil", + wantErr: true, + }, + { + name: "empty", + resp: &iaas.VPCRoutingTable{}, + want: []string{"ID", "NAME", "SYSTEM ROUTES", "DYNAMIC ROUTES"}, + }, + { + name: "table", + resp: resp, + want: []string{testRoutingTableId, "rt", "my routing table", "env: dev", "team: network", "true", "false", "2026-10-01"}, + }, + { + name: "json", + format: print.JSONOutputFormat, + resp: resp, + want: []string{`"name": "rt"`, `"dynamicRoutes": false`, `"systemRoutes": true`, testRoutingTableId}, + }, + { + name: "yaml", + format: print.YAMLOutputFormat, + resp: resp, + want: []string{"name: rt", "dynamicRoutes: false", "systemRoutes: true", testRoutingTableId}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + + err := outputResult(params.Printer, tt.format, tt.resp) + if (err != nil) != tt.wantErr { + t.Fatalf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + output := params.Out.String() + for _, want := range tt.want { + if !strings.Contains(output, want) { + t.Errorf("output %q does not contain %q", output, want) + } + } + if tt.format == "" && tt.resp != nil && len(tt.resp.Labels) > 0 && strings.Index(output, "env: dev") > strings.Index(output, "team: network") { + t.Errorf("labels are not sorted: %s", output) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/routingtable/list/list.go b/internal/cmd/alpha/vpc/routingtable/list/list.go new file mode 100644 index 000000000..8bb8833d9 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/list/list.go @@ -0,0 +1,175 @@ +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" + "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" + labelSelectorFlag = "label-selector" + filterFlag = "filter" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string + LabelSelector *string + Filter *string + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists VPC routing-tables", + Long: "Lists routing-tables for a VPC in a region.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List routing-tables for VPC "xxx" in region "eu02"`, + `$ stackit alpha vpc routing-table list --vpc-id xxx --region eu02`, + ), + examples.NewExample( + `List up to ten routing-tables with labels, using the configured project and region`, + `$ stackit alpha vpc routing-table list --vpc-id xxx --label-selector env=dev --limit 10`, + ), + ), + 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 + } + + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list vpc routing-tables: %w", err) + } + if resp == nil { + return fmt.Errorf("list vpc routing-tables response is nil") + } + + if model.Limit != nil && int64(len(resp.Items)) > *model.Limit { + resp.Items = resp.Items[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, model.VpcId, resp) + }, + } + configureFlags(cmd) + + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().String(labelSelectorFlag, "", "Filter by label") + cmd.Flags().String(filterFlag, "", "Filter resources by fields. A subset of expr-lang is supported. See https://expr-lang.org/docs/language-definition for usage details") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + 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{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + Filter: flags.FlagToStringPointer(p, cmd, filterFlag), + Limit: limit, + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiListVPCRoutingTablesRequest { + req := apiClient.ListVPCRoutingTables(ctx, model.ProjectId, model.VpcId, model.Region) + if model.LabelSelector != nil { + req = req.LabelSelector(*model.LabelSelector) + } + if model.Filter != nil { + req = req.Filter(*model.Filter) + } + + return req +} + +func outputResult(p *print.Printer, outputFormat, vpcId string, resp *iaas.VPCRoutingTableList) error { + if resp == nil { + return fmt.Errorf("list vpc routing-tables response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + if len(resp.Items) == 0 { + p.Outputf("No routing-tables found for VPC %q\n", vpcId) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION", "LABELS", "SYSTEM ROUTES", "DYNAMIC ROUTES", "CREATED AT", "UPDATED AT") + for _, routingTable := range resp.Items { + var labels []string + for _, key := range slices.Sorted(maps.Keys(routingTable.Labels)) { + labels = append(labels, fmt.Sprintf("%s: %v", key, routingTable.Labels[key])) + } + + table.AddRow( + utils.PtrString(routingTable.Id), + routingTable.Name, + utils.PtrString(routingTable.Description), + strings.Join(labels, "\n"), + utils.PtrString(routingTable.SystemRoutes), + utils.PtrString(routingTable.DynamicRoutes), + utils.ConvertTimePToDateTimeString(routingTable.CreatedAt), + utils.ConvertTimePToDateTimeString(routingTable.UpdatedAt), + ) + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/routingtable/list/list_test.go b/internal/cmd/alpha/vpc/routingtable/list/list_test.go new file mode 100644 index 000000000..f9fbd1bec --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/list/list_test.go @@ -0,0 +1,319 @@ +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() + testRoutingTableId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: "eu02", + vpcIdFlag: testVpcId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + args []string + flagValues map[string]string + want *inputModel + }{ + { + name: "defaults", + flagValues: fixtureFlagValues(), + want: fixtureInputModel(), + }, + { + name: "label selector", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[labelSelectorFlag] = "env=dev" + }), + want: fixtureInputModel(func(m *inputModel) { + m.LabelSelector = new("env=dev") + }), + }, + { + name: "filter", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[filterFlag] = `name == "rt"` + }), + want: fixtureInputModel(func(m *inputModel) { + m.Filter = new(`name == "rt"`) + }), + }, + { + name: "limit", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[limitFlag] = "10" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Limit = new(int64(10)) + }), + }, + { + name: "combined options", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[labelSelectorFlag] = "env=dev" + f[filterFlag] = `name == "rt"` + f[limitFlag] = "10" + }), + want: fixtureInputModel(func(m *inputModel) { + m.LabelSelector = new("env=dev") + m.Filter = new(`name == "rt"`) + m.Limit = new(int64(10)) + }), + }, + { + name: "zero limit", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[limitFlag] = "0" + }), + }, + { + name: "negative limit", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[limitFlag] = "-1" + }), + }, + { + name: "invalid limit", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[limitFlag] = "invalid" + }), + }, + { + name: "missing project", + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.ProjectIdFlag) + }), + }, + { + name: "invalid project", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[globalflags.ProjectIdFlag] = "invalid" + }), + }, + { + name: "missing region", + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.RegionFlag) + }), + }, + { + name: "missing vpc", + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, vpcIdFlag) + }), + }, + { + name: "invalid vpc", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "invalid" + }), + }, + { + name: "empty vpc", + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "" + }), + }, + { + name: "unexpected argument", + args: []string{"unexpected"}, + flagValues: fixtureFlagValues(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.want, tt.args, tt.flagValues, tt.want != nil) + }) + } +} + +func TestBuildRequest(t *testing.T) { + ctx := context.Background() + client := &iaas.DefaultAPIServiceMock{} + + for _, tt := range []struct { + name string + model *inputModel + want iaas.ApiListVPCRoutingTablesRequest + }{ + { + name: "default", + model: fixtureInputModel(), + want: client.ListVPCRoutingTables(ctx, testProjectId, testVpcId, "eu02"), + }, + { + name: "label selector", + model: fixtureInputModel(func(m *inputModel) { + m.LabelSelector = new("env=dev") + }), + want: client.ListVPCRoutingTables(ctx, testProjectId, testVpcId, "eu02").LabelSelector("env=dev"), + }, + { + name: "filter", + model: fixtureInputModel(func(m *inputModel) { + m.Filter = new(`name == "rt"`) + }), + want: client.ListVPCRoutingTables(ctx, testProjectId, testVpcId, "eu02").Filter(`name == "rt"`), + }, + { + name: "combined filters", + model: fixtureInputModel(func(m *inputModel) { + m.LabelSelector = new("env=dev") + m.Filter = new(`name == "rt"`) + }), + want: client.ListVPCRoutingTables(ctx, testProjectId, testVpcId, "eu02").LabelSelector("env=dev").Filter(`name == "rt"`), + }, + { + name: "empty filters", + model: fixtureInputModel(func(m *inputModel) { + m.LabelSelector = new("") + m.Filter = new("") + }), + want: client.ListVPCRoutingTables(ctx, testProjectId, testVpcId, "eu02").LabelSelector("").Filter(""), + }, + { + name: "limit is client side", + model: fixtureInputModel(func(m *inputModel) { + m.Limit = new(int64(10)) + }), + want: client.ListVPCRoutingTables(ctx, testProjectId, testVpcId, "eu02"), + }, + } { + t.Run(tt.name, func(t *testing.T) { + got := buildRequest(ctx, tt.model, 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.VPCRoutingTableList{ + Items: []iaas.VPCRoutingTable{ + { + Id: &testRoutingTableId, + Name: "rt", + Description: new("my routing table"), + Labels: map[string]any{ + "team": "network", + "env": "dev", + }, + SystemRoutes: new(true), + DynamicRoutes: new(false), + }, + }, + } + + for _, tt := range []struct { + name string + format string + resp *iaas.VPCRoutingTableList + want []string + wantErr bool + }{ + { + name: "nil", + wantErr: true, + }, + { + name: "nil items", + resp: &iaas.VPCRoutingTableList{}, + want: []string{"No routing-tables found", testVpcId}, + }, + { + name: "empty items", + resp: &iaas.VPCRoutingTableList{ + Items: []iaas.VPCRoutingTable{}, + }, + want: []string{"No routing-tables found", testVpcId}, + }, + { + name: "table", + resp: resp, + want: []string{testRoutingTableId, "rt", "my routing table", "env: dev", "team: network", "true", "false"}, + }, + { + name: "missing optional fields", + resp: &iaas.VPCRoutingTableList{ + Items: []iaas.VPCRoutingTable{ + { + Name: "rt", + }, + }, + }, + want: []string{"rt", "SYSTEM ROUTES", "DYNAMIC ROUTES"}, + }, + { + name: "json", + format: print.JSONOutputFormat, + resp: resp, + want: []string{`"items"`, `"name": "rt"`, `"dynamicRoutes": false`, testRoutingTableId}, + }, + { + name: "yaml", + format: print.YAMLOutputFormat, + resp: resp, + want: []string{"items:", "name: rt", "dynamicRoutes: false", testRoutingTableId}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + + err := outputResult(params.Printer, tt.format, testVpcId, tt.resp) + if (err != nil) != tt.wantErr { + t.Fatalf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + output := params.Out.String() + for _, want := range tt.want { + if !strings.Contains(output, want) { + t.Errorf("output %q does not contain %q", output, want) + } + } + if tt.format == "" && strings.Contains(output, "env: dev") && strings.Index(output, "env: dev") > strings.Index(output, "team: network") { + t.Errorf("labels are not sorted: %s", output) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/routingtable/routingtable.go b/internal/cmd/alpha/vpc/routingtable/routingtable.go new file mode 100644 index 000000000..1893642e1 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/routingtable.go @@ -0,0 +1,35 @@ +package routingtable + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/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: "routing-table", + Short: "Manages VPC routing-tables", + Long: "Manages routing-tables for a VPC in a region.", + 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/routingtable/update/update.go b/internal/cmd/alpha/vpc/routingtable/update/update.go new file mode 100644 index 000000000..4a96db0ec --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/update/update.go @@ -0,0 +1,166 @@ +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" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + vpcIdFlag = "vpc-id" + routingTableIdArg = "ROUTING_TABLE_ID" + nameFlag = "name" + descriptionFlag = "description" + labelFlag = "labels" + dynamicRoutesFlag = "dynamic-routes" + systemRoutesFlag = "system-routes" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + VpcId string + RoutingTableId string + Name *string + Description *string + Labels map[string]any + DynamicRoutes *bool + SystemRoutes *bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", routingTableIdArg), + Short: "Updates a VPC routing-table", + Long: "Updates a routing-table for a VPC in a region.", + Args: args.SingleArg(routingTableIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the name of routing-table "xxx" for VPC "yyy" in region "eu02"`, + `$ stackit alpha vpc routing-table update xxx --vpc-id yyy --region eu02 --name rt`, + ), + examples.NewExample( + `Disable dynamic routes, using the configured project and region`, + `$ stackit alpha vpc routing-table update xxx --vpc-id yyy --dynamic-routes=false`, + ), + ), + 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) + vpcLabel = model.VpcId + } else if vpcLabel == "" { + vpcLabel = model.VpcId + } + + routingTableLabel, err := iaasUtils.GetVPCRoutingTableName(ctx, apiClient.DefaultAPI, model.ProjectId, model.VpcId, model.Region, model.RoutingTableId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get VPC routing-table name: %v", err) + routingTableLabel = model.RoutingTableId + } else if routingTableLabel == "" { + routingTableLabel = model.RoutingTableId + } + + prompt := fmt.Sprintf("Are you sure you want to update the routing-table %q for VPC %q in region %q?", routingTableLabel, vpcLabel, model.Region) + 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 routing-table: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().String(nameFlag, "", "Name of the routing-table") + cmd.Flags().String(descriptionFlag, "", "Description of the routing-table") + cmd.Flags().StringToString(labelFlag, nil, "Key=value labels") + cmd.Flags().Bool(dynamicRoutesFlag, false, "If set to false, prevents dynamic routes from propagating to the routing-table") + cmd.Flags().Bool(systemRoutesFlag, false, "If set to false, disables automatic system routes") + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, vpcIdFlag)) + cmd.MarkFlagsOneRequired(nameFlag, descriptionFlag, labelFlag, dynamicRoutesFlag, systemRoutesFlag) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []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), + RoutingTableId: inputArgs[0], + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToAny(p, cmd, labelFlag), + DynamicRoutes: flags.FlagToBoolPointer(p, cmd, dynamicRoutesFlag), + SystemRoutes: flags.FlagToBoolPointer(p, cmd, systemRoutesFlag), + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiUpdateVPCRoutingTableRequest { + payload := iaas.UpdateVPCRoutingTablePayload{ + Name: model.Name, + Description: model.Description, + Labels: model.Labels, + DynamicRoutes: model.DynamicRoutes, + SystemRoutes: model.SystemRoutes, + } + + return apiClient.UpdateVPCRoutingTable(ctx, model.ProjectId, model.VpcId, model.Region, model.RoutingTableId).UpdateVPCRoutingTablePayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, resp *iaas.VPCRoutingTable) error { + if resp == nil { + return fmt.Errorf("update vpc routing-table response is nil") + } + if resp.Id == nil || *resp.Id == "" { + return fmt.Errorf("update vpc routing-table id is empty") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Updated routing-table with ID %q.\n", *resp.Id) + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/routingtable/update/update_test.go b/internal/cmd/alpha/vpc/routingtable/update/update_test.go new file mode 100644 index 000000000..fe8b88009 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/update/update_test.go @@ -0,0 +1,433 @@ +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() + testRoutingTableId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: "eu02", + vpcIdFlag: testVpcId, + nameFlag: "rt", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: "eu02", + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + RoutingTableId: testRoutingTableId, + Name: new("rt"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixturePayload(mods ...func(*iaas.UpdateVPCRoutingTablePayload)) iaas.UpdateVPCRoutingTablePayload { + payload := iaas.UpdateVPCRoutingTablePayload{ + Name: new("rt"), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + args []string + flagValues map[string]string + want *inputModel + }{ + { + name: "name only", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(), + want: fixtureInputModel(), + }, + { + name: "description only", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, nameFlag) + f[descriptionFlag] = "my routing table" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Name = nil + m.Description = new("my routing table") + }), + }, + { + name: "clear description", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, nameFlag) + f[descriptionFlag] = "" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Name = nil + m.Description = new("") + }), + }, + { + name: "labels only", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, nameFlag) + f[labelFlag] = "env=dev,team=network" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Name = nil + m.Labels = map[string]any{ + "env": "dev", + "team": "network", + } + }), + }, + { + name: "empty labels", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[labelFlag] = "" + }), + }, + { + name: "disable dynamic routes", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, nameFlag) + f[dynamicRoutesFlag] = "false" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Name = nil + m.DynamicRoutes = new(false) + }), + }, + { + name: "enable dynamic routes", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, nameFlag) + f[dynamicRoutesFlag] = "true" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Name = nil + m.DynamicRoutes = new(true) + }), + }, + { + name: "disable system routes", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, nameFlag) + f[systemRoutesFlag] = "false" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Name = nil + m.SystemRoutes = new(false) + }), + }, + { + name: "enable system routes", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, nameFlag) + f[systemRoutesFlag] = "true" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Name = nil + m.SystemRoutes = new(true) + }), + }, + { + name: "combined updates", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[descriptionFlag] = "my routing table" + f[dynamicRoutesFlag] = "false" + f[systemRoutesFlag] = "false" + }), + want: fixtureInputModel(func(m *inputModel) { + m.Description = new("my routing table") + m.DynamicRoutes = new(false) + m.SystemRoutes = new(false) + }), + }, + { + name: "no update flags", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, nameFlag) + }), + }, + { + name: "missing project", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.ProjectIdFlag) + }), + }, + { + name: "invalid project", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[globalflags.ProjectIdFlag] = "invalid" + }), + }, + { + name: "missing region", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, globalflags.RegionFlag) + }), + }, + { + name: "missing vpc", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + delete(f, vpcIdFlag) + }), + }, + { + name: "invalid vpc", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "invalid" + }), + }, + { + name: "empty vpc", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[vpcIdFlag] = "" + }), + }, + { + name: "invalid labels", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[labelFlag] = "invalid" + }), + }, + { + name: "invalid boolean", + args: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(f map[string]string) { + f[dynamicRoutesFlag] = "invalid" + }), + }, + { + name: "missing routing-table id", + args: []string{}, + flagValues: fixtureFlagValues(), + }, + { + name: "invalid routing-table id", + args: []string{"invalid"}, + flagValues: fixtureFlagValues(), + }, + { + name: "empty routing-table id", + args: []string{""}, + flagValues: fixtureFlagValues(), + }, + { + name: "too many arguments", + args: []string{testRoutingTableId, testRoutingTableId}, + flagValues: fixtureFlagValues(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.want, tt.args, tt.flagValues, tt.want != nil) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + name string + model *inputModel + wantPayload iaas.UpdateVPCRoutingTablePayload + }{ + { + name: "all fields", + model: fixtureInputModel(func(m *inputModel) { + m.Description = new("description") + m.Labels = map[string]any{"env": "dev"} + m.DynamicRoutes = new(true) + m.SystemRoutes = new(true) + }), + wantPayload: fixturePayload(func(p *iaas.UpdateVPCRoutingTablePayload) { + p.Description = new("description") + p.Labels = map[string]any{"env": "dev"} + p.DynamicRoutes = new(true) + p.SystemRoutes = new(true) + }), + }, + { + name: "empty labels", + model: fixtureInputModel(func(m *inputModel) { + m.Labels = map[string]any{} + }), + wantPayload: fixturePayload(func(p *iaas.UpdateVPCRoutingTablePayload) { + p.Labels = map[string]any{} + }), + }, + { + name: "omitted labels", + model: fixtureInputModel(func(m *inputModel) { + m.Description = new("description") + m.DynamicRoutes = new(true) + m.SystemRoutes = new(true) + }), + wantPayload: fixturePayload(func(p *iaas.UpdateVPCRoutingTablePayload) { + p.Description = new("description") + p.DynamicRoutes = new(true) + p.SystemRoutes = new(true) + }), + }, + { + name: "name only leaves other fields unchanged", + model: fixtureInputModel(), + wantPayload: fixturePayload(), + }, + { + name: "disabled routes", + model: fixtureInputModel(func(m *inputModel) { + m.DynamicRoutes = new(false) + m.SystemRoutes = new(false) + }), + wantPayload: fixturePayload(func(p *iaas.UpdateVPCRoutingTablePayload) { + p.DynamicRoutes = new(false) + p.SystemRoutes = new(false) + }), + }, + { + name: "clear description", + model: fixtureInputModel(func(m *inputModel) { + m.Description = new("") + }), + wantPayload: fixturePayload(func(p *iaas.UpdateVPCRoutingTablePayload) { + p.Description = new("") + }), + }, + { + name: "omitted name", + model: fixtureInputModel(func(m *inputModel) { + m.Name = nil + m.Description = new("description") + }), + wantPayload: fixturePayload(func(p *iaas.UpdateVPCRoutingTablePayload) { + p.Name = nil + p.Description = new("description") + }), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + client := &iaas.DefaultAPIServiceMock{} + + want := client.UpdateVPCRoutingTable(ctx, testProjectId, testVpcId, "eu02", testRoutingTableId).UpdateVPCRoutingTablePayload(tt.wantPayload) + got := buildRequest(ctx, tt.model, 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.VPCRoutingTable + want string + wantErr bool + }{ + { + name: "nil", + wantErr: true, + }, + { + name: "missing id", + resp: &iaas.VPCRoutingTable{}, + wantErr: true, + }, + { + name: "empty id", + resp: &iaas.VPCRoutingTable{ + Id: new(""), + }, + wantErr: true, + }, + { + name: "updated", + resp: &iaas.VPCRoutingTable{ + Id: &testRoutingTableId, + }, + want: "Updated routing-table with ID \"" + testRoutingTableId + "\".", + }, + { + name: "json", + format: print.JSONOutputFormat, + resp: &iaas.VPCRoutingTable{ + Id: &testRoutingTableId, + Name: "rt", + }, + want: `"name": "rt"`, + }, + { + name: "yaml", + format: print.YAMLOutputFormat, + resp: &iaas.VPCRoutingTable{ + Id: &testRoutingTableId, + Name: "rt", + }, + want: "name: rt", + }, + } { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + + err := outputResult(params.Printer, tt.format, 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 0e4458dbc..d234504c4 100644 --- a/internal/cmd/alpha/vpc/vpc.go +++ b/internal/cmd/alpha/vpc/vpc.go @@ -9,6 +9,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/list" networkRange "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/network-range" "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/region" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable" "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" @@ -35,4 +36,5 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(networkRange.NewCmd(params)) cmd.AddCommand(update.NewCmd(params)) cmd.AddCommand(region.NewCmd(params)) + cmd.AddCommand(routingtable.NewCmd(params)) } diff --git a/internal/pkg/services/iaasalpha/utils/utils.go b/internal/pkg/services/iaasalpha/utils/utils.go index 23d7ba07f..91f160f2c 100644 --- a/internal/pkg/services/iaasalpha/utils/utils.go +++ b/internal/pkg/services/iaasalpha/utils/utils.go @@ -23,6 +23,17 @@ func GetVPCName(ctx context.Context, apiClient iaas.DefaultAPI, projectId, vpcId return resp.Name, nil } +func GetVPCRoutingTableName(ctx context.Context, apiClient iaas.DefaultAPI, projectId, vpcId, region, routingTableId string) (string, error) { + resp, err := apiClient.GetVPCRoutingTable(ctx, projectId, vpcId, region, routingTableId).Execute() + if err != nil { + return "", fmt.Errorf("get vpc routing-table: %w", err) + } + if resp == nil { + return "", ErrResponseNil + } + return resp.Name, nil +} + func GetVPCNetworkRangePrefix(ctx context.Context, apiClient iaas.DefaultAPI, projectId, vpcId, region, networkRangeId string) (string, error) { resp, err := apiClient.GetVPCNetworkRange(ctx, projectId, vpcId, region, networkRangeId).Execute() if err != nil {