diff --git a/docs/stackit_alpha_vpc_routing-table.md b/docs/stackit_alpha_vpc_routing-table.md index ce4e664cc..479798eaa 100644 --- a/docs/stackit_alpha_vpc_routing-table.md +++ b/docs/stackit_alpha_vpc_routing-table.md @@ -34,5 +34,6 @@ stackit alpha vpc routing-table [flags] * [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 static-route](./stackit_alpha_vpc_routing-table_static-route.md) - Manages static routes of a VPC routing-table * [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_static-route.md b/docs/stackit_alpha_vpc_routing-table_static-route.md new file mode 100644 index 000000000..2d47cdc17 --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_static-route.md @@ -0,0 +1,38 @@ +## stackit alpha vpc routing-table static-route + +Manages static routes of a VPC routing-table + +### Synopsis + +Manages static routes of a VPC routing-table + +``` +stackit alpha vpc routing-table static-route [flags] +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc routing-table static-route" +``` + +### 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 +* [stackit alpha vpc routing-table static-route create](./stackit_alpha_vpc_routing-table_static-route_create.md) - Creates a static-route in a routing-table +* [stackit alpha vpc routing-table static-route delete](./stackit_alpha_vpc_routing-table_static-route_delete.md) - Deletes a static-route within a VPC routing-table +* [stackit alpha vpc routing-table static-route describe](./stackit_alpha_vpc_routing-table_static-route_describe.md) - Describes a static-route within a VPC routing-table +* [stackit alpha vpc routing-table static-route list](./stackit_alpha_vpc_routing-table_static-route_list.md) - Lists all static-routes within a VPC routing-table +* [stackit alpha vpc routing-table static-route update](./stackit_alpha_vpc_routing-table_static-route_update.md) - Updates a static-route in a VPC routing-table + diff --git a/docs/stackit_alpha_vpc_routing-table_static-route_create.md b/docs/stackit_alpha_vpc_routing-table_static-route_create.md new file mode 100644 index 000000000..3163f1d2a --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_static-route_create.md @@ -0,0 +1,53 @@ +## stackit alpha vpc routing-table static-route create + +Creates a static-route in a routing-table + +### Synopsis + +Creates a static-route in a routing-table. + +``` +stackit alpha vpc routing-table static-route create [flags] +``` + +### Examples + +``` + Create a static-route with CIDRv4 destination and IPv4 nexthop + $ stackit alpha vpc routing-table static-route create --routing-table-id xxx --vpc-id yyy --destination-type cidrv4 --destination-value --nexthop-type ipv4 --nexthop-value + + Create a route with CIDRv6 destination and IPv6 nexthop + $ stackit alpha vpc routing-table static-route create --routing-table-id xxx --vpc-id yyy --destination-type cidrv6 --destination-value --nexthop-type ipv6 --nexthop-value + + Create a route with CIDRv6 destination and nexthop Internet + $ stackit alpha vpc routing-table static-route create --routing-table-id xxx --vpc-id yyy --destination-type cidrv6 --destination-value --nexthop-type internet +``` + +### Options + +``` + --destination-type string Destination type (one of: [cidrv4, cidrv6]) + --destination-value string Destination value + -h, --help Help for "stackit alpha vpc routing-table static-route create" + --labels stringToString Labels are key-value string pairs which can be attached to a static route. E.g. '--labels key1=value1,key2=value2,...' (default []) + --nexthop-type string Next hop type (one of: [ipv4, ipv6, internet, blackhole]) + --nexthop-value string NextHop value + --routing-table-id string Routing-Table ID + --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 static-route](./stackit_alpha_vpc_routing-table_static-route.md) - Manages static routes of a VPC routing-table + diff --git a/docs/stackit_alpha_vpc_routing-table_static-route_delete.md b/docs/stackit_alpha_vpc_routing-table_static-route_delete.md new file mode 100644 index 000000000..5c052f8a3 --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_static-route_delete.md @@ -0,0 +1,42 @@ +## stackit alpha vpc routing-table static-route delete + +Deletes a static-route within a VPC routing-table + +### Synopsis + +Deletes a static-route within a VPC routing-table. + +``` +stackit alpha vpc routing-table static-route delete ROUTE_ID [flags] +``` + +### Examples + +``` + Deletes a static-route within a VPC routing-table + $ stackit alpha vpc routing-table static-route delete xxx --routing-table-id yyy --vpc-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc routing-table static-route delete" + --routing-table-id string Routing-Table ID + --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 static-route](./stackit_alpha_vpc_routing-table_static-route.md) - Manages static routes of a VPC routing-table + diff --git a/docs/stackit_alpha_vpc_routing-table_static-route_describe.md b/docs/stackit_alpha_vpc_routing-table_static-route_describe.md new file mode 100644 index 000000000..0eb26d07d --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_static-route_describe.md @@ -0,0 +1,42 @@ +## stackit alpha vpc routing-table static-route describe + +Describes a static-route within a VPC routing-table + +### Synopsis + +Describes a static-route within a VPC routing-table. + +``` +stackit alpha vpc routing-table static-route describe ROUTE_ID [flags] +``` + +### Examples + +``` + Describe a static-route within a VPC routing-table + $ stackit alpha vpc routing-table static-route describe xxx --routing-table-id yyy --vpc-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc routing-table static-route describe" + --routing-table-id string Routing-Table ID + --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 static-route](./stackit_alpha_vpc_routing-table_static-route.md) - Manages static routes of a VPC routing-table + diff --git a/docs/stackit_alpha_vpc_routing-table_static-route_list.md b/docs/stackit_alpha_vpc_routing-table_static-route_list.md new file mode 100644 index 000000000..1e34ce18c --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_static-route_list.md @@ -0,0 +1,50 @@ +## stackit alpha vpc routing-table static-route list + +Lists all static-routes within a VPC routing-table + +### Synopsis + +Lists all static-routes within a VPC routing-table. + +``` +stackit alpha vpc routing-table static-route list [flags] +``` + +### Examples + +``` + List all static-routes within a VPC routing-table + $ stackit alpha vpc routing-table static-route list --routing-table-id xxx --vpc-id yyy + + List all static-routes within a VPC routing-table with labels foo=bar,bar=baz + $ stackit alpha vpc routing-table static-route list --routing-table-id xxx --vpc-id yyy --label-selector foo=bar,bar=baz + + List up to 10 static-routes within a VPC routing-table + $ stackit alpha vpc routing-table static-route list --routing-table-id xxx --vpc-id yyy --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc routing-table static-route list" + --label-selector string Filter by label + --limit int Maximum number of entries to list + --routing-table-id string Routing-Table ID + --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 static-route](./stackit_alpha_vpc_routing-table_static-route.md) - Manages static routes of a VPC routing-table + diff --git a/docs/stackit_alpha_vpc_routing-table_static-route_update.md b/docs/stackit_alpha_vpc_routing-table_static-route_update.md new file mode 100644 index 000000000..d6eaab4cc --- /dev/null +++ b/docs/stackit_alpha_vpc_routing-table_static-route_update.md @@ -0,0 +1,43 @@ +## stackit alpha vpc routing-table static-route update + +Updates a static-route in a VPC routing-table + +### Synopsis + +Updates a static-route in a VPC routing-table. + +``` +stackit alpha vpc routing-table static-route update ROUTE_ID [flags] +``` + +### Examples + +``` + Updates the label(s) of a static-route with ID "xxx" in a routing-table ID "yyy" in VPC "zzz" + $ stackit alpha vpc routing-table static-route update xxx --routing-table-id yyy --vpc-id zzz --labels key=value,foo=bar +``` + +### Options + +``` + -h, --help Help for "stackit alpha vpc routing-table static-route update" + --labels stringToString Labels are key-value string pairs which can be attached to a static route. E.g. '--labels key1=value1,key2=value2,...' (default []) + --routing-table-id string Routing-Table ID + --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 static-route](./stackit_alpha_vpc_routing-table_static-route.md) - Manages static routes of a VPC routing-table + diff --git a/internal/cmd/alpha/vpc/routingtable/routingtable.go b/internal/cmd/alpha/vpc/routingtable/routingtable.go index 1893642e1..54d64d86b 100644 --- a/internal/cmd/alpha/vpc/routingtable/routingtable.go +++ b/internal/cmd/alpha/vpc/routingtable/routingtable.go @@ -7,6 +7,7 @@ import ( "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/staticroute" "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" @@ -32,4 +33,5 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(update.NewCmd(params)) cmd.AddCommand(list.NewCmd(params)) cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(staticroute.NewCmd(params)) } diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/create/create.go b/internal/cmd/alpha/vpc/routingtable/staticroute/create/create.go new file mode 100644 index 000000000..cde64fc28 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/create/create.go @@ -0,0 +1,260 @@ +package create + +import ( + "context" + "errors" + "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/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 ( + destinationValueFlag = "destination-value" + labelFlag = "labels" + vpcIdFlag = "vpc-id" + nextHopValueFlag = "nexthop-value" + routingTableIdFlag = "routing-table-id" + + // Destination Type Constants + destTypeCIDRv4 = "cidrv4" + destTypeCIDRv6 = "cidrv6" + + // NextHop Type Constants + nextHopTypeIPv4 = "ipv4" + nextHopTypeIPv6 = "ipv6" + nextHopTypeInternet = "internet" + nextHopTypeBlackhole = "blackhole" +) + +var ( + destinationTypeFlag = flags.StringEnumFlag( + "destination-type", + []string{destTypeCIDRv4, destTypeCIDRv6}, + "Destination type", + flags.StringEnumIgnoreCase[string](), + ) + nextHopTypeFlag = flags.StringEnumFlag( + "nexthop-type", + []string{nextHopTypeIPv4, nextHopTypeIPv6, nextHopTypeInternet, nextHopTypeBlackhole}, + "Next hop type", + flags.StringEnumIgnoreCase[string](), + ) +) + +type inputModel struct { + *globalflags.GlobalFlagModel + DestinationType string + DestinationValue string + Labels map[string]any + VpcId string + NextHopType string + NextHopValue *string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a static-route in a routing-table", + Long: "Creates a static-route in a routing-table.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample("Create a static-route with CIDRv4 destination and IPv4 nexthop", + `$ stackit alpha vpc routing-table static-route create --routing-table-id xxx --vpc-id yyy --destination-type cidrv4 --destination-value --nexthop-type ipv4 --nexthop-value `), + + examples.NewExample("Create a route with CIDRv6 destination and IPv6 nexthop", + `$ stackit alpha vpc routing-table static-route create --routing-table-id xxx --vpc-id yyy --destination-type cidrv6 --destination-value --nexthop-type ipv6 --nexthop-value `), + + examples.NewExample("Create a route with CIDRv6 destination and nexthop Internet", + `$ stackit alpha vpc routing-table static-route create --routing-table-id xxx --vpc-id yyy --destination-type cidrv6 --destination-value --nexthop-type internet`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + 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 create a static-route for vpc routing-table %q?", routingTableLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req, err := buildRequest(ctx, model, apiClient.DefaultAPI) + if err != nil { + return err + } + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create route request failed: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.CIDRFlag(), destinationValueFlag, "Destination value") + cmd.Flags().String(nextHopValueFlag, "", "NextHop value") + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + destinationTypeFlag.Register(cmd.Flags()) + nextHopTypeFlag.Register(cmd.Flags()) + + cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a static route. E.g. '--labels key1=value1,key2=value2,...'") + + err := flags.MarkFlagsRequired(cmd, vpcIdFlag, routingTableIdFlag, destinationTypeFlag.Name(), destinationValueFlag, nextHopTypeFlag.Name()) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := &inputModel{ + GlobalFlagModel: globalFlags, + DestinationType: destinationTypeFlag.Get(), + DestinationValue: flags.FlagToStringValue(p, cmd, destinationValueFlag), + Labels: flags.FlagToStringToAny(p, cmd, labelFlag), + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + NextHopType: nextHopTypeFlag.Get(), + NextHopValue: flags.FlagToStringPointer(p, cmd, nextHopValueFlag), + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + } + + // Next Hop validation logic + switch strings.ToLower(model.NextHopType) { + case nextHopTypeInternet, nextHopTypeBlackhole: + if model.NextHopValue != nil && *model.NextHopValue != "" { + return nil, errors.New("--nexthop-value is not allowed when --nexthop-type is 'internet' or 'blackhole'") + } + case nextHopTypeIPv4, nextHopTypeIPv6: + if model.NextHopValue == nil || *model.NextHopValue == "" { + return nil, errors.New("--nexthop-value is required when --nexthop-type is 'ipv4' or 'ipv6'") + } + default: + return nil, fmt.Errorf("invalid nexthop-type: %q", model.NextHopType) + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) (iaas.ApiAddVPCStaticRouteRequest, error) { + destination := buildDestination(model) + nextHop := buildNextHop(model) + + if destination != nil && nextHop != nil { + payload := iaas.AddVPCStaticRoutePayload{ + Destination: *destination, + Nexthop: *nextHop, + Labels: model.Labels, + } + + return apiClient.AddVPCStaticRoute( + ctx, + model.ProjectId, + model.VpcId, + model.Region, + model.RoutingTableId, + ).AddVPCStaticRoutePayload(payload), nil + } + + return iaas.ApiAddVPCStaticRouteRequest{}, fmt.Errorf("invalid input") +} + +func buildDestination(model *inputModel) *iaas.AddVPCStaticRoutePayloadDestination { + destinationType := strings.ToLower(model.DestinationType) + switch destinationType { + case destTypeCIDRv4: + return &iaas.AddVPCStaticRoutePayloadDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: model.DestinationType, + Value: model.DestinationValue, + }, + } + case destTypeCIDRv6: + return &iaas.AddVPCStaticRoutePayloadDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: model.DestinationType, + Value: model.DestinationValue, + }, + } + default: + return nil + } +} + +func buildNextHop(model *inputModel) *iaas.AddVPCStaticRoutePayloadNexthop { + nextHopType := strings.ToLower(model.NextHopType) + switch nextHopType { + case nextHopTypeIPv4: + return &iaas.AddVPCStaticRoutePayloadNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: model.NextHopType, + Value: *model.NextHopValue, + }, + } + case nextHopTypeIPv6: + return &iaas.AddVPCStaticRoutePayloadNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: model.NextHopType, + Value: *model.NextHopValue, + }, + } + case nextHopTypeInternet: + return &iaas.AddVPCStaticRoutePayloadNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: model.NextHopType, + }, + } + case nextHopTypeBlackhole: + return &iaas.AddVPCStaticRoutePayloadNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: model.NextHopType, + }, + } + default: + return nil + } +} + +func outputResult(p *print.Printer, outputFormat string, route *iaas.Route) error { + if route == nil { + return fmt.Errorf("create route response is empty") + } + + return p.OutputResult(outputFormat, route, func() error { + p.Outputf("Created route with ID %q\n", utils.PtrString(route.Id)) + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/create/create_test.go b/internal/cmd/alpha/vpc/routingtable/staticroute/create/create_test.go new file mode 100644 index 000000000..a25084050 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/create/create_test.go @@ -0,0 +1,398 @@ +package create + +import ( + "context" + "maps" + "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/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{DefaultAPI: &iaas.DefaultAPIService{}} + + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() + testRoutingTableId = uuid.NewString() + testLabels = map[string]any{ + "key1": "value1", + "key2": "value2", + } +) + +const ( + testRegion = "eu01" + testDestinationType = destTypeCIDRv4 + testDestinationValue = "1.1.1.0/24" + testNextHopType = nextHopTypeIPv4 + testNextHopValue = "1.1.1.1" + testLabelSelector = "key1=value1,key2=value2" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + labelFlag: testLabelSelector, + vpcIdFlag: testVpcId, + routingTableIdFlag: testRoutingTableId, + destinationTypeFlag.Name(): testDestinationType, + destinationValueFlag: testDestinationValue, + nextHopTypeFlag.Name(): testNextHopType, + nextHopValueFlag: testNextHopValue, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + VpcId: testVpcId, + RoutingTableId: testRoutingTableId, + DestinationType: testDestinationType, + DestinationValue: testDestinationValue, + NextHopType: testNextHopType, + NextHopValue: new(testNextHopValue), + Labels: maps.Clone(testLabels), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiAddVPCStaticRouteRequest)) iaas.ApiAddVPCStaticRouteRequest { + request := testClient.DefaultAPI.AddVPCStaticRoute(testCtx, testProjectId, testVpcId, testRegion, testRoutingTableId) + request = request.AddVPCStaticRoutePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaas.AddVPCStaticRoutePayload)) iaas.AddVPCStaticRoutePayload { + payload := iaas.AddVPCStaticRoutePayload{ + Destination: iaas.AddVPCStaticRoutePayloadDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: testDestinationType, + Value: testDestinationValue, + }, + }, + Nexthop: iaas.AddVPCStaticRoutePayloadNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: testNextHopType, + Value: testNextHopValue, + }, + }, + Labels: testLabels, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "routing-table ID missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "destination value missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, destinationValueFlag) + }), + isValid: false, + }, + { + description: "destination type missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, destinationTypeFlag.Name()) + }), + isValid: false, + }, + { + description: "next hop type missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nextHopTypeFlag.Name()) + }), + isValid: false, + }, + { + description: "next hop value missing when nexthop type is ipv4", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag.Name()] = testNextHopType + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "vpc ID missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, vpcIdFlag) + }), + isValid: false, + }, + { + description: "vpc ID invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[vpcIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "routing table ID invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid destination type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationTypeFlag.Name()] = "invalid" + }), + isValid: false, + }, + { + description: "invalid next hop type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag.Name()] = "invalid" + }), + isValid: false, + }, + { + description: "valid input - internet next hop without next hop value", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag.Name()] = nextHopTypeInternet + delete(flagValues, nextHopValueFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeInternet + model.NextHopValue = nil + }), + }, + { + description: "invalid input - internet next hop with next hop value", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag.Name()] = nextHopTypeInternet + flagValues[nextHopValueFlag] = "some-value" + }), + isValid: false, + }, + { + description: "valid input - blackhole next hop without next hop value", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag.Name()] = nextHopTypeBlackhole + delete(flagValues, nextHopValueFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeBlackhole + model.NextHopValue = nil + }), + }, + { + description: "invalid input - blackhole next hop with next hop value", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag.Name()] = nextHopTypeBlackhole + flagValues[nextHopValueFlag] = "some-value" + }), + isValid: false, + }, + { + description: "valid input - CIDRv6 destination and IPv6 nexthop", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationTypeFlag.Name()] = destTypeCIDRv6 + flagValues[destinationValueFlag] = "2001:db8::/32" + flagValues[nextHopTypeFlag.Name()] = nextHopTypeIPv6 + flagValues[nextHopValueFlag] = "2001:db8::1" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DestinationType = destTypeCIDRv6 + model.DestinationValue = "2001:db8::/32" + model.NextHopType = nextHopTypeIPv6 + model.NextHopValue = utils.Ptr("2001:db8::1") + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiAddVPCStaticRouteRequest + expectErr bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "cidrv6 and ipv6", + model: fixtureInputModel(func(model *inputModel) { + model.DestinationType = destTypeCIDRv6 + model.DestinationValue = "2001:db8::/32" + model.NextHopType = nextHopTypeIPv6 + model.NextHopValue = utils.Ptr("2001:db8::1") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddVPCStaticRouteRequest) { + payload := fixturePayload(func(payload *iaas.AddVPCStaticRoutePayload) { + payload.Destination = iaas.AddVPCStaticRoutePayloadDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: destTypeCIDRv6, + Value: "2001:db8::/32", + }, + } + payload.Nexthop = iaas.AddVPCStaticRoutePayloadNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: nextHopTypeIPv6, + Value: "2001:db8::1", + }, + } + }) + *request = request.AddVPCStaticRoutePayload(payload) + }), + }, + { + description: "nexthop internet", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeInternet + model.NextHopValue = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddVPCStaticRouteRequest) { + payload := fixturePayload(func(payload *iaas.AddVPCStaticRoutePayload) { + payload.Nexthop = iaas.AddVPCStaticRoutePayloadNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: nextHopTypeInternet, + }, + } + }) + *request = request.AddVPCStaticRoutePayload(payload) + }), + }, + { + description: "nexthop blackhole", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeBlackhole + model.NextHopValue = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddVPCStaticRouteRequest) { + payload := fixturePayload(func(payload *iaas.AddVPCStaticRoutePayload) { + payload.Nexthop = iaas.AddVPCStaticRoutePayloadNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: nextHopTypeBlackhole, + }, + } + }) + *request = request.AddVPCStaticRoutePayload(payload) + }), + }, + { + description: "invalid destination type returns error", + model: fixtureInputModel(func(model *inputModel) { + model.DestinationType = "unsupported" + }), + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + gotReq, err := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + if (err != nil) != tt.expectErr { + t.Fatalf("buildRequest() error = %v, expectErr %v", err, tt.expectErr) + } + if tt.expectErr { + return + } + + if diff := cmp.Diff( + tt.expectedRequest, + gotReq, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, iaas.DefaultAPIService{}), + ); diff != "" { + t.Errorf("buildRequest() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + outputFormat string + route *iaas.Route + wantErr bool + }{ + { + name: "nil route should return error", + route: nil, + wantErr: true, + }, + { + name: "empty route", + route: &iaas.Route{}, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.outputFormat, tt.route); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/delete/delete.go b/internal/cmd/alpha/vpc/routingtable/staticroute/delete/delete.go new file mode 100644 index 000000000..0a178e3af --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/delete/delete.go @@ -0,0 +1,115 @@ +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/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 ( + routeIdArg = "ROUTE_ID" + routingTableIdFlag = "routing-table-id" + vpcIdFlag = "vpc-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + RouteId string + RoutingTableId string + VpcId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", routeIdArg), + Short: "Deletes a static-route within a VPC routing-table", + Long: "Deletes a static-route within a VPC routing-table.", + Args: args.SingleArg(routeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Deletes a static-route within a VPC routing-table`, + `$ stackit alpha vpc routing-table static-route delete xxx --routing-table-id yyy --vpc-id zzz`, + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + 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 static-route %q in routing-table %q?", model.RouteId, routingTableLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient.DefaultAPI) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete static-route from routing-table: %w", err) + } + + params.Printer.Outputf("Deleted static-route %q from routing-table %q.\n", model.RouteId, model.RoutingTableId) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + err := flags.MarkFlagsRequired(cmd, vpcIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + routeId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + RouteId: routeId, + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiDeleteVPCStaticRouteRequest { + return apiClient.DeleteVPCStaticRoute(ctx, model.ProjectId, model.VpcId, model.Region, model.RoutingTableId, model.RouteId) +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/delete/delete_test.go b/internal/cmd/alpha/vpc/routingtable/staticroute/delete/delete_test.go new file mode 100644 index 000000000..929577df6 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/delete/delete_test.go @@ -0,0 +1,183 @@ +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" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{DefaultAPI: &iaas.DefaultAPIService{}} + + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() + testRoutingTableId = uuid.NewString() + testRouteId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRouteId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + vpcIdFlag: testVpcId, + routingTableIdFlag: testRoutingTableId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + RouteId: testRouteId, + RoutingTableId: testRoutingTableId, + VpcId: testVpcId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(*iaas.ApiDeleteVPCStaticRouteRequest)) iaas.ApiDeleteVPCStaticRouteRequest { + req := testClient.DefaultAPI.DeleteVPCStaticRoute(testCtx, testProjectId, testVpcId, testRegion, testRoutingTableId, testRouteId) + for _, mod := range mods { + mod(&req) + } + return req +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "missing route id arg", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing vpc-id flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, vpcIdFlag) + }), + isValid: false, + }, + { + description: "missing routing-table-id flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "arg value invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "invalid vpc-id flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flags map[string]string) { + flags[vpcIdFlag] = "invalid-vpc" + }), + isValid: false, + }, + { + description: "invalid routing-table-id flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flags map[string]string) { + flags[routingTableIdFlag] = "invalid-table" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiDeleteVPCStaticRouteRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + gotReq := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + if diff := cmp.Diff( + tt.expectedRequest, + gotReq, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, iaas.DefaultAPIService{}), + ); diff != "" { + t.Errorf("buildRequest() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/describe/describe.go b/internal/cmd/alpha/vpc/routingtable/staticroute/describe/describe.go new file mode 100644 index 000000000..fd4c77a25 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/describe/describe.go @@ -0,0 +1,143 @@ +package describe + +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/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 ( + routeIdArg = "ROUTE_ID" + routingTableIdFlag = "routing-table-id" + vpcIdFlag = "vpc-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + RouteId string + RoutingTableId string + VpcId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", routeIdArg), + Short: "Describes a static-route within a VPC routing-table", + Long: "Describes a static-route within a VPC routing-table.", + Args: args.SingleArg(routeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a static-route within a VPC routing-table`, + `$ stackit alpha vpc routing-table static-route describe xxx --routing-table-id yyy --vpc-id zzz`, + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient.DefaultAPI) + response, err := request.Execute() + if err != nil { + return fmt.Errorf("describe static-route: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, response) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + err := flags.MarkFlagsRequired(cmd, vpcIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + routeId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + RouteId: routeId, + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiGetVPCStaticRouteRequest { + return apiClient.GetVPCStaticRoute(ctx, model.ProjectId, model.VpcId, model.Region, model.RoutingTableId, model.RouteId) +} + +func outputResult(p *print.Printer, outputFormat string, route *iaas.Route) error { + if route == nil { + return fmt.Errorf("describe static-route response is empty") + } + + return p.OutputResult(outputFormat, route, func() error { + routeDetails := iaasUtils.ExtractRouteDetails(route) + + table := tables.NewTable() + + table.AddRow("ID", utils.PtrString(route.Id)) + table.AddSeparator() + + table.AddRow("DESTINATION TYPE", routeDetails.DestType) + table.AddSeparator() + + table.AddRow("DESTINATION VALUE", routeDetails.DestValue) + table.AddSeparator() + + table.AddRow("NEXTHOP TYPE", routeDetails.NextHopType) + table.AddSeparator() + + table.AddRow("NEXTHOP VALUE", routeDetails.NextHopValue) + table.AddSeparator() + + table.AddRow("LABELS", routeDetails.Labels) + table.AddSeparator() + + table.AddRow("CREATED AT", routeDetails.CreatedAt) + table.AddSeparator() + + table.AddRow("UPDATED AT", routeDetails.UpdatedAt) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/describe/describe_test.go b/internal/cmd/alpha/vpc/routingtable/staticroute/describe/describe_test.go new file mode 100644 index 000000000..54dc831b7 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/describe/describe_test.go @@ -0,0 +1,259 @@ +package describe + +import ( + "context" + "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" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{DefaultAPI: &iaas.DefaultAPIService{}} + + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() + testRoutingTableId = uuid.NewString() + testRouteId = uuid.NewString() + + testLabels = map[string]any{ + "key1": "value1", + "key2": "value2", + } +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRouteId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + vpcIdFlag: testVpcId, + routingTableIdFlag: testRoutingTableId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + RouteId: testRouteId, + RoutingTableId: testRoutingTableId, + VpcId: testVpcId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetVPCStaticRouteRequest)) iaas.ApiGetVPCStaticRouteRequest { + req := testClient.DefaultAPI.GetVPCStaticRoute(testCtx, testProjectId, testVpcId, testRegion, testRoutingTableId, testRouteId) + for _, mod := range mods { + mod(&req) + } + return req +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + argValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "arg value invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "routing-table-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "vpc-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, vpcIdFlag) + }), + isValid: false, + }, + { + description: "invalid routing-table-id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdFlag] = "invalid-id" + }), + isValid: false, + }, + { + description: "invalid vpc-id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[vpcIdFlag] = "invalid-vpc" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiGetVPCStaticRouteRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + gotReq := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + if diff := cmp.Diff( + tt.expectedRequest, + gotReq, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, iaas.DefaultAPIService{}), + ); diff != "" { + t.Errorf("buildRequest() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + fixtureRoute := iaas.Route{ + Id: new("route-foo"), + Destination: iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: "cidrv4", + Value: "10.0.0.0/24", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: "ipv4", + Value: "10.0.0.1", + }, + }, + Labels: testLabels, + CreatedAt: new(time.Now()), + UpdatedAt: new(time.Now()), + } + + tests := []struct { + name string + outputFormat string + route *iaas.Route + wantErr bool + }{ + { + name: "nil route should return error", + outputFormat: print.PrettyOutputFormat, + route: nil, + wantErr: true, + }, + { + name: "empty route", + outputFormat: print.PrettyOutputFormat, + route: &iaas.Route{}, + wantErr: false, + }, + { + name: "pretty output with one route", + outputFormat: print.PrettyOutputFormat, + route: new(fixtureRoute), + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + route: new(fixtureRoute), + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + route: new(fixtureRoute), + wantErr: false, + }, + } + + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.outputFormat, tt.route); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/list/list.go b/internal/cmd/alpha/vpc/routingtable/staticroute/list/list.go new file mode 100644 index 000000000..ea71fb648 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/list/list.go @@ -0,0 +1,167 @@ +package list + +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/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + labelSelectorFlag = "label-selector" + limitFlag = "limit" + routingTableIdFlag = "routing-table-id" + vpcIdFlag = "vpc-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LabelSelector *string + Limit *int64 + RoutingTableId string + VpcId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all static-routes within a VPC routing-table", + Long: "Lists all static-routes within a VPC routing-table.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all static-routes within a VPC routing-table`, + `$ stackit alpha vpc routing-table static-route list --routing-table-id xxx --vpc-id yyy`, + ), + examples.NewExample( + `List all static-routes within a VPC routing-table with labels foo=bar,bar=baz`, + `$ stackit alpha vpc routing-table static-route list --routing-table-id xxx --vpc-id yyy --label-selector foo=bar,bar=baz`, + ), + examples.NewExample( + `List up to 10 static-routes within a VPC routing-table`, + `$ stackit alpha vpc routing-table static-route list --routing-table-id xxx --vpc-id yyy --limit 10`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient.DefaultAPI) + response, err := request.Execute() + if err != nil { + return fmt.Errorf("list static-routes: %w", err) + } + + routes := response.Items + + // Truncate output + if model.Limit != nil && len(routes) > int(*model.Limit) { + routes = routes[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, routes, model.VpcId, model.RoutingTableId) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().String(labelSelectorFlag, "", "Filter by label") + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + err := flags.MarkFlagsRequired(cmd, vpcIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + 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, + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + Limit: limit, + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiListVPCStaticRoutesRequest { + req := apiClient.ListVPCStaticRoutes(ctx, model.ProjectId, model.VpcId, model.Region, model.RoutingTableId) + + if model.LabelSelector != nil { + req = req.LabelSelector(*model.LabelSelector) + } + + return req +} + +func outputResult(p *print.Printer, outputFormat string, routes []iaas.Route, vpcId, routeTableId string) error { + if routes == nil { + return fmt.Errorf("list static-routes response is nil") + } + + return p.OutputResult(outputFormat, routes, func() error { + if len(routes) == 0 { + p.Outputf("No static-routes found for routing-table %q in VPC %q\n", routeTableId, vpcId) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "DESTINATION TYPE", "DESTINATION VALUE", "NEXTHOP TYPE", "NEXTHOP VALUE", "LABELS") + for _, route := range routes { + routeDetails := iaasUtils.ExtractRouteDetails(&route) + table.AddRow( + utils.PtrString(route.Id), + routeDetails.DestType, + routeDetails.DestValue, + routeDetails.NextHopType, + routeDetails.NextHopValue, + routeDetails.Labels, + ) + table.AddSeparator() + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/list/list_test.go b/internal/cmd/alpha/vpc/routingtable/staticroute/list/list_test.go new file mode 100644 index 000000000..d4b315876 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/list/list_test.go @@ -0,0 +1,291 @@ +package list + +import ( + "context" + "strconv" + "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" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testLabelSelector = "key1=value1,key2=value2" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{DefaultAPI: &iaas.DefaultAPIService{}} + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() + testRoutingTableId = uuid.NewString() + + testLimit int64 = 10 +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + vpcIdFlag: testVpcId, + routingTableIdFlag: testRoutingTableId, + labelSelectorFlag: testLabelSelector, + limitFlag: strconv.Itoa(int(testLimit)), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + RoutingTableId: testRoutingTableId, + VpcId: testVpcId, + LabelSelector: new(testLabelSelector), + Limit: new(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListVPCStaticRoutesRequest)) iaas.ApiListVPCStaticRoutesRequest { + request := testClient.DefaultAPI.ListVPCStaticRoutes(testCtx, testProjectId, testVpcId, testRegion, testRoutingTableId) + request = request.LabelSelector(testLabelSelector) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + argValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "routing-table-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "vpc-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, vpcIdFlag) + }), + isValid: false, + }, + { + description: "labels missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelSelectorFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + }, + { + description: "limit missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + }, + { + description: "invalid limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "negative limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-10" + }), + isValid: false, + }, + { + description: "limit zero flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListVPCStaticRoutesRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "label selector nil", + model: fixtureInputModel(func(m *inputModel) { + m.LabelSelector = nil + }), + expectedRequest: testClient.DefaultAPI.ListVPCStaticRoutes(testCtx, testProjectId, testVpcId, testRegion, testRoutingTableId), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + gotReq := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + if diff := cmp.Diff( + tt.expectedRequest, + gotReq, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, iaas.DefaultAPIService{}), + ); diff != "" { + t.Errorf("buildRequest() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + fixtureRoute := iaas.Route{ + Id: new("route-foo"), + Destination: iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: "cidrv4", + Value: "10.0.0.0/24", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: "ipv4", + Value: "10.0.0.1", + }, + }, + Labels: map[string]any{ + "key1": "value1", + "key2": "value2", + }, + CreatedAt: new(time.Now()), + UpdatedAt: new(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routes []iaas.Route + wantErr bool + }{ + { + name: "nil routes should return error", + outputFormat: print.PrettyOutputFormat, + routes: nil, + wantErr: true, + }, + { + name: "empty routes list", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{}, + wantErr: false, + }, + { + name: "empty routes list json output", + outputFormat: print.JSONOutputFormat, + routes: []iaas.Route{}, + wantErr: false, + }, + { + name: "empty routes list yaml output", + outputFormat: print.YAMLOutputFormat, + routes: []iaas.Route{}, + wantErr: false, + }, + { + name: "route list with empty struct", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{{}}, + wantErr: false, + }, + { + name: "pretty output with one route", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{fixtureRoute}, + wantErr: false, + }, + { + name: "pretty output with multiple routes", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{fixtureRoute, fixtureRoute, fixtureRoute}, + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + routes: []iaas.Route{fixtureRoute}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + routes: []iaas.Route{fixtureRoute}, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.outputFormat, tt.routes, "dummy-vpc-id", "dummy-route-table-id"); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/staticroute.go b/internal/cmd/alpha/vpc/routingtable/staticroute/staticroute.go new file mode 100644 index 000000000..62100603b --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/staticroute.go @@ -0,0 +1,35 @@ +package staticroute + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/staticroute/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/staticroute/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/staticroute/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/staticroute/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/alpha/vpc/routingtable/staticroute/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" +) + +// The static-route command group is in alpha because the API is only on alpha available. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "static-route", + Short: "Manages static routes of a VPC routing-table", + Long: "Manages static routes of a VPC routing-table", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/update/update.go b/internal/cmd/alpha/vpc/routingtable/staticroute/update/update.go new file mode 100644 index 000000000..0533396d3 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/update/update.go @@ -0,0 +1,144 @@ +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" + cliErr "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 ( + labelFlag = "labels" + routeIdArg = "ROUTE_ID" + routingTableIdFlag = "routing-table-id" + vpcIdFlag = "vpc-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Labels map[string]any + RouteId string + RoutingTableId string + VpcId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", routeIdArg), + Short: "Updates a static-route in a VPC routing-table", + Long: "Updates a static-route in a VPC routing-table.", + Args: args.SingleArg(routeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Updates the label(s) of a static-route with ID "xxx" in a routing-table ID "yyy" in VPC "zzz"`, + "$ stackit alpha vpc routing-table static-route update xxx --routing-table-id yyy --vpc-id zzz --labels key=value,foo=bar", + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + 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 static-route %q for routing-table %q?", model.RouteId, routingTableLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient.DefaultAPI) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update static-route %q of routing-table %q: %w", model.RouteId, model.RoutingTableId, err) + } + + return outputResult(params.Printer, model.OutputFormat, model.RoutingTableId, model.VpcId, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), vpcIdFlag, "VPC ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a static route. E.g. '--labels key1=value1,key2=value2,...'") + + err := flags.MarkFlagsRequired(cmd, labelFlag, vpcIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + routeId := inputArgs[0] + + labels := flags.FlagToStringToAny(p, cmd, labelFlag) + + if labels == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Labels: labels, + RouteId: routeId, + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + VpcId: flags.FlagToStringValue(p, cmd, vpcIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient iaas.DefaultAPI) iaas.ApiUpdateVPCStaticRouteRequest { + req := apiClient.UpdateVPCStaticRoute(ctx, model.ProjectId, model.VpcId, model.Region, model.RoutingTableId, model.RouteId) + + payload := iaas.UpdateVPCStaticRoutePayload{ + Labels: model.Labels, + } + + return req.UpdateVPCStaticRoutePayload(payload) +} + +func outputResult(p *print.Printer, outputFormat, routingTableId, vpcId string, route *iaas.Route) error { + return p.OutputResult(outputFormat, route, func() error { + if route == nil { + return fmt.Errorf("update static-route response is empty") + } + + if route.Id == nil || *route.Id == "" { + return fmt.Errorf("update static-route response has empty id") + } + + p.Outputf("Updated static-route %q for routing-table %q in VPC %q.\n", *route.Id, routingTableId, vpcId) + return nil + }) +} diff --git a/internal/cmd/alpha/vpc/routingtable/staticroute/update/update_test.go b/internal/cmd/alpha/vpc/routingtable/staticroute/update/update_test.go new file mode 100644 index 000000000..df35bf376 --- /dev/null +++ b/internal/cmd/alpha/vpc/routingtable/staticroute/update/update_test.go @@ -0,0 +1,278 @@ +package update + +import ( + "context" + "maps" + "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" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testLabelSelector = "key1=value1,key2=value2" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{DefaultAPI: &iaas.DefaultAPIService{}} + + testProjectId = uuid.NewString() + testVpcId = uuid.NewString() + testRoutingTableId = uuid.NewString() + testRouteId = uuid.NewString() + + testLabels = map[string]any{ + "key1": "value1", + "key2": "value2", + } +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRouteId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + vpcIdFlag: testVpcId, + routingTableIdFlag: testRoutingTableId, + labelFlag: testLabelSelector, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + RouteId: testRouteId, + RoutingTableId: testRoutingTableId, + VpcId: testVpcId, + Labels: maps.Clone(testLabels), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(req *iaas.ApiUpdateVPCStaticRouteRequest)) iaas.ApiUpdateVPCStaticRouteRequest { + req := testClient.DefaultAPI.UpdateVPCStaticRoute(testCtx, testProjectId, testVpcId, testRegion, testRoutingTableId, testRouteId) + payload := iaas.UpdateVPCStaticRoutePayload{ + Labels: testLabels, + } + req = req.UpdateVPCStaticRoutePayload(payload) + + for _, mod := range mods { + mod(&req) + } + + return req +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "arg value invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "routing-table-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "vpc-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, vpcIdFlag) + }), + isValid: false, + }, + { + description: "labels are missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelFlag) + }), + isValid: false, + }, + { + description: "invalid label format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelFlag] = "invalid-label" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiUpdateVPCStaticRouteRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "labels nil", + model: fixtureInputModel(func(m *inputModel) { + m.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateVPCStaticRouteRequest) { + *request = request.UpdateVPCStaticRoutePayload(iaas.UpdateVPCStaticRoutePayload{ + Labels: nil, + }) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + gotReq := buildRequest(testCtx, tt.model, testClient.DefaultAPI) + + if diff := cmp.Diff(tt.expectedRequest, gotReq, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, iaas.DefaultAPIService{}), + ); diff != "" { + t.Errorf("buildRequest() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + fixtureRoute := iaas.Route{ + Id: new("route-foo"), + Destination: iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: "cidrv4", + Value: "10.0.0.0/24", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: "ipv4", + Value: "10.0.0.1", + }, + }, + Labels: testLabels, + CreatedAt: new(time.Now()), + UpdatedAt: new(time.Now()), + } + + tests := []struct { + name string + outputFormat string + route *iaas.Route + wantErr bool + }{ + { + name: "nil route should return error", + outputFormat: print.PrettyOutputFormat, + route: nil, + wantErr: true, + }, + { + name: "empty route", + outputFormat: print.PrettyOutputFormat, + route: &iaas.Route{}, + wantErr: true, + }, + { + name: "pretty output with one route", + outputFormat: print.PrettyOutputFormat, + route: new(fixtureRoute), + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + route: new(fixtureRoute), + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + route: new(fixtureRoute), + wantErr: false, + }, + } + + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.outputFormat, "", "", tt.route); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/alpha/vpc/vpc.go b/internal/cmd/alpha/vpc/vpc.go index d234504c4..fe7330812 100644 --- a/internal/cmd/alpha/vpc/vpc.go +++ b/internal/cmd/alpha/vpc/vpc.go @@ -16,6 +16,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) +// The vpc command group is in alpha because the API is only on alpha available. func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "vpc", diff --git a/internal/pkg/services/iaasalpha/utils/utils.go b/internal/pkg/services/iaasalpha/utils/utils.go index 91f160f2c..a33e410ad 100644 --- a/internal/pkg/services/iaasalpha/utils/utils.go +++ b/internal/pkg/services/iaasalpha/utils/utils.go @@ -4,14 +4,27 @@ import ( "context" "errors" "fmt" + "time" iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) var ( ErrResponseNil = errors.New("response is nil") ) +type RouteDetails struct { + DestType string + DestValue string + NextHopType string + NextHopValue string + CreatedAt string + UpdatedAt string + Labels string +} + func GetVPCName(ctx context.Context, apiClient iaas.DefaultAPI, projectId, vpcId string) (string, error) { resp, err := apiClient.GetVPC(ctx, projectId, vpcId).Execute() if err != nil { @@ -51,3 +64,48 @@ func GetVPCNetworkRangePrefix(ctx context.Context, apiClient iaas.DefaultAPI, pr return "", ErrResponseNil } + +func ExtractRouteDetails(route *iaas.Route) RouteDetails { + var routeDetails RouteDetails + if route == nil { + return routeDetails + } + + if route.Destination.DestinationCIDRv4 != nil { + routeDetails.DestType = route.Destination.DestinationCIDRv4.Type + routeDetails.DestValue = route.Destination.DestinationCIDRv4.Value + } else if route.Destination.DestinationCIDRv6 != nil { + routeDetails.DestType = route.Destination.DestinationCIDRv6.Type + routeDetails.DestValue = route.Destination.DestinationCIDRv6.Value + } + + if route.Nexthop.NexthopIPv4 != nil { + routeDetails.NextHopType = route.Nexthop.NexthopIPv4.Type + routeDetails.NextHopValue = route.Nexthop.NexthopIPv4.Value + } else if route.Nexthop.NexthopIPv6 != nil { + routeDetails.NextHopType = route.Nexthop.NexthopIPv6.Type + routeDetails.NextHopValue = route.Nexthop.NexthopIPv6.Value + } else if route.Nexthop.NexthopInternet != nil { + routeDetails.NextHopType = route.Nexthop.NexthopInternet.Type + } else if route.Nexthop.NexthopBlackhole != nil { + routeDetails.NextHopType = route.Nexthop.NexthopBlackhole.Type + } + + if len(route.Labels) > 0 { + stringMap := make(map[string]string) + for key, value := range route.Labels { + stringMap[key] = fmt.Sprintf("%v", value) + } + routeDetails.Labels = utils.JoinStringMap(stringMap, ": ", "\n") + } + + if route.CreatedAt != nil { + routeDetails.CreatedAt = route.CreatedAt.Format(time.RFC3339) + } + + if route.UpdatedAt != nil { + routeDetails.UpdatedAt = route.UpdatedAt.Format(time.RFC3339) + } + + return routeDetails +} diff --git a/internal/pkg/services/iaasalpha/utils/utils_test.go b/internal/pkg/services/iaasalpha/utils/utils_test.go index 1a98e45f3..5ad77af8c 100644 --- a/internal/pkg/services/iaasalpha/utils/utils_test.go +++ b/internal/pkg/services/iaasalpha/utils/utils_test.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "testing" + "time" + "github.com/google/go-cmp/cmp" iaas "github.com/stackitcloud/stackit-sdk-go/services/iaas/v2alpha1api" ) @@ -13,6 +15,8 @@ type IaaSClientMocked struct { GetVPCResp *iaas.VPC GetVPCNetworkRangeFails bool GetVPCNetworkRangeResp *iaas.VPCNetworkRange + GetVPCRoutingTableFails bool + GetVPCRoutingTableResp *iaas.VPCRoutingTable } func newMock(m *IaaSClientMocked) iaas.DefaultAPI { @@ -29,6 +33,12 @@ func newMock(m *IaaSClientMocked) iaas.DefaultAPI { } return m.GetVPCNetworkRangeResp, nil }), + GetVPCRoutingTableExecuteMock: new(func(_ iaas.ApiGetVPCRoutingTableRequest) (*iaas.VPCRoutingTable, error) { + if m.GetVPCRoutingTableFails { + return nil, fmt.Errorf("could not get vpc routing table") + } + return m.GetVPCRoutingTableResp, nil + }), } } @@ -160,3 +170,315 @@ func TestGetVPCNetworkRangePrefix(t *testing.T) { }) } } + +func TestGetVPCRoutingTable(t *testing.T) { + type args struct { + getVpcRoutingTableFails bool + getVpcRoutingTableResp *iaas.VPCRoutingTable + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "base", + args: args{ + getVpcRoutingTableResp: &iaas.VPCRoutingTable{ + Name: "test-vpc", + }, + }, + want: "test-vpc", + }, + { + name: "nil response", + args: args{ + getVpcRoutingTableResp: nil, + }, + wantErr: true, + }, + { + name: "get fails", + args: args{ + getVpcRoutingTableFails: true, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &IaaSClientMocked{ + GetVPCRoutingTableFails: tt.args.getVpcRoutingTableFails, + GetVPCRoutingTableResp: tt.args.getVpcRoutingTableResp, + } + got, err := GetVPCRoutingTableName(context.Background(), newMock(m), "", "", "", "") + if (err != nil) != tt.wantErr { + t.Errorf("GetVPCNetworkRangePrefix() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetVPCNetworkRangePrefix() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractRouteDetails(t *testing.T) { + const ( + ipv4 = "ipv4" + ipv6 = "ipv6" + cidrv4 = "cidrv4" + cidrv6 = "cidrv6" + ) + + created := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + updated := time.Date(2024, 1, 2, 4, 5, 6, 0, time.UTC) + + tests := []struct { + description string + input *iaas.Route + want RouteDetails + }{ + { + description: "completely empty route (zero value)", + input: &iaas.Route{}, + want: RouteDetails{}, + }, + { + description: "destination only, no nexthop, no labels", + input: &iaas.Route{ + Destination: iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: cidrv4, + Value: "10.0.0.0/24", + }, + }, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + }, + }, + { + description: "nexthop only, no destination, empty labels map", + input: &iaas.Route{ + Nexthop: iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: ipv4, + Value: "10.0.0.1", + }, + }, + Labels: map[string]any{}, // empty but non-nil + }, + want: RouteDetails{ + NextHopType: ipv4, + NextHopValue: "10.0.0.1", + }, + }, + { + description: "destination present, nexthop struct nil, labels nil", + input: &iaas.Route{ + Destination: iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: cidrv6, + Value: "2001:db8::/32", + }, + }, + Labels: nil, + }, + want: RouteDetails{ + DestType: cidrv6, + DestValue: "2001:db8::/32", + }, + }, + { + description: "CIDRv4 destination, IPv4 nexthop, with labels", + input: &iaas.Route{ + Destination: iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: cidrv4, + Value: "10.0.0.0/24", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: ipv4, + Value: "10.0.0.1", + }, + }, + Labels: map[string]any{ + "key": "value", + }, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + NextHopType: ipv4, + NextHopValue: "10.0.0.1", + Labels: "key: value", + }, + }, + { + description: "CIDRv6 destination, IPv6 nexthop, with no labels", + input: &iaas.Route{ + Destination: iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: cidrv6, + Value: "2001:db8::/32", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: ipv6, + Value: "2001:db8::1", + }, + }, + Labels: nil, + }, + want: RouteDetails{ + DestType: cidrv6, + DestValue: "2001:db8::/32", + NextHopType: ipv6, + NextHopValue: "2001:db8::1", + }, + }, + { + description: "Internet nexthop without value", + input: &iaas.Route{ + Destination: iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: cidrv4, + Value: "0.0.0.0/0", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: "Internet", + }, + }, + Labels: nil, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "0.0.0.0/0", + NextHopType: "Internet", + // HopValue empty + }, + }, + { + description: "Blackhole nexthop without value and nil labels map", + input: &iaas.Route{ + Destination: iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: cidrv6, + Value: "::/0", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: "Blackhole", + }, + }, + Labels: nil, + }, + want: RouteDetails{ + DestType: cidrv6, + DestValue: "::/0", + NextHopType: "Blackhole", + }, + }, + { + description: "route with created and updated timestamps", + input: &iaas.Route{ + Destination: iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: cidrv4, + Value: "10.0.0.0/24", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: ipv4, + Value: "10.0.0.1", + }, + }, + CreatedAt: &created, + UpdatedAt: &updated, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + NextHopType: ipv4, + NextHopValue: "10.0.0.1", + CreatedAt: created.Format(time.RFC3339), + UpdatedAt: updated.Format(time.RFC3339), + Labels: "", + }, + }, + { + description: "route with created timestamp only", + input: &iaas.Route{ + Destination: iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: cidrv4, + Value: "10.0.0.0/24", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: ipv4, + Value: "10.0.0.1", + }, + }, + CreatedAt: &created, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + NextHopType: ipv4, + NextHopValue: "10.0.0.1", + CreatedAt: created.Format(time.RFC3339), + UpdatedAt: "", + Labels: "", + }, + }, + { + description: "route with updated timestamp only", + input: &iaas.Route{ + Destination: iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: cidrv4, + Value: "10.0.0.0/24", + }, + }, + Nexthop: iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: ipv4, + Value: "10.0.0.1", + }, + }, + UpdatedAt: &updated, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + NextHopType: ipv4, + NextHopValue: "10.0.0.1", + CreatedAt: "", + UpdatedAt: updated.Format(time.RFC3339), + Labels: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := ExtractRouteDetails(tt.input) + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("ExtractRouteDetails() mismatch (-want +got):\n%s", diff) + } + }) + } +}