From a1df2eaa2b0629947416833d504a47c5cc029428 Mon Sep 17 00:00:00 2001 From: Mauritz Uphoff Date: Tue, 23 Sep 2025 17:23:04 +0200 Subject: [PATCH] feat(routing-table): provide rt and routes functionality --- docs/stackit_beta.md | 1 + docs/stackit_beta_routing-table.md | 36 ++ docs/stackit_beta_routing-table_describe.md | 42 ++ docs/stackit_beta_routing-table_list.md | 50 ++ docs/stackit_beta_routing-table_route.md | 38 ++ ...stackit_beta_routing-table_route_create.md | 63 +++ ...stackit_beta_routing-table_route_delete.md | 43 ++ ...ackit_beta_routing-table_route_describe.md | 43 ++ docs/stackit_beta_routing-table_route_list.md | 51 ++ ...stackit_beta_routing-table_route_update.md | 44 ++ go.mod | 1 + go.sum | 2 + internal/cmd/beta/beta.go | 2 + .../beta/routingtable/describe/describe.go | 162 ++++++ .../routingtable/describe/describe_test.go | 190 +++++++ internal/cmd/beta/routingtable/list/list.go | 209 ++++++++ .../cmd/beta/routingtable/list/list_test.go | 215 ++++++++ .../beta/routingtable/route/create/create.go | 290 ++++++++++ .../routingtable/route/create/create_test.go | 499 ++++++++++++++++++ .../beta/routingtable/route/delete/delete.go | 123 +++++ .../routingtable/route/delete/delete_test.go | 141 +++++ .../routingtable/route/describe/describe.go | 205 +++++++ .../route/describe/describe_test.go | 225 ++++++++ .../cmd/beta/routingtable/route/list/list.go | 191 +++++++ .../beta/routingtable/route/list/list_test.go | 240 +++++++++ internal/cmd/beta/routingtable/route/route.go | 33 ++ .../beta/routingtable/route/update/update.go | 158 ++++++ .../routingtable/route/update/update_test.go | 236 +++++++++ .../cmd/beta/routingtable/routingtable.go | 31 ++ .../pkg/services/iaas/client/alphaclient.go | 44 ++ .../pkg/services/routing-table/utils/utils.go | 40 ++ .../routing-table/utils/utils_test.go | 135 +++++ 32 files changed, 3783 insertions(+) create mode 100644 docs/stackit_beta_routing-table.md create mode 100644 docs/stackit_beta_routing-table_describe.md create mode 100644 docs/stackit_beta_routing-table_list.md create mode 100644 docs/stackit_beta_routing-table_route.md create mode 100644 docs/stackit_beta_routing-table_route_create.md create mode 100644 docs/stackit_beta_routing-table_route_delete.md create mode 100644 docs/stackit_beta_routing-table_route_describe.md create mode 100644 docs/stackit_beta_routing-table_route_list.md create mode 100644 docs/stackit_beta_routing-table_route_update.md create mode 100644 internal/cmd/beta/routingtable/describe/describe.go create mode 100644 internal/cmd/beta/routingtable/describe/describe_test.go create mode 100644 internal/cmd/beta/routingtable/list/list.go create mode 100644 internal/cmd/beta/routingtable/list/list_test.go create mode 100644 internal/cmd/beta/routingtable/route/create/create.go create mode 100644 internal/cmd/beta/routingtable/route/create/create_test.go create mode 100644 internal/cmd/beta/routingtable/route/delete/delete.go create mode 100644 internal/cmd/beta/routingtable/route/delete/delete_test.go create mode 100644 internal/cmd/beta/routingtable/route/describe/describe.go create mode 100644 internal/cmd/beta/routingtable/route/describe/describe_test.go create mode 100644 internal/cmd/beta/routingtable/route/list/list.go create mode 100644 internal/cmd/beta/routingtable/route/list/list_test.go create mode 100644 internal/cmd/beta/routingtable/route/route.go create mode 100644 internal/cmd/beta/routingtable/route/update/update.go create mode 100644 internal/cmd/beta/routingtable/route/update/update_test.go create mode 100644 internal/cmd/beta/routingtable/routingtable.go create mode 100644 internal/pkg/services/iaas/client/alphaclient.go create mode 100644 internal/pkg/services/routing-table/utils/utils.go create mode 100644 internal/pkg/services/routing-table/utils/utils_test.go diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index b58eb067a..bc927b532 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -42,5 +42,6 @@ stackit beta [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers +* [stackit beta routing-table](./stackit_beta_routing-table.md) - Manage routing-tables and its according routes * [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex diff --git a/docs/stackit_beta_routing-table.md b/docs/stackit_beta_routing-table.md new file mode 100644 index 000000000..f2173ce26 --- /dev/null +++ b/docs/stackit_beta_routing-table.md @@ -0,0 +1,36 @@ +## stackit beta routing-table + +Manage routing-tables and its according routes + +### Synopsis + +Manage routing-tables and its according routes + +``` +stackit beta routing-table [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta 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 beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta routing-table describe](./stackit_beta_routing-table_describe.md) - Describe a routing-table +* [stackit beta routing-table list](./stackit_beta_routing-table_list.md) - List all routing-tables +* [stackit beta routing-table route](./stackit_beta_routing-table_route.md) - Manage routes of a routing-table + diff --git a/docs/stackit_beta_routing-table_describe.md b/docs/stackit_beta_routing-table_describe.md new file mode 100644 index 000000000..d2db08f5f --- /dev/null +++ b/docs/stackit_beta_routing-table_describe.md @@ -0,0 +1,42 @@ +## stackit beta routing-table describe + +Describe a routing-table + +### Synopsis + +Describe a routing-table + +``` +stackit beta routing-table describe ROUTING_TABLE_ID_ARG [flags] +``` + +### Examples + +``` + Describe a routing-table + $ stackit beta routing-table describe xxxx-xxxx-xxxx-xxxx --organization-id xxx --network-area-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta routing-table describe" + --network-area-id string Network-Area ID + --organization-id string Organization 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 beta routing-table](./stackit_beta_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_beta_routing-table_list.md b/docs/stackit_beta_routing-table_list.md new file mode 100644 index 000000000..99109552d --- /dev/null +++ b/docs/stackit_beta_routing-table_list.md @@ -0,0 +1,50 @@ +## stackit beta routing-table list + +List all routing-tables + +### Synopsis + +List all routing-tables + +``` +stackit beta routing-table list [flags] +``` + +### Examples + +``` + List all routing-tables + $ stackit beta routing-table list --organization-id xxx --network-area-id yyy + + List all routing-tables with labels + $ stackit beta routing-table list --label-selector env=dev,env=rc --organization-id xxx --network-area-id yyy + + List all routing-tables with labels and limit to 10 + $ stackit beta routing-table list --label-selector env=dev,env=rc --limit 10 --organization-id xxx --network-area-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta routing-table list" + --label-selector string Filter by label + --limit int Maximum number of entries to list + --network-area-id string Network-Area ID + --organization-id string Organization 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 beta routing-table](./stackit_beta_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_beta_routing-table_route.md b/docs/stackit_beta_routing-table_route.md new file mode 100644 index 000000000..3add3abcd --- /dev/null +++ b/docs/stackit_beta_routing-table_route.md @@ -0,0 +1,38 @@ +## stackit beta routing-table route + +Manage routes of a routing-table + +### Synopsis + +Manage routes of a routing-table + +``` +stackit beta routing-table route [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta routing-table 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 beta routing-table](./stackit_beta_routing-table.md) - Manage routing-tables and its according routes +* [stackit beta routing-table route create](./stackit_beta_routing-table_route_create.md) - Creates a route in a routing-table +* [stackit beta routing-table route delete](./stackit_beta_routing-table_route_delete.md) - Deletes a route within a routing-table +* [stackit beta routing-table route describe](./stackit_beta_routing-table_route_describe.md) - Describe a route within a routing-table +* [stackit beta routing-table route list](./stackit_beta_routing-table_route_list.md) - list all routes within a routing-table +* [stackit beta routing-table route update](./stackit_beta_routing-table_route_update.md) - Updates a route in a routing-table + diff --git a/docs/stackit_beta_routing-table_route_create.md b/docs/stackit_beta_routing-table_route_create.md new file mode 100644 index 000000000..7cb5b7823 --- /dev/null +++ b/docs/stackit_beta_routing-table_route_create.md @@ -0,0 +1,63 @@ +## stackit beta routing-table route create + +Creates a route in a routing-table + +### Synopsis + +Creates a route in a routing-table. + +``` +stackit beta routing-table route create [flags] +``` + +### Examples + +``` + Create a route with CIDRv4 destination and IPv4 nexthop + stackit beta routing-tables route create \ +--routing-table-id xxx --organization-id yyy --network-area-id zzz \ +--destination-type cidrv4 --destination-value \ +--nexthop-type ipv4 --nexthop-value + + Create a route with CIDRv6 destination and IPv6 nexthop + stackit beta routing-tables route create \ +--routing-table-id xxx --organization-id yyy --network-area-id zzz \ +--destination-type cidrv6 --destination-value \ +--nexthop-type ipv6 --nexthop-value + + Create a route with CIDRv6 destination and Nexthop Internet + stackit beta routing-tables route create \ +--routing-table-id xxx --organization-id yyy --network-area-id zzz \ +--destination-type cidrv6 --destination-value \ +--nexthop-type internet +``` + +### Options + +``` + --destination-type string Destination type + --destination-value string Destination value + -h, --help Help for "stackit beta routing-table route create" + --labels stringToString Key=value labels (default []) + --network-area-id string Network-Area ID + --nexthop-type string Next hop type + --nexthop-value string NextHop value + --organization-id string Organization ID + --routing-table-id string Routing-Table 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 beta routing-table route](./stackit_beta_routing-table_route.md) - Manage routes of a routing-table + diff --git a/docs/stackit_beta_routing-table_route_delete.md b/docs/stackit_beta_routing-table_route_delete.md new file mode 100644 index 000000000..ba10a41ef --- /dev/null +++ b/docs/stackit_beta_routing-table_route_delete.md @@ -0,0 +1,43 @@ +## stackit beta routing-table route delete + +Deletes a route within a routing-table + +### Synopsis + +Deletes a route within a routing-table + +``` +stackit beta routing-table route delete routing-table-id [flags] +``` + +### Examples + +``` + Deletes a route within a routing-table + $ stackit beta routing-table route delete xxxx-xxxx-xxxx-xxxx --routing-table-id xxx --organization-id yyy --network-area-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit beta routing-table route delete" + --network-area-id string Network-Area ID + --organization-id string Organization ID + --routing-table-id string Routing-Table 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 beta routing-table route](./stackit_beta_routing-table_route.md) - Manage routes of a routing-table + diff --git a/docs/stackit_beta_routing-table_route_describe.md b/docs/stackit_beta_routing-table_route_describe.md new file mode 100644 index 000000000..586bf88d6 --- /dev/null +++ b/docs/stackit_beta_routing-table_route_describe.md @@ -0,0 +1,43 @@ +## stackit beta routing-table route describe + +Describe a route within a routing-table + +### Synopsis + +Describe a route within a routing-table + +``` +stackit beta routing-table route describe ROUTE_ID_ARG [flags] +``` + +### Examples + +``` + Describe a route within a routing-table + $ stackit beta routing-table route describe xxxx-xxxx-xxxx-xxxx --routing-table-id xxx --organization-id yyy --network-area-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit beta routing-table route describe" + --network-area-id string Network-Area ID + --organization-id string Organization ID + --routing-table-id string Routing-Table 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 beta routing-table route](./stackit_beta_routing-table_route.md) - Manage routes of a routing-table + diff --git a/docs/stackit_beta_routing-table_route_list.md b/docs/stackit_beta_routing-table_route_list.md new file mode 100644 index 000000000..0dfd13f46 --- /dev/null +++ b/docs/stackit_beta_routing-table_route_list.md @@ -0,0 +1,51 @@ +## stackit beta routing-table route list + +list all routes within a routing-table + +### Synopsis + +list all routes within a routing-table + +``` +stackit beta routing-table route list [flags] +``` + +### Examples + +``` + List all routes within a routing-table + $ stackit beta routing-table route list --routing-table-id xxx --organization-id yyy --network-area-id zzz + + List all routes within a routing-table with labels + $ stackit beta routing-table list --routing-table-id xxx --organization-id yyy --network-area-id zzz --label-selector env=dev,env=rc + + List all routes within a routing-tables with labels and limit to 10 + $ stackit beta routing-table list --routing-table-id xxx --organization-id yyy --network-area-id zzz --label-selector env=dev,env=rc --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta routing-table route list" + --label-selector string Filter by label + --limit int Maximum number of entries to list + --network-area-id string Network-Area ID + --organization-id string Organization ID + --routing-table-id string Routing-Table 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 beta routing-table route](./stackit_beta_routing-table_route.md) - Manage routes of a routing-table + diff --git a/docs/stackit_beta_routing-table_route_update.md b/docs/stackit_beta_routing-table_route_update.md new file mode 100644 index 000000000..044e3b165 --- /dev/null +++ b/docs/stackit_beta_routing-table_route_update.md @@ -0,0 +1,44 @@ +## stackit beta routing-table route update + +Updates a route in a routing-table + +### Synopsis + +Updates a route in a routing-table. + +``` +stackit beta routing-table route update ROUTE_ID_ARG [flags] +``` + +### Examples + +``` + Updates the label(s) of a route with ID "xxx" in a routing-table ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit beta routing-table route update xxx --labels key=value,foo=bar --routing-table-id xxx --organization-id yyy --network-area-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit beta routing-table route update" + --labels stringToString Labels are key-value string pairs which can be attached to a route. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels (default []) + --network-area-id string Network-Area ID + --organization-id string Organization ID + --routing-table-id string Routing-Table 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 beta routing-table route](./stackit_beta_routing-table_route.md) - Manage routes of a routing-table + diff --git a/go.mod b/go.mod index 706e4c843..d70ad7a4b 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.1 github.com/stackitcloud/stackit-sdk-go/services/git v0.7.1 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.30.0 + github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.21-alpha github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.2 github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.1 github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.2.1 diff --git a/go.sum b/go.sum index def1c58e0..bfb76fe20 100644 --- a/go.sum +++ b/go.sum @@ -573,6 +573,8 @@ github.com/stackitcloud/stackit-sdk-go/services/git v0.7.1 h1:hkFixFnBcQzU4BSIZF github.com/stackitcloud/stackit-sdk-go/services/git v0.7.1/go.mod h1:Ng1EzrRndG3iGXGH90AZJz//wfK+2YOyDwTnTLwX3a4= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.30.0 h1:01+noyCSadNH3ALHufcVXxNs0hBsetzJkOMN1Fe0VLc= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.30.0/go.mod h1:854gnLR92NvAbJAA1xZEumrtNh1DoBP1FXTMvhwYA6w= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.21-alpha h1:m1jq6a8dbUe+suFuUNdHmM/cSehpGLUtDbK1CqLqydg= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.21-alpha/go.mod h1:Nu1b5Phsv8plgZ51+fkxPVsU91ZJ5Ayz+cthilxdmQ8= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.5.1 h1:OdJEs8eOfrzn9tCBDLxIyP8hX50zPfcXNYnRoQX+chs= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.5.1/go.mod h1:11uzaOPCF9SeDHXEGOPMlHDD3J5r2TnvCGUwW9Igq9c= github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.1 h1:hv5WrRU9rN6Jx4OwdOGJRyaQrfA9p1tzEoQK6/CDyoA= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 5a007b87e..9934dca8c 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/routingtable" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/cmd/params" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -38,4 +39,5 @@ func NewCmd(params *params.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(alb.NewCmd(params)) + cmd.AddCommand(routingtable.NewCmd(params)) } diff --git a/internal/cmd/beta/routingtable/describe/describe.go b/internal/cmd/beta/routingtable/describe/describe.go new file mode 100644 index 000000000..275f52582 --- /dev/null +++ b/internal/cmd/beta/routingtable/describe/describe.go @@ -0,0 +1,162 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +const ( + organizationIdFlag = "organization-id" + networkAreaIdFlag = "network-area-id" + routingTableArg = "ROUTING_TABLE_ID_ARG" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId *string + NetworkAreaId *string + RoutingTableId *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", routingTableArg), + Short: "Describe a routing-table", + Long: "Describe a routing-table", + Args: args.SingleArg(routingTableArg, nil), + Example: examples.Build( + examples.NewExample( + `Describe a routing-table`, + `$ stackit beta routing-table describe xxxx-xxxx-xxxx-xxxx --organization-id xxx --network-area-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureAlphaClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := apiClient.GetRoutingTableOfArea( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + ) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("describe routing-tables: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, response) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, args []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + if len(args) == 0 { + return nil, fmt.Errorf("at least one argument is required") + } + routingTableId := args[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + RoutingTableId: &routingTableId, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, routingTable *iaasalpha.RoutingTable) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(routingTable, "", " ") + if err != nil { + return fmt.Errorf("marshal routing-table describe: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(routingTable, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal routing-table describe: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + var labels []string + for key, value := range *routingTable.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION", "CREATED_AT", "UPDATED_AT", "DEFAULT", "LABELS", "SYSTEM_ROUTES") + table.AddRow( + utils.PtrString(routingTable.Id), + utils.PtrString(routingTable.Name), + utils.PtrString(routingTable.Description), + routingTable.CreatedAt.String(), + routingTable.UpdatedAt.String(), + utils.PtrString(routingTable.Default), + strings.Join(labels, "\n"), + utils.PtrString(routingTable.SystemRoutes), + ) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/routingtable/describe/describe_test.go b/internal/cmd/beta/routingtable/describe/describe_test.go new file mode 100644 index 000000000..5a57d9a32 --- /dev/null +++ b/internal/cmd/beta/routingtable/describe/describe_test.go @@ -0,0 +1,190 @@ +package describe + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +var testRegion = "eu01" +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: utils.Ptr(testOrgId), + NetworkAreaId: utils.Ptr(testNetworkAreaId), + RoutingTableId: utils.Ptr(testRoutingTableId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRoutingTableId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +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: "network-area-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "org-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRouteTable := iaasalpha.RoutingTable{ + CreatedAt: utils.Ptr(time.Now()), + Default: nil, + Description: utils.Ptr("description"), + Id: utils.Ptr("route-foo"), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + Name: utils.Ptr("route-foo"), + SystemRoutes: utils.Ptr(true), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routingTable iaasalpha.RoutingTable + wantErr bool + }{ + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + routingTable: dummyRouteTable, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + routingTable: dummyRouteTable, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, &tt.routingTable); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/routingtable/list/list.go b/internal/cmd/beta/routingtable/list/list.go new file mode 100644 index 000000000..708411334 --- /dev/null +++ b/internal/cmd/beta/routingtable/list/list.go @@ -0,0 +1,209 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/iaas/client" + rmClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/client" + rmUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +const ( + organizationIdFlag = "organization-id" + networkAreaIdFlag = "network-area-id" + labelSelectorFlag = "label-selector" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId *string + NetworkAreaId *string + LabelSelector *string + Limit *int64 +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all routing-tables", + Long: "List all routing-tables", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all routing-tables`, + `$ stackit beta routing-table list --organization-id xxx --network-area-id yyy`, + ), + examples.NewExample( + `List all routing-tables with labels`, + `$ stackit beta routing-table list --label-selector env=dev,env=rc --organization-id xxx --network-area-id yyy`, + ), + examples.NewExample( + `List all routing-tables with labels and limit to 10`, + `$ stackit beta routing-table list --label-selector env=dev,env=rc --limit 10 --organization-id xxx --network-area-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureAlphaClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("list routing-tables: %w", err) + } + + if items := response.Items; items == nil || len(*items) == 0 { + var orgLabel string + rmApiClient, err := rmClient.ConfigureClient(params.Printer, params.CliVersion) + if err == nil { + orgLabel, err = rmUtils.GetOrganizationName(ctx, rmApiClient, *model.OrganizationId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get organization name: %v", err) + orgLabel = *model.OrganizationId + } else if orgLabel == "" { + orgLabel = *model.OrganizationId + } + } else { + params.Printer.Debug(print.ErrorLevel, "configure resource manager client: %v", err) + } + params.Printer.Info("No routing-tables found for organization %q\n", orgLabel) + return nil + } + + // Truncate output + items := *response.Items + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, items) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().String(labelSelectorFlag, "", "Filter by label") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*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, + Limit: limit, + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaasalpha.APIClient) iaasalpha.ApiListRoutingTablesOfAreaRequest { + request := apiClient.ListRoutingTablesOfArea(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region) + if model.LabelSelector != nil { + request.LabelSelector(*model.LabelSelector) + } + + return request +} +func outputResult(p *print.Printer, outputFormat string, items []iaasalpha.RoutingTable) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("marshal routing-table list: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal routing-table list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION", "CREATED_AT", "UPDATED_AT", "DEFAULT", "LABELS", "SYSTEM_ROUTES") + + for _, item := range items { + var labels []string + for key, value := range *item.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + + table.AddRow( + utils.PtrString(item.Id), + utils.PtrString(item.Name), + utils.PtrString(item.Description), + item.CreatedAt.String(), + item.UpdatedAt.String(), + utils.PtrString(item.Default), + strings.Join(labels, "\n"), + utils.PtrString(item.SystemRoutes), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/routingtable/list/list_test.go b/internal/cmd/beta/routingtable/list/list_test.go new file mode 100644 index 000000000..02976bd2e --- /dev/null +++ b/internal/cmd/beta/routingtable/list/list_test.go @@ -0,0 +1,215 @@ +package list + +import ( + "strconv" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +var testRegion = "eu01" +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() + +var testLabelSelectorFlag = "key1=value1,key2=value2" +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +var testLimitFlag = int64(10) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + labelSelectorFlag: testLabelSelectorFlag, + limitFlag: strconv.Itoa(int(testLimitFlag)), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: utils.Ptr(testOrgId), + NetworkAreaId: utils.Ptr(testNetworkAreaId), + LabelSelector: utils.Ptr(testLabelSelectorFlag), + Limit: utils.Ptr(testLimitFlag), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "network-area-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "org-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRouteTable := iaasalpha.RoutingTable{ + CreatedAt: utils.Ptr(time.Now()), + Default: nil, + Description: utils.Ptr("description"), + Id: utils.Ptr("route-foo"), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + Name: utils.Ptr("route-foo"), + SystemRoutes: utils.Ptr(true), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routingTable []iaasalpha.RoutingTable + wantErr bool + }{ + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + routingTable: []iaasalpha.RoutingTable{dummyRouteTable}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + routingTable: []iaasalpha.RoutingTable{dummyRouteTable}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, tt.routingTable); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/routingtable/route/create/create.go b/internal/cmd/beta/routingtable/route/create/create.go new file mode 100644 index 000000000..54332e3b9 --- /dev/null +++ b/internal/cmd/beta/routingtable/route/create/create.go @@ -0,0 +1,290 @@ +package create + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/iaas/client" + routeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/routing-table/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +const ( + organizationIdFlag = "organization-id" + networkAreaIdFlag = "network-area-id" + routingTableIdFlag = "routing-table-id" + destinationTypeFlag = "destination-type" + destinationValueFlag = "destination-value" + nextHopTypeFlag = "nexthop-type" + nextHopValueFlag = "nexthop-value" + labelFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId *string + NetworkAreaId *string + RoutingTableId *string + DestinationType *string + DestinationValue *string + NextHopType *string + NextHopValue *string + Labels *map[string]string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a route in a routing-table", + Long: "Creates a route in a routing-table.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample("Create a route with CIDRv4 destination and IPv4 nexthop", + `stackit beta routing-tables route create \ +--routing-table-id xxx --organization-id yyy --network-area-id zzz \ +--destination-type cidrv4 --destination-value \ +--nexthop-type ipv4 --nexthop-value `), + + examples.NewExample("Create a route with CIDRv6 destination and IPv6 nexthop", + `stackit beta routing-tables route create \ +--routing-table-id xxx --organization-id yyy --network-area-id zzz \ +--destination-type cidrv6 --destination-value \ +--nexthop-type ipv6 --nexthop-value `), + + examples.NewExample("Create a route with CIDRv6 destination and Nexthop Internet", + `stackit beta routing-tables route create \ +--routing-table-id xxx --organization-id yyy --network-area-id zzz \ +--destination-type cidrv6 --destination-value \ +--nexthop-type internet`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + apiClient, err := client.ConfigureAlphaClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a route for routing-table with id %q?", *model.RoutingTableId) + if err := params.Printer.PromptForConfirmation(prompt); err != nil { + return err + } + } + + req, err := buildRequest(ctx, model, apiClient) + 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.Items) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + cmd.Flags().Var(flags.CIDRFlag(), destinationValueFlag, "Destination value") + cmd.Flags().String(nextHopValueFlag, "", "NextHop value") + + cmd.Flags().Var( + flags.EnumFlag(true, "", "cidrv4", "cidrv6"), + destinationTypeFlag, + "Destination type") + + cmd.Flags().Var( + flags.EnumFlag(true, "", "ipv4", "ipv6", "internet", "blackhole"), + nextHopTypeFlag, + "Next hop type") + + cmd.Flags().StringToString(labelFlag, nil, "Key=value labels") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag, destinationTypeFlag, destinationValueFlag, nextHopTypeFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := &inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + DestinationType: flags.FlagToStringPointer(p, cmd, destinationTypeFlag), + DestinationValue: flags.FlagToStringPointer(p, cmd, destinationValueFlag), + NextHopType: flags.FlagToStringPointer(p, cmd, nextHopTypeFlag), + NextHopValue: flags.FlagToStringPointer(p, cmd, nextHopValueFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + } + + // Validation logic + switch strings.ToLower(*model.NextHopType) { + case "internet", "blackhole": + if model.NextHopValue != nil && *model.NextHopValue != "" { + return nil, errors.New("--nexthop-value is not allowed when --nexthop-type is 'internet' or 'blackhole'") + } + case "ipv4", "ipv6": + 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) + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaasalpha.APIClient) (iaasalpha.ApiAddRoutesToRoutingTableRequest, error) { + dest := buildDestination(model) + nexthop := buildNextHop(model) + + payload := iaasalpha.AddRoutesToRoutingTablePayload{ + Items: &[]iaasalpha.Route{ + { + Destination: dest, + Nexthop: nexthop, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + }, + }, + } + + return apiClient.AddRoutesToRoutingTable( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + ).AddRoutesToRoutingTablePayload(payload), nil +} + +func buildDestination(model *inputModel) *iaasalpha.RouteDestination { + t := strings.ToLower(*model.DestinationType) + switch t { + case "cidrv4": + return &iaasalpha.RouteDestination{ + DestinationCIDRv4: &iaasalpha.DestinationCIDRv4{ + Type: model.DestinationType, + Value: model.DestinationValue, + }, + } + case "cidrv6": + return &iaasalpha.RouteDestination{ + DestinationCIDRv6: &iaasalpha.DestinationCIDRv6{ + Type: model.DestinationType, + Value: model.DestinationValue, + }, + } + default: + return nil + } +} + +func buildNextHop(model *inputModel) *iaasalpha.RouteNexthop { + t := strings.ToLower(*model.NextHopType) + switch t { + case "ipv4": + return &iaasalpha.RouteNexthop{ + NexthopIPv4: &iaasalpha.NexthopIPv4{ + Type: model.NextHopType, + Value: model.NextHopValue, + }, + } + case "ipv6": + return &iaasalpha.RouteNexthop{ + NexthopIPv6: &iaasalpha.NexthopIPv6{ + Type: model.NextHopType, + Value: model.NextHopValue, + }, + } + case "internet": + return &iaasalpha.RouteNexthop{ + NexthopInternet: &iaasalpha.NexthopInternet{ + Type: model.NextHopType, + }, + } + case "blackhole": + return &iaasalpha.RouteNexthop{ + NexthopBlackhole: &iaasalpha.NexthopBlackhole{ + Type: model.NextHopType, + }, + } + default: + return nil + } +} + +func outputResult(p *print.Printer, outputFormat string, items []iaasalpha.Route) error { + if len(items) == 0 { + return fmt.Errorf("create routes response is empty") + } + + switch outputFormat { + case print.JSONOutputFormat: + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("marshal routes: %w", err) + } + p.Outputln(string(data)) + case print.YAMLOutputFormat: + data, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal routes: %w", err) + } + p.Outputln(string(data)) + default: + table := tables.NewTable() + table.SetHeader("ID", "DEST. TYPE", "DEST. VALUE", "NEXTHOP TYPE", "NEXTHOP VALUE", "LABELS", "CREATED", "UPDATED") + for _, item := range items { + destType, destValue, hopType, hopValue, labels := routeUtils.ExtractRouteDetails(item) + + table.AddRow( + utils.PtrString(item.Id), + destType, + destValue, + hopType, + hopValue, + labels, + item.CreatedAt.String(), + item.UpdatedAt.String(), + ) + } + return table.Display(p) + } + return nil +} diff --git a/internal/cmd/beta/routingtable/route/create/create_test.go b/internal/cmd/beta/routingtable/route/create/create_test.go new file mode 100644 index 000000000..d79f94da7 --- /dev/null +++ b/internal/cmd/beta/routingtable/route/create/create_test.go @@ -0,0 +1,499 @@ +package create + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaasalpha.APIClient{} + +var testRegion = "eu01" +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +var testDestinationTypeFlag = "cidrv4" +var testDestinationValueFlag = "1.1.1.0/24" +var testNextHopTypeFlag = "ipv4" +var testNextHopValueFlag = "1.1.1.1" +var testLabelSelectorFlag = "key1=value1,key2=value2" +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + labelFlag: testLabelSelectorFlag, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + destinationTypeFlag: testDestinationTypeFlag, + destinationValueFlag: testDestinationValueFlag, + nextHopTypeFlag: testNextHopTypeFlag, + nextHopValueFlag: testNextHopValueFlag, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: utils.Ptr(testOrgId), + NetworkAreaId: utils.Ptr(testNetworkAreaId), + RoutingTableId: utils.Ptr(testRoutingTableId), + DestinationType: utils.Ptr(testDestinationTypeFlag), + DestinationValue: utils.Ptr(testDestinationValueFlag), + NextHopType: utils.Ptr(testNextHopTypeFlag), + NextHopValue: utils.Ptr(testNextHopValueFlag), + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaasalpha.ApiAddRoutesToRoutingTableRequest)) iaasalpha.ApiAddRoutesToRoutingTableRequest { + request := testClient.AddRoutesToRoutingTable(testCtx, testOrgId, testNetworkAreaId, testRegion, testRoutingTableId) + request = request.AddRoutesToRoutingTablePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaasalpha.AddRoutesToRoutingTablePayload)) iaasalpha.AddRoutesToRoutingTablePayload { + payload := iaasalpha.AddRoutesToRoutingTablePayload{ + Items: &[]iaasalpha.Route{ + { + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv4: &iaasalpha.DestinationCIDRv4{ + Type: utils.Ptr(testDestinationTypeFlag), + Value: utils.Ptr(testDestinationValueFlag), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopIPv4: &iaasalpha.NexthopIPv4{ + Type: utils.Ptr(testNextHopTypeFlag), + Value: utils.Ptr(testNextHopValueFlag), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + }, + }, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description 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) + }), + isValid: false, + }, + { + description: "nexthop-type missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nextHopTypeFlag) + }), + isValid: false, + }, + { + description: "nexthop-value missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "org id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "org id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "org area id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "network area id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "network area id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "network area id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "nexthop-type is internet and nexthop-value is provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "internet" + flagValues[nextHopValueFlag] = "1.1.1.1" // should not be allowed + }), + isValid: false, + }, + { + description: "nexthop-type is blackhole and nexthop-value is provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "blackhole" + flagValues[nextHopValueFlag] = "1.1.1.1" + }), + isValid: false, + }, + { + description: "nexthop-type is internet and nexthop-value is not provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "internet" + delete(flagValues, nextHopValueFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NextHopType = utils.Ptr("internet") + model.NextHopValue = nil + }), + isValid: true, + }, + { + description: "nexthop-type is blackhole and nexthop-value is not provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "blackhole" + delete(flagValues, nextHopValueFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NextHopType = utils.Ptr("blackhole") + model.NextHopValue = nil + }), + isValid: true, + }, + { + description: "nexthop-type is ipv4 and nexthop-value is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "ipv4" + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + description: "nexthop-type is ipv6 and nexthop-value is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "ipv6" + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + description: "invalid nexthop-type provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "invalid-type" + }), + isValid: false, + }, + { + description: "optional labels is provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelFlag] = "key=value" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = utils.Ptr(map[string]string{"key": "value"}) + }), + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaasalpha.ApiAddRoutesToRoutingTableRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "optional labels provided", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = utils.Ptr(map[string]string{"key": "value"}) + }), + expectedRequest: fixtureRequest(func(request *iaasalpha.ApiAddRoutesToRoutingTableRequest) { + *request = (*request).AddRoutesToRoutingTablePayload(fixturePayload(func(payload *iaasalpha.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(map[string]string{"key": "value"})) + })) + }), + }, + { + description: "destination is cidrv6 and nexthop is ipv6", + model: fixtureInputModel(func(model *inputModel) { + model.DestinationType = utils.Ptr("cidrv6") + model.DestinationValue = utils.Ptr("2001:db8::/32") + model.NextHopType = utils.Ptr("ipv6") + model.NextHopValue = utils.Ptr("2001:db8::1") + }), + expectedRequest: fixtureRequest(func(request *iaasalpha.ApiAddRoutesToRoutingTableRequest) { + *request = (*request).AddRoutesToRoutingTablePayload(iaasalpha.AddRoutesToRoutingTablePayload{ + Items: &[]iaasalpha.Route{ + { + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv6: &iaasalpha.DestinationCIDRv6{ + Type: utils.Ptr("cidrv6"), + Value: utils.Ptr("2001:db8::/32"), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopIPv6: &iaasalpha.NexthopIPv6{ + Type: utils.Ptr("ipv6"), + Value: utils.Ptr("2001:db8::1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + }, + }, + }) + }), + }, + { + description: "nexthop type is internet (no value)", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = utils.Ptr("internet") + model.NextHopValue = nil + }), + expectedRequest: fixtureRequest(func(request *iaasalpha.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaasalpha.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaasalpha.RouteNexthop{ + NexthopInternet: &iaasalpha.NexthopInternet{ + Type: utils.Ptr("internet"), + }, + } + }) + *request = (*request).AddRoutesToRoutingTablePayload(payload) + }), + }, + { + description: "nexthop type is blackhole (no value)", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = utils.Ptr("blackhole") + model.NextHopValue = nil + }), + expectedRequest: fixtureRequest(func(request *iaasalpha.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaasalpha.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaasalpha.RouteNexthop{ + NexthopBlackhole: &iaasalpha.NexthopBlackhole{ + Type: utils.Ptr("blackhole"), + }, + } + }) + *request = (*request).AddRoutesToRoutingTablePayload(payload) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("buildRequest returned error: %v", err) + } + + if diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx)); diff != "" { + t.Errorf("buildRequest() mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaasalpha.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv4: &iaasalpha.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopIPv4: &iaasalpha.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + items []iaasalpha.Route + wantErr bool + }{ + { + name: "nil items should return error", + outputFormat: "", + items: nil, + wantErr: true, + }, + { + name: "empty items list", + outputFormat: "", + items: []iaasalpha.Route{}, + wantErr: true, + }, + { + name: "table output with one route", + outputFormat: "", + items: []iaasalpha.Route{dummyRoute}, + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + items: []iaasalpha.Route{dummyRoute}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + items: []iaasalpha.Route{dummyRoute}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, tt.items); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/routingtable/route/delete/delete.go b/internal/cmd/beta/routingtable/route/delete/delete.go new file mode 100644 index 000000000..05064ad70 --- /dev/null +++ b/internal/cmd/beta/routingtable/route/delete/delete.go @@ -0,0 +1,123 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/iaas/client" +) + +const ( + organizationIdFlag = "organization-id" + networkAreaIdFlag = "network-area-id" + routingTableIdFlag = "routing-table-id" + routeIdArg = "ROUTE_ID_ARG" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId *string + NetworkAreaId *string + RoutingTableId *string + RouteID *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", routingTableIdFlag), + Short: "Deletes a route within a routing-table", + Long: "Deletes a route within a routing-table", + Args: args.SingleArg(routeIdArg, nil), + Example: examples.Build( + examples.NewExample( + `Deletes a route within a routing-table`, + `$ stackit beta routing-table route delete xxxx-xxxx-xxxx-xxxx --routing-table-id xxx --organization-id yyy --network-area-id zzz`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureAlphaClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the route %q in routing-table %q for network-area-id %q?", *model.RouteID, *model.RoutingTableId, *model.OrganizationId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := apiClient.DeleteRouteFromRoutingTable( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + *model.RouteID, + ) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete route from routing-table: %w", err) + } + + params.Printer.Outputf("Route %q from routing-table %q deleted.", *model.RouteID, *model.RoutingTableId) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + if len(inputArgs) == 0 { + return nil, fmt.Errorf("at least one argument is required") + } + routeId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + RouteID: &routeId, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} diff --git a/internal/cmd/beta/routingtable/route/delete/delete_test.go b/internal/cmd/beta/routingtable/route/delete/delete_test.go new file mode 100644 index 000000000..ab842a7fb --- /dev/null +++ b/internal/cmd/beta/routingtable/route/delete/delete_test.go @@ -0,0 +1,141 @@ +package delete + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +var ( + testOrgId = uuid.NewString() + testNetworkAreaId = uuid.NewString() + testRoutingTableId = uuid.NewString() + testRouteId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.InfoVerbosity, + }, + OrganizationId: &testOrgId, + NetworkAreaId: &testNetworkAreaId, + RoutingTableId: &testRoutingTableId, + RouteID: &testRouteId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + args []string + flagValues map[string]string + isValid bool + expectedRoute *inputModel + }{ + { + description: "valid input", + args: []string{testRouteId}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedRoute: fixtureInputModel(func(m *inputModel) { + m.RouteID = &testRouteId + }), + }, + { + description: "missing route id arg", + args: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing organization-id flag", + args: []string{testRouteId}, + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, "organization-id") + }), + isValid: false, + }, + { + description: "missing network-area-id flag", + args: []string{testRouteId}, + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, "network-area-id") + }), + isValid: false, + }, + { + description: "missing routing-table-id flag", + args: []string{testRouteId}, + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, "routing-table-id") + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.args) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedRoute) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/routingtable/route/describe/describe.go b/internal/cmd/beta/routingtable/route/describe/describe.go new file mode 100644 index 000000000..6d4a0eeda --- /dev/null +++ b/internal/cmd/beta/routingtable/route/describe/describe.go @@ -0,0 +1,205 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +const ( + organizationIdFlag = "organization-id" + networkAreaIdFlag = "network-area-id" + routingTableIdFlag = "routing-table-id" + routeIdArg = "ROUTE_ID_ARG" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId *string + NetworkAreaId *string + RoutingTableId *string + RouteID *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", routeIdArg), + Short: "Describe a route within a routing-table", + Long: "Describe a route within a routing-table", + Args: args.SingleArg(routeIdArg, nil), + Example: examples.Build( + examples.NewExample( + `Describe a route within a routing-table`, + `$ stackit beta routing-table route describe xxxx-xxxx-xxxx-xxxx --routing-table-id xxx --organization-id yyy --network-area-id zzz`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureAlphaClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := apiClient.GetRouteOfRoutingTable( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + *model.RouteID, + ) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("describe route: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, response) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, args []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + if len(args) == 0 { + return nil, fmt.Errorf("at least one argument is required") + } + routeId := args[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + RouteID: &routeId, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, routingTable *iaasalpha.Route) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(routingTable, "", " ") + if err != nil { + return fmt.Errorf("marshal route describe: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(routingTable, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal route describe: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + var labels []string + for key, value := range *routingTable.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + + destinationType := "" + destinationValue := "" + if dest := routingTable.Destination.DestinationCIDRv4; dest != nil { + if dest.Type != nil { + destinationType = *dest.Type + } + if dest.Value != nil { + destinationValue = *dest.Value + } + } + if dest := routingTable.Destination.DestinationCIDRv6; dest != nil { + if dest.Type != nil { + destinationType = *dest.Type + } + if dest.Value != nil { + destinationValue = *dest.Value + } + } + + nextHopType := "" + nextHopValue := "" + if nextHop := routingTable.Destination.DestinationCIDRv4; nextHop != nil { + if nextHop.Type != nil { + nextHopType = *nextHop.Type + } + if nextHop.Value != nil { + nextHopValue = *nextHop.Value + } + } + if nextHop := routingTable.Destination.DestinationCIDRv6; nextHop != nil { + if nextHop.Type != nil { + nextHopType = *nextHop.Type + } + if nextHop.Value != nil { + nextHopValue = *nextHop.Value + } + } + + table := tables.NewTable() + table.SetHeader("ID", "CREATED_AT", "UPDATED_AT", "DESTINATION TYPE", "DESTINATION VALUE", "NEXTHOP TYPE", "NEXTHOP VALUE", "LABELS") + table.AddRow( + utils.PtrString(routingTable.Id), + routingTable.CreatedAt.String(), + routingTable.UpdatedAt.String(), + destinationType, + destinationValue, + nextHopType, + nextHopValue, + strings.Join(labels, "\n"), + ) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/beta/routingtable/route/describe/describe_test.go b/internal/cmd/beta/routingtable/route/describe/describe_test.go new file mode 100644 index 000000000..63706b8d3 --- /dev/null +++ b/internal/cmd/beta/routingtable/route/describe/describe_test.go @@ -0,0 +1,225 @@ +package describe + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +var testRegion = "eu01" +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() +var testRouteId = uuid.NewString() + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: utils.Ptr(testOrgId), + NetworkAreaId: utils.Ptr(testNetworkAreaId), + RoutingTableId: utils.Ptr(testRoutingTableId), + RouteID: utils.Ptr(testRouteId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRouteId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +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: "routing-table-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "network-area-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "org-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "routing-table-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "routing-id missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routeIdArg) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaasalpha.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv4: &iaasalpha.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopIPv4: &iaasalpha.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + route iaasalpha.Route + wantErr bool + }{ + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + route: dummyRoute, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + route: dummyRoute, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, &tt.route); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/routingtable/route/list/list.go b/internal/cmd/beta/routingtable/route/list/list.go new file mode 100644 index 000000000..5c35eed0c --- /dev/null +++ b/internal/cmd/beta/routingtable/route/list/list.go @@ -0,0 +1,191 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/iaas/client" + routeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/routing-table/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +const ( + organizationIdFlag = "organization-id" + networkAreaIdFlag = "network-area-id" + routingTableIdFlag = "routing-table-id" + labelSelectorFlag = "label-selector" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId *string + NetworkAreaId *string + RoutingTableId *string + LabelSelector *string + Limit *int64 +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "list all routes within a routing-table", + Long: "list all routes within a routing-table", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all routes within a routing-table`, + `$ stackit beta routing-table route list --routing-table-id xxx --organization-id yyy --network-area-id zzz`, + ), + examples.NewExample( + `List all routes within a routing-table with labels`, + `$ stackit beta routing-table list --routing-table-id xxx --organization-id yyy --network-area-id zzz --label-selector env=dev,env=rc`, + ), + examples.NewExample( + `List all routes within a routing-tables with labels and limit to 10`, + `$ stackit beta routing-table list --routing-table-id xxx --organization-id yyy --network-area-id zzz --label-selector env=dev,env=rc --limit 10`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureAlphaClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := apiClient.ListRoutesOfRoutingTable( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + ) + + if model.LabelSelector != nil { + request.LabelSelector(*model.LabelSelector) + } + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("list routes: %w", err) + } + + if items := response.Items; items == nil || len(*items) == 0 { + params.Printer.Info("No routes found for routing-table %q\n", *model.RoutingTableId) + return nil + } + + // Truncate output + items := *response.Items + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, items) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + cmd.Flags().String(labelSelectorFlag, "", "Filter by label") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*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, + Limit: limit, + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, items []iaasalpha.Route) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("marshal routes list: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal routes list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "DEST. TYPE", "DEST. VALUE", "NEXTHOP TYPE", "NEXTHOP VALUE", "LABELS", "CREATED", "UPDATED") + for _, item := range items { + destType, destValue, hopType, hopValue, labels := routeUtils.ExtractRouteDetails(item) + + table.AddRow( + utils.PtrString(item.Id), + destType, + destValue, + hopType, + hopValue, + labels, + item.CreatedAt.String(), + item.UpdatedAt.String(), + ) + } + return table.Display(p) + } +} diff --git a/internal/cmd/beta/routingtable/route/list/list_test.go b/internal/cmd/beta/routingtable/route/list/list_test.go new file mode 100644 index 000000000..3a9b07763 --- /dev/null +++ b/internal/cmd/beta/routingtable/route/list/list_test.go @@ -0,0 +1,240 @@ +package list + +import ( + "strconv" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +var testRegion = "eu01" +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +var testLabelSelectorFlag = "key1=value1,key2=value2" +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +var testLimitFlag = int64(10) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + labelSelectorFlag: testLabelSelectorFlag, + limitFlag: strconv.Itoa(int(testLimitFlag)), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: utils.Ptr(testOrgId), + NetworkAreaId: utils.Ptr(testNetworkAreaId), + RoutingTableId: utils.Ptr(testRoutingTableId), + LabelSelector: utils.Ptr(testLabelSelectorFlag), + Limit: utils.Ptr(testLimitFlag), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]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: "network-area-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "org-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "routing-table-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaasalpha.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv4: &iaasalpha.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopIPv4: &iaasalpha.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routes []iaasalpha.Route + wantErr bool + }{ + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + routes: []iaasalpha.Route{dummyRoute}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + routes: []iaasalpha.Route{dummyRoute}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, tt.routes); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/routingtable/route/route.go b/internal/cmd/beta/routingtable/route/route.go new file mode 100644 index 000000000..cfca28e1a --- /dev/null +++ b/internal/cmd/beta/routingtable/route/route.go @@ -0,0 +1,33 @@ +package route + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/routingtable/route/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/routingtable/route/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/routingtable/route/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/routingtable/route/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/routingtable/route/update" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "route", + Short: "Manage routes of a routing-table", + Long: "Manage routes of a routing-table", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.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/beta/routingtable/route/update/update.go b/internal/cmd/beta/routingtable/route/update/update.go new file mode 100644 index 000000000..9659b24b5 --- /dev/null +++ b/internal/cmd/beta/routingtable/route/update/update.go @@ -0,0 +1,158 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +const ( + organizationIdFlag = "organization-id" + networkAreaIdFlag = "network-area-id" + routingTableIdFlag = "routing-table-id" + labelFlag = "labels" + routeIdArg = "ROUTE_ID_ARG" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId *string + NetworkAreaId *string + RoutingTableId *string + RouteId string + Labels *map[string]string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", routeIdArg), + Short: "Updates a route in a routing-table", + Long: "Updates a route in a routing-table.", + Args: args.SingleArg(routeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Updates the label(s) of a route with ID "xxx" in a routing-table ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit beta routing-table route update xxx --labels key=value,foo=bar --routing-table-id xxx --organization-id yyy --network-area-id zzz", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureAlphaClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := apiClient.UpdateRouteOfRoutingTable( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + model.RouteId, + ) + + payload := iaasalpha.UpdateRouteOfRoutingTablePayload{ + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + } + req = req.UpdateRouteOfRoutingTablePayload(payload) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update route %q of routing-table %q : %w", model.RouteId, *model.RoutingTableId, err) + } + + return outputResult(params.Printer, model.OutputFormat, *model.RoutingTableId, *model.NetworkAreaId, *resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area 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 route. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels") + + err := flags.MarkFlagsRequired(cmd, labelFlag, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + if len(inputArgs) == 0 { + return nil, fmt.Errorf("at least one argument is required") + } + routeId := inputArgs[0] + + labels := flags.FlagToStringToStringPointer(p, cmd, labelFlag) + + if labels == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + RouteId: routeId, + Labels: labels, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, routingTableId, networkAreaId string, route iaasalpha.Route) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(route, "", " ") + if err != nil { + return fmt.Errorf("marshal route: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(route, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal route: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Updated static route %q for routing-table %q in network-are %q.", *route.Id, routingTableId, networkAreaId) + return nil + } +} diff --git a/internal/cmd/beta/routingtable/route/update/update_test.go b/internal/cmd/beta/routingtable/route/update/update_test.go new file mode 100644 index 000000000..2a0ed75ff --- /dev/null +++ b/internal/cmd/beta/routingtable/route/update/update_test.go @@ -0,0 +1,236 @@ +package update + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +var testRegion = "eu01" +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() +var testRouteId = uuid.NewString() + +var testLabelSelectorFlag = "key1=value1,key2=value2" +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + labelFlag: testLabelSelectorFlag, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: utils.Ptr(testOrgId), + NetworkAreaId: utils.Ptr(testNetworkAreaId), + RoutingTableId: utils.Ptr(testRoutingTableId), + RouteId: testRouteId, + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRouteId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +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: "routing-table-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "network-area-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "org-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "routing-table-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "routing-id missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routeIdArg) + }), + isValid: false, + }, + { + description: "labels are missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaasalpha.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv4: &iaasalpha.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopIPv4: &iaasalpha.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + route iaasalpha.Route + wantErr bool + }{ + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + route: dummyRoute, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + route: dummyRoute, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, "", "", tt.route); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/routingtable/routingtable.go b/internal/cmd/beta/routingtable/routingtable.go new file mode 100644 index 000000000..bcc926138 --- /dev/null +++ b/internal/cmd/beta/routingtable/routingtable.go @@ -0,0 +1,31 @@ +package routingtable + +import ( + "github.com/spf13/cobra" + rtDescribe "github.com/stackitcloud/stackit-cli/internal/cmd/beta/routingtable/describe" + rtList "github.com/stackitcloud/stackit-cli/internal/cmd/beta/routingtable/list" + route "github.com/stackitcloud/stackit-cli/internal/cmd/beta/routingtable/route" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "routing-table", + Short: "Manage routing-tables and its according routes", + Long: "Manage routing-tables and its according routes", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand( + rtList.NewCmd(params), + rtDescribe.NewCmd(params), + route.NewCmd(params), + ) +} diff --git a/internal/pkg/services/iaas/client/alphaclient.go b/internal/pkg/services/iaas/client/alphaclient.go new file mode 100644 index 000000000..0f9db99f9 --- /dev/null +++ b/internal/pkg/services/iaas/client/alphaclient.go @@ -0,0 +1,44 @@ +package client + +import ( + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +func ConfigureAlphaClient(p *print.Printer, cliVersion string) (*iaasalpha.APIClient, error) { + authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) + if err != nil { + p.Debug(print.ErrorLevel, "configure authentication: %v", err) + return nil, &errors.AuthError{} + } + cfgOptions := []sdkConfig.ConfigurationOption{ + utils.UserAgentConfigOption(cliVersion), + authCfgOption, + } + + customEndpoint := viper.GetString(config.IaaSCustomEndpointKey) + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + + apiClient, err := iaasalpha.NewAPIClient(cfgOptions...) + if err != nil { + p.Debug(print.ErrorLevel, "create new API client: %v", err) + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/routing-table/utils/utils.go b/internal/pkg/services/routing-table/utils/utils.go new file mode 100644 index 000000000..02afac663 --- /dev/null +++ b/internal/pkg/services/routing-table/utils/utils.go @@ -0,0 +1,40 @@ +package utils + +import ( + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +func ExtractRouteDetails(item iaasalpha.Route) (destType, destValue, hopType, hopValue, labels string) { + if item.Destination.DestinationCIDRv4 != nil { + destType = utils.PtrString(item.Destination.DestinationCIDRv4.Type) + destValue = utils.PtrString(item.Destination.DestinationCIDRv4.Value) + } else if item.Destination.DestinationCIDRv6 != nil { + destType = utils.PtrString(item.Destination.DestinationCIDRv6.Type) + destValue = utils.PtrString(item.Destination.DestinationCIDRv6.Value) + } + + if item.Nexthop.NexthopIPv4 != nil { + hopType = utils.PtrString(item.Nexthop.NexthopIPv4.Type) + hopValue = utils.PtrString(item.Nexthop.NexthopIPv4.Value) + } else if item.Nexthop.NexthopIPv6 != nil { + hopType = utils.PtrString(item.Nexthop.NexthopIPv6.Type) + hopValue = utils.PtrString(item.Nexthop.NexthopIPv6.Value) + } else if item.Nexthop.NexthopInternet != nil { + hopType = utils.PtrString(item.Nexthop.NexthopInternet.Type) + } else if item.Nexthop.NexthopBlackhole != nil { + hopType = utils.PtrString(item.Nexthop.NexthopBlackhole.Type) + } + + var sortedLabels []string + if item.Labels != nil && len(*item.Labels) > 0 { + for key, value := range *item.Labels { + sortedLabels = append(sortedLabels, fmt.Sprintf("%s: %s", key, value)) + } + } + + return destType, destValue, hopType, hopValue, strings.Join(sortedLabels, ",") +} diff --git a/internal/pkg/services/routing-table/utils/utils_test.go b/internal/pkg/services/routing-table/utils/utils_test.go new file mode 100644 index 000000000..eda0626c8 --- /dev/null +++ b/internal/pkg/services/routing-table/utils/utils_test.go @@ -0,0 +1,135 @@ +package utils + +import ( + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +func TestExtractRouteDetails(t *testing.T) { + tests := []struct { + description string + input *iaasalpha.Route + wantDestType string + wantDestValue string + wantHopType string + wantHopValue string + wantLabels string + }{ + { + description: "CIDRv4 destination, IPv4 nexthop, with labels", + input: &iaasalpha.Route{ + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv4: &iaasalpha.DestinationCIDRv4{ + Type: utils.Ptr("CIDRv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopIPv4: &iaasalpha.NexthopIPv4{ + Type: utils.Ptr("IPv4"), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + wantDestType: "CIDRv4", + wantDestValue: "10.0.0.0/24", + wantHopType: "IPv4", + wantHopValue: "10.0.0.1", + wantLabels: "key=value", + }, + { + description: "CIDRv6 destination, IPv6 nexthop, with no labels", + input: &iaasalpha.Route{ + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv6: &iaasalpha.DestinationCIDRv6{ + Type: utils.Ptr("CIDRv6"), + Value: utils.Ptr("2001:db8::/32"), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopIPv4: &iaasalpha.NexthopIPv4{ + Type: utils.Ptr("IPv6"), + Value: utils.Ptr("2001:db8::1"), + }, + }, + Labels: nil, + }, + wantDestType: "CIDRv6", + wantDestValue: "2001:db8::/32", + wantHopType: "IPv6", + wantHopValue: "2001:db8::1", + wantLabels: "", + }, + { + description: "Internet nexthop without value", + input: &iaasalpha.Route{ + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv4: &iaasalpha.DestinationCIDRv4{ + Type: utils.Ptr("CIDRv4"), + Value: utils.Ptr("0.0.0.0/0"), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopInternet: &iaasalpha.NexthopInternet{ + Type: utils.Ptr("Internet"), + }, + }, + Labels: nil, + }, + wantDestType: "CIDRv4", + wantDestValue: "0.0.0.0/0", + wantHopType: "Internet", + wantHopValue: "", + wantLabels: "", + }, + { + description: "Blackhole nexthop without value and nil labels map", + input: &iaasalpha.Route{ + Destination: &iaasalpha.RouteDestination{ + DestinationCIDRv6: &iaasalpha.DestinationCIDRv6{ + Type: utils.Ptr("CIDRv6"), + Value: utils.Ptr("::/0"), + }, + }, + Nexthop: &iaasalpha.RouteNexthop{ + NexthopBlackhole: &iaasalpha.NexthopBlackhole{ + Type: utils.Ptr("Blackhole"), + }, + }, + Labels: nil, + }, + wantDestType: "CIDRv6", + wantDestValue: "::/0", + wantHopType: "Blackhole", + wantHopValue: "", + wantLabels: "", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + destType, destValue, hopType, hopValue, labels := ExtractRouteDetails(*tt.input) + + if destType != tt.wantDestType { + t.Errorf("destType = %v, want %v", destType, tt.wantDestType) + } + if destValue != tt.wantDestValue { + t.Errorf("destValue = %v, want %v", destValue, tt.wantDestValue) + } + if hopType != tt.wantHopType { + t.Errorf("hopType = %v, want %v", hopType, tt.wantHopType) + } + if hopValue != tt.wantHopValue { + t.Errorf("hopValue = %v, want %v", hopValue, tt.wantHopValue) + } + if (tt.wantLabels != "" && labels == "") || (tt.wantLabels == "" && labels != "") { + t.Errorf("labels mismatch: got %q, want %q", labels, tt.wantLabels) + } + }) + } +}