diff --git a/docs/stackit.md b/docs/stackit.md index d0ddc4554..afa5a0c96 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -52,6 +52,7 @@ stackit [flags] * [stackit quota](./stackit_quota.md) - Manage server quotas * [stackit rabbitmq](./stackit_rabbitmq.md) - Provides functionality for RabbitMQ * [stackit redis](./stackit_redis.md) - Provides functionality for Redis +* [stackit routing-table](./stackit_routing-table.md) - Manage routing-tables and its according routes * [stackit secrets-manager](./stackit_secrets-manager.md) - Provides functionality for Secrets Manager * [stackit security-group](./stackit_security-group.md) - Manage security groups * [stackit server](./stackit_server.md) - Provides functionality for servers diff --git a/docs/stackit_network_create.md b/docs/stackit_network_create.md index 146264977..44934bee3 100644 --- a/docs/stackit_network_create.md +++ b/docs/stackit_network_create.md @@ -30,6 +30,9 @@ stackit network create [flags] Create an IPv6 network with name "network-1" with DNS name servers, a prefix and a gateway $ stackit network create --name network-1 --ipv6-dns-name-servers "2001:4860:4860::8888,2001:4860:4860::8844" --ipv6-prefix "2001:4860:4860::8888" --ipv6-gateway "2001:4860:4860::8888" + + Create a network with name "network-1" and attach routing-table "xxx" + $ stackit network create --name network-1 --routing-table-id xxx ``` ### Options @@ -49,6 +52,7 @@ stackit network create [flags] --no-ipv4-gateway If set to true, the network doesn't have an IPv4 gateway --no-ipv6-gateway If set to true, the network doesn't have an IPv6 gateway --non-routed If set to true, the network is not routed and therefore not accessible from other networks + --routing-table-id string The ID of the routing-table for the network ``` ### Options inherited from parent commands diff --git a/docs/stackit_network_update.md b/docs/stackit_network_update.md index 313ce68fa..7069b26d2 100644 --- a/docs/stackit_network_update.md +++ b/docs/stackit_network_update.md @@ -24,6 +24,9 @@ stackit network update NETWORK_ID [flags] Update IPv6 network with ID "xxx" with new name "network-1-new", new gateway and new DNS name servers $ stackit network update xxx --name network-1-new --ipv6-dns-name-servers "2001:4860:4860::8888" --ipv6-gateway "2001:4860:4860::8888" + + Update network with ID "xxx" with new routing-table id "xxx" + $ stackit network update xxx --routing-table-id xxx ``` ### Options @@ -38,6 +41,7 @@ stackit network update NETWORK_ID [flags] -n, --name string Network name --no-ipv4-gateway If set to true, the network doesn't have an IPv4 gateway --no-ipv6-gateway If set to true, the network doesn't have an IPv6 gateway + --routing-table-id string The ID of the routing-table for the network ``` ### Options inherited from parent commands diff --git a/docs/stackit_routing-table.md b/docs/stackit_routing-table.md new file mode 100644 index 000000000..accc36f68 --- /dev/null +++ b/docs/stackit_routing-table.md @@ -0,0 +1,42 @@ +## stackit routing-table + +Manage routing-tables and its according routes + +### Synopsis + +Manage routing-tables and their associated routes. + +This API is currently available only to selected customers. +To request access, please contact your account manager or submit a support ticket. + +``` +stackit routing-table [flags] +``` + +### Options + +``` + -h, --help Help for "stackit 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](./stackit.md) - Manage STACKIT resources using the command line +* [stackit routing-table create](./stackit_routing-table_create.md) - Creates a routing-table +* [stackit routing-table delete](./stackit_routing-table_delete.md) - Deletes a routing-table +* [stackit routing-table describe](./stackit_routing-table_describe.md) - Describes a routing-table +* [stackit routing-table list](./stackit_routing-table_list.md) - Lists all routing-tables +* [stackit routing-table route](./stackit_routing-table_route.md) - Manages routes of a routing-table +* [stackit routing-table update](./stackit_routing-table_update.md) - Updates a routing-table + diff --git a/docs/stackit_routing-table_create.md b/docs/stackit_routing-table_create.md new file mode 100644 index 000000000..d04fefa64 --- /dev/null +++ b/docs/stackit_routing-table_create.md @@ -0,0 +1,56 @@ +## stackit routing-table create + +Creates a routing-table + +### Synopsis + +Creates a routing-table. + +``` +stackit routing-table create [flags] +``` + +### Examples + +``` + Create a routing-table with name `rt` + stackit routing-table create --organization-id xxx --network-area-id yyy --name "rt" + + Create a routing-table with name `rt` and description `some description` + stackit routing-table create --organization-id xxx --network-area-id yyy --name "rt" --description "some description" + + Create a routing-table with name `rt` with system_routes disabled + stackit routing-table create --organization-id xxx --network-area-id yyy --name "rt" --non-system-routes + + Create a routing-table with name `rt` with dynamic_routes disabled + stackit routing-table create --organization-id xxx --network-area-id yyy --name "rt" --non-dynamic-routes +``` + +### Options + +``` + --description string Description of the routing-table + -h, --help Help for "stackit routing-table create" + --labels stringToString Key=value labels (default []) + --name string Name of the routing-table + --network-area-id string Network-Area ID + --non-dynamic-routes If true, preventing dynamic routes from propagating to the routing-table. + --non-system-routes If true, automatically disables routes for project-to-project communication. + --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 routing-table](./stackit_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_routing-table_delete.md b/docs/stackit_routing-table_delete.md new file mode 100644 index 000000000..d40dab675 --- /dev/null +++ b/docs/stackit_routing-table_delete.md @@ -0,0 +1,42 @@ +## stackit routing-table delete + +Deletes a routing-table + +### Synopsis + +Deletes a routing-table + +``` +stackit routing-table delete ROUTING_TABLE_ARG [flags] +``` + +### Examples + +``` + Deletes a a routing-table + $ stackit routing-table delete xxxx-xxxx-xxxx-xxxx --organization-id yyy --network-area-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit routing-table delete" + --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 routing-table](./stackit_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_routing-table_describe.md b/docs/stackit_routing-table_describe.md new file mode 100644 index 000000000..5d7c8fd09 --- /dev/null +++ b/docs/stackit_routing-table_describe.md @@ -0,0 +1,42 @@ +## stackit routing-table describe + +Describes a routing-table + +### Synopsis + +Describes a routing-table + +``` +stackit routing-table describe ROUTING_TABLE_ID_ARG [flags] +``` + +### Examples + +``` + Describe a routing-table + $ stackit routing-table describe xxxx-xxxx-xxxx-xxxx --organization-id xxx --network-area-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit 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 routing-table](./stackit_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_routing-table_list.md b/docs/stackit_routing-table_list.md new file mode 100644 index 000000000..d45350821 --- /dev/null +++ b/docs/stackit_routing-table_list.md @@ -0,0 +1,50 @@ +## stackit routing-table list + +Lists all routing-tables + +### Synopsis + +Lists all routing-tables + +``` +stackit routing-table list [flags] +``` + +### Examples + +``` + List all routing-tables + $ stackit routing-table list --organization-id xxx --network-area-id yyy + + List all routing-tables with labels + $ stackit routing-table list --label-selector env=dev,env=rc --organization-id xxx --network-area-id yyy + + List all routing-tables with labels and set limit to 10 + $ stackit routing-table list --label-selector env=dev,env=rc --limit 10 --organization-id xxx --network-area-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit 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 routing-table](./stackit_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_routing-table_route.md b/docs/stackit_routing-table_route.md new file mode 100644 index 000000000..aa28d570d --- /dev/null +++ b/docs/stackit_routing-table_route.md @@ -0,0 +1,38 @@ +## stackit routing-table route + +Manages routes of a routing-table + +### Synopsis + +Manages routes of a routing-table + +``` +stackit routing-table route [flags] +``` + +### Options + +``` + -h, --help Help for "stackit 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 routing-table](./stackit_routing-table.md) - Manage routing-tables and its according routes +* [stackit routing-table route create](./stackit_routing-table_route_create.md) - Creates a route in a routing-table +* [stackit routing-table route delete](./stackit_routing-table_route_delete.md) - Deletes a route within a routing-table +* [stackit routing-table route describe](./stackit_routing-table_route_describe.md) - Describes a route within a routing-table +* [stackit routing-table route list](./stackit_routing-table_route_list.md) - Lists all routes within a routing-table +* [stackit routing-table route update](./stackit_routing-table_route_update.md) - Updates a route in a routing-table + diff --git a/docs/stackit_routing-table_route_create.md b/docs/stackit_routing-table_route_create.md new file mode 100644 index 000000000..4dff90c8b --- /dev/null +++ b/docs/stackit_routing-table_route_create.md @@ -0,0 +1,63 @@ +## stackit routing-table route create + +Creates a route in a routing-table + +### Synopsis + +Creates a route in a routing-table. + +``` +stackit routing-table route create [flags] +``` + +### Examples + +``` + Create a route with CIDRv4 destination and IPv4 nexthop + stackit routing-table 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 routing-table 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 routing-table 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 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 routing-table route](./stackit_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_routing-table_route_delete.md b/docs/stackit_routing-table_route_delete.md new file mode 100644 index 000000000..bda6b9311 --- /dev/null +++ b/docs/stackit_routing-table_route_delete.md @@ -0,0 +1,43 @@ +## stackit routing-table route delete + +Deletes a route within a routing-table + +### Synopsis + +Deletes a route within a routing-table + +``` +stackit routing-table route delete routing-table-id [flags] +``` + +### Examples + +``` + Deletes a route within a routing-table + $ stackit 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 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 routing-table route](./stackit_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_routing-table_route_describe.md b/docs/stackit_routing-table_route_describe.md new file mode 100644 index 000000000..c9cb51f2b --- /dev/null +++ b/docs/stackit_routing-table_route_describe.md @@ -0,0 +1,43 @@ +## stackit routing-table route describe + +Describes a route within a routing-table + +### Synopsis + +Describes a route within a routing-table + +``` +stackit routing-table route describe ROUTE_ID_ARG [flags] +``` + +### Examples + +``` + Describe a route within a routing-table + $ stackit 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 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 routing-table route](./stackit_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_routing-table_route_list.md b/docs/stackit_routing-table_route_list.md new file mode 100644 index 000000000..597be772c --- /dev/null +++ b/docs/stackit_routing-table_route_list.md @@ -0,0 +1,51 @@ +## stackit routing-table route list + +Lists all routes within a routing-table + +### Synopsis + +Lists all routes within a routing-table + +``` +stackit routing-table route list [flags] +``` + +### Examples + +``` + List all routes within a routing-table + $ stackit 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 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 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 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 routing-table route](./stackit_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_routing-table_route_update.md b/docs/stackit_routing-table_route_update.md new file mode 100644 index 000000000..447512218 --- /dev/null +++ b/docs/stackit_routing-table_route_update.md @@ -0,0 +1,44 @@ +## stackit routing-table route update + +Updates a route in a routing-table + +### Synopsis + +Updates a route in a routing-table. + +``` +stackit 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 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 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 routing-table route](./stackit_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_routing-table_update.md b/docs/stackit_routing-table_update.md new file mode 100644 index 000000000..7afd5d16b --- /dev/null +++ b/docs/stackit_routing-table_update.md @@ -0,0 +1,55 @@ +## stackit routing-table update + +Updates a routing-table + +### Synopsis + +Updates a routing-table. + +``` +stackit routing-table update ROUTE_TABLE_ID_ARG [flags] +``` + +### Examples + +``` + Updates the label(s) of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit routing-table update xxx --labels key=value,foo=bar --organization-id yyy --network-area-id zzz + + Updates the name of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit routing-table update xxx --name foo --organization-id yyy --network-area-id zzz + + Updates the description of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit routing-table update xxx --description foo --organization-id yyy --network-area-id zzz + + Disables the dynamic_routes of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit routing-table update xxx --organization-id yyy --network-area-id zzz --non-dynamic-routes +``` + +### Options + +``` + --description string Description of the routing-table + -h, --help Help for "stackit routing-table update" + --labels stringToString Key=value labels (default []) + --name string Name of the routing-table + --network-area-id string Network-Area ID + --non-dynamic-routes If true, preventing dynamic routes from propagating to the routing-table. + --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 routing-table](./stackit_routing-table.md) - Manage routing-tables and its according routes + diff --git a/internal/cmd/network/create/create.go b/internal/cmd/network/create/create.go index 9877e4477..a44b9c822 100644 --- a/internal/cmd/network/create/create.go +++ b/internal/cmd/network/create/create.go @@ -34,6 +34,7 @@ const ( nonRoutedFlag = "non-routed" noIpv4GatewayFlag = "no-ipv4-gateway" noIpv6GatewayFlag = "no-ipv6-gateway" + routingTableIdFlag = "routing-table-id" labelFlag = "labels" ) @@ -51,6 +52,7 @@ type inputModel struct { NonRouted bool NoIPv4Gateway bool NoIPv6Gateway bool + RoutingTableID *string Labels *map[string]string } @@ -85,6 +87,10 @@ func NewCmd(params *params.CmdParams) *cobra.Command { `Create an IPv6 network with name "network-1" with DNS name servers, a prefix and a gateway`, `$ stackit network create --name network-1 --ipv6-dns-name-servers "2001:4860:4860::8888,2001:4860:4860::8844" --ipv6-prefix "2001:4860:4860::8888" --ipv6-gateway "2001:4860:4860::8888"`, ), + examples.NewExample( + `Create a network with name "network-1" and attach routing-table "xxx"`, + `$ stackit network create --name network-1 --routing-table-id xxx`, + ), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -158,6 +164,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(nonRoutedFlag, false, "If set to true, the network is not routed and therefore not accessible from other networks") cmd.Flags().Bool(noIpv4GatewayFlag, false, "If set to true, the network doesn't have an IPv4 gateway") cmd.Flags().Bool(noIpv6GatewayFlag, false, "If set to true, the network doesn't have an IPv6 gateway") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "The ID of the routing-table for the network") cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network. E.g. '--labels key1=value1,key2=value2,...'") // IPv4 checks @@ -196,6 +203,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, NonRouted: flags.FlagToBoolValue(p, cmd, nonRoutedFlag), NoIPv4Gateway: flags.FlagToBoolValue(p, cmd, noIpv4GatewayFlag), NoIPv6Gateway: flags.FlagToBoolValue(p, cmd, noIpv6GatewayFlag), + RoutingTableID: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), } @@ -294,11 +302,12 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } payload := iaas.CreateNetworkPayload{ - Name: model.Name, - Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), - Routed: &routed, - Ipv4: ipv4Network, - Ipv6: ipv6Network, + Name: model.Name, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + Routed: &routed, + Ipv4: ipv4Network, + Ipv6: ipv6Network, + RoutingTableId: model.RoutingTableID, } return req.CreateNetworkPayload(payload) diff --git a/internal/cmd/network/create/create_test.go b/internal/cmd/network/create/create_test.go index a73f7d07a..0b82ad56a 100644 --- a/internal/cmd/network/create/create_test.go +++ b/internal/cmd/network/create/create_test.go @@ -39,9 +39,10 @@ var ( type testCtxKey struct{} var ( - testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") - testClient = &iaas.APIClient{} - testProjectId = uuid.NewString() + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testRoutingTableId = uuid.NewString() ) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { @@ -49,9 +50,10 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st globalflags.ProjectIdFlag: testProjectId, globalflags.RegionFlag: testRegion, - nameFlag: testNetworkName, - nonRoutedFlag: strconv.FormatBool(testNonRouted), - labelFlag: "key=value", + nameFlag: testNetworkName, + nonRoutedFlag: strconv.FormatBool(testNonRouted), + labelFlag: "key=value", + routingTableIdFlag: testRoutingTableId, } for _, mod := range mods { mod(flagValues) @@ -101,6 +103,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { Labels: utils.Ptr(map[string]string{ "key": "value", }), + RoutingTableID: utils.Ptr(testRoutingTableId), } for _, mod := range mods { mod(model) @@ -168,6 +171,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateNetworkPayload)) iaas.Creat Labels: utils.Ptr(map[string]interface{}{ "key": "value", }), + RoutingTableId: utils.Ptr(testRoutingTableId), } for _, mod := range mods { mod(&payload) @@ -468,6 +472,14 @@ func TestParseInput(t *testing.T) { }), isValid: true, }, + { + description: "routing-table id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdFlag] = "invalid-uuid" + }), + expectedModel: nil, + isValid: false, + }, } for _, tt := range tests { @@ -530,6 +542,23 @@ func TestBuildRequest(t *testing.T) { Routed: utils.Ptr(false), }), }, + { + description: "network with routing-table id attached", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Name: utils.Ptr(testNetworkName), + RoutingTableID: utils.Ptr(testRoutingTableId), + }, + expectedRequest: testClient.CreateNetwork(testCtx, testProjectId, testRegion).CreateNetworkPayload(iaas.CreateNetworkPayload{ + Name: utils.Ptr(testNetworkName), + RoutingTableId: utils.Ptr(testRoutingTableId), + Routed: utils.Ptr(true), + }), + }, { description: "use ipv4 dns servers and prefix length", model: &inputModel{ diff --git a/internal/cmd/network/describe/describe.go b/internal/cmd/network/describe/describe.go index c7f0d08bc..9f85dead0 100644 --- a/internal/cmd/network/describe/describe.go +++ b/internal/cmd/network/describe/describe.go @@ -150,6 +150,11 @@ func outputResult(p *print.Printer, outputFormat string, network *iaas.Network) table.AddRow("ROUTED", routed) table.AddSeparator() + if network.RoutingTableId != nil { + table.AddRow("ROUTING-TABLE ID", utils.PtrString(network.RoutingTableId)) + table.AddSeparator() + } + if ipv4Gateway != nil { table.AddRow("IPv4 GATEWAY", *ipv4Gateway) table.AddSeparator() diff --git a/internal/cmd/network/list/list.go b/internal/cmd/network/list/list.go index e92ab31cc..01d5bd32f 100644 --- a/internal/cmd/network/list/list.go +++ b/internal/cmd/network/list/list.go @@ -140,7 +140,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli func outputResult(p *print.Printer, outputFormat string, networks []iaas.Network) error { return p.OutputResult(outputFormat, networks, func() error { table := tables.NewTable() - table.SetHeader("ID", "NAME", "STATUS", "PUBLIC IP", "PREFIXES", "ROUTED") + table.SetHeader("ID", "NAME", "STATUS", "PUBLIC IP", "PREFIXES", "ROUTED", "ROUTING TABLE ID") for _, network := range networks { var publicIp, prefixes string @@ -161,6 +161,7 @@ func outputResult(p *print.Printer, outputFormat string, networks []iaas.Network publicIp, prefixes, routed, + utils.PtrString(network.RoutingTableId), ) table.AddSeparator() } diff --git a/internal/cmd/network/update/update.go b/internal/cmd/network/update/update.go index b1891fd34..919421752 100644 --- a/internal/cmd/network/update/update.go +++ b/internal/cmd/network/update/update.go @@ -31,6 +31,7 @@ const ( ipv6GatewayFlag = "ipv6-gateway" noIpv4GatewayFlag = "no-ipv4-gateway" noIpv6GatewayFlag = "no-ipv6-gateway" + routingTableIdFlag = "routing-table-id" labelFlag = "labels" ) @@ -44,6 +45,7 @@ type inputModel struct { IPv6Gateway *string NoIPv4Gateway bool NoIPv6Gateway bool + RoutingTableId *string Labels *map[string]string } @@ -70,6 +72,10 @@ func NewCmd(params *params.CmdParams) *cobra.Command { `Update IPv6 network with ID "xxx" with new name "network-1-new", new gateway and new DNS name servers`, `$ stackit network update xxx --name network-1-new --ipv6-dns-name-servers "2001:4860:4860::8888" --ipv6-gateway "2001:4860:4860::8888"`, ), + examples.NewExample( + `Update network with ID "xxx" with new routing-table id "xxx"`, + `$ stackit network update xxx --routing-table-id xxx`, + ), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -139,6 +145,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(ipv6GatewayFlag, "", "The IPv6 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway") cmd.Flags().Bool(noIpv4GatewayFlag, false, "If set to true, the network doesn't have an IPv4 gateway") cmd.Flags().Bool(noIpv6GatewayFlag, false, "If set to true, the network doesn't have an IPv6 gateway") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "The ID of the routing-table for the network") cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network. E.g. '--labels key1=value1,key2=value2,...'") } @@ -160,6 +167,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu IPv6Gateway: flags.FlagToStringPointer(p, cmd, ipv6GatewayFlag), NoIPv4Gateway: flags.FlagToBoolValue(p, cmd, noIpv4GatewayFlag), NoIPv6Gateway: flags.FlagToBoolValue(p, cmd, noIpv6GatewayFlag), + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), } @@ -197,10 +205,11 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } payload := iaas.PartialUpdateNetworkPayload{ - Name: model.Name, - Ipv4: payloadIPv4, - Ipv6: payloadIPv6, - Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + Name: model.Name, + Ipv4: payloadIPv4, + Ipv6: payloadIPv6, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + RoutingTableId: model.RoutingTableId, } return req.PartialUpdateNetworkPayload(payload) diff --git a/internal/cmd/network/update/update_test.go b/internal/cmd/network/update/update_test.go index 236fbcd8b..88c34e4a8 100644 --- a/internal/cmd/network/update/update_test.go +++ b/internal/cmd/network/update/update_test.go @@ -26,6 +26,7 @@ var testClient = &iaas.APIClient{} var testProjectId = uuid.NewString() var testNetworkId = uuid.NewString() +var testRoutingTableId = uuid.NewString() func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -48,6 +49,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st ipv6DnsNameServersFlag: "2001:4860:4860::8888,2001:4860:4860::8844", ipv6GatewayFlag: "2001:4860:4860::8888", labelFlag: "key=value", + routingTableIdFlag: testRoutingTableId, } for _, mod := range mods { mod(flagValues) @@ -71,6 +73,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { Labels: utils.Ptr(map[string]string{ "key": "value", }), + RoutingTableId: utils.Ptr(testRoutingTableId), } for _, mod := range mods { mod(model) @@ -101,6 +104,7 @@ func fixturePayload(mods ...func(payload *iaas.PartialUpdateNetworkPayload)) iaa Nameservers: utils.Ptr([]string{"2001:4860:4860::8888", "2001:4860:4860::8844"}), Gateway: iaas.NewNullableString(utils.Ptr("2001:4860:4860::8888")), }, + RoutingTableId: utils.Ptr(testRoutingTableId), } for _, mod := range mods { mod(&payload) @@ -240,6 +244,15 @@ func TestParseInput(t *testing.T) { }), isValid: true, }, + { + description: "route-table id wrong format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdFlag] = "wrong-format" + }), + expectedModel: nil, + isValid: false, + }, } for _, tt := range tests { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 4e0ac6ea9..d81e73dc6 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -33,6 +33,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/quota" "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq" "github.com/stackitcloud/stackit-cli/internal/cmd/redis" + "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable" secretsmanager "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager" securitygroup "github.com/stackitcloud/stackit-cli/internal/cmd/security-group" "github.com/stackitcloud/stackit-cli/internal/cmd/server" @@ -160,38 +161,39 @@ func configureFlags(cmd *cobra.Command) error { } func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand(affinityGroups.NewCmd(params)) cmd.AddCommand(auth.NewCmd(params)) - cmd.AddCommand(configCmd.NewCmd(params)) cmd.AddCommand(beta.NewCmd(params)) + cmd.AddCommand(configCmd.NewCmd(params)) cmd.AddCommand(curl.NewCmd(params)) cmd.AddCommand(dns.NewCmd(params)) + cmd.AddCommand(git.NewCmd(params)) + cmd.AddCommand(image.NewCmd(params)) + cmd.AddCommand(keypair.NewCmd(params)) cmd.AddCommand(loadbalancer.NewCmd(params)) cmd.AddCommand(logme.NewCmd(params)) cmd.AddCommand(mariadb.NewCmd(params)) cmd.AddCommand(mongodbflex.NewCmd(params)) - cmd.AddCommand(objectstorage.NewCmd(params)) + cmd.AddCommand(network.NewCmd(params)) + cmd.AddCommand(networkArea.NewCmd(params)) + cmd.AddCommand(networkinterface.NewCmd(params)) cmd.AddCommand(observability.NewCmd(params)) + cmd.AddCommand(objectstorage.NewCmd(params)) cmd.AddCommand(opensearch.NewCmd(params)) cmd.AddCommand(organization.NewCmd(params)) cmd.AddCommand(postgresflex.NewCmd(params)) cmd.AddCommand(project.NewCmd(params)) + cmd.AddCommand(publicip.NewCmd(params)) + cmd.AddCommand(quota.NewCmd(params)) cmd.AddCommand(rabbitmq.NewCmd(params)) cmd.AddCommand(redis.NewCmd(params)) + cmd.AddCommand(routingtable.NewCmd(params)) + cmd.AddCommand(securitygroup.NewCmd(params)) cmd.AddCommand(secretsmanager.NewCmd(params)) + cmd.AddCommand(server.NewCmd(params)) cmd.AddCommand(serviceaccount.NewCmd(params)) cmd.AddCommand(ske.NewCmd(params)) - cmd.AddCommand(server.NewCmd(params)) - cmd.AddCommand(networkArea.NewCmd(params)) - cmd.AddCommand(network.NewCmd(params)) cmd.AddCommand(volume.NewCmd(params)) - cmd.AddCommand(networkinterface.NewCmd(params)) - cmd.AddCommand(publicip.NewCmd(params)) - cmd.AddCommand(securitygroup.NewCmd(params)) - cmd.AddCommand(keypair.NewCmd(params)) - cmd.AddCommand(image.NewCmd(params)) - cmd.AddCommand(quota.NewCmd(params)) - cmd.AddCommand(affinityGroups.NewCmd(params)) - cmd.AddCommand(git.NewCmd(params)) } // traverseCommands calls f for c and all of its children. diff --git a/internal/cmd/routingtable/create/create.go b/internal/cmd/routingtable/create/create.go new file mode 100644 index 000000000..c8c196a5a --- /dev/null +++ b/internal/cmd/routingtable/create/create.go @@ -0,0 +1,208 @@ +package create + +import ( + "context" + "fmt" + "strings" + "time" + + "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/iaas" +) + +const ( + descriptionFlag = "description" + labelFlag = "labels" + nameFlag = "name" + networkAreaIdFlag = "network-area-id" + nonDynamicRoutesFlag = "non-dynamic-routes" + nonSystemRoutesFlag = "non-system-routes" + organizationIdFlag = "organization-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Description *string + Labels *map[string]string + Name *string + NetworkAreaId *string + NonSystemRoutes bool + NonDynamicRoutes bool + OrganizationId *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a routing-table", + Long: "Creates a routing-table.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + "Create a routing-table with name `rt`", + `stackit routing-table create --organization-id xxx --network-area-id yyy --name "rt"`, + ), + examples.NewExample( + "Create a routing-table with name `rt` and description `some description`", + `stackit routing-table create --organization-id xxx --network-area-id yyy --name "rt" --description "some description"`, + ), + examples.NewExample( + "Create a routing-table with name `rt` with system_routes disabled", + `stackit routing-table create --organization-id xxx --network-area-id yyy --name "rt" --non-system-routes`, + ), + examples.NewExample( + "Create a routing-table with name `rt` with dynamic_routes disabled", + `stackit routing-table create --organization-id xxx --network-area-id yyy --name "rt" --non-dynamic-routes`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := "Are you sure you want to create a routing-table?" + if err := params.Printer.PromptForConfirmation(prompt); err != nil { + return err + } + } + + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + routingTableResp, err := req.Execute() + if err != nil { + return fmt.Errorf("create routing-table request failed: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, routingTableResp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(descriptionFlag, "", "Description of the routing-table") + cmd.Flags().StringToString(labelFlag, nil, "Key=value labels") + cmd.Flags().String(nameFlag, "", "Name of the routing-table") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Bool(nonDynamicRoutesFlag, false, "If true, preventing dynamic routes from propagating to the routing-table.") + cmd.Flags().Bool(nonSystemRoutesFlag, false, "If true, automatically disables routes for project-to-project communication.") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, nameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := &inputModel{ + GlobalFlagModel: globalFlags, + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + NonDynamicRoutes: flags.FlagToBoolValue(p, cmd, nonDynamicRoutesFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + NonSystemRoutes: flags.FlagToBoolValue(p, cmd, nonSystemRoutesFlag), + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) (iaas.ApiAddRoutingTableToAreaRequest, error) { + systemRoutes := true + if model.NonSystemRoutes { + systemRoutes = false + } + + dynamicRoutes := true + if model.NonDynamicRoutes { + dynamicRoutes = false + } + + payload := iaas.AddRoutingTableToAreaPayload{ + Description: model.Description, + Name: model.Name, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + SystemRoutes: utils.Ptr(systemRoutes), + DynamicRoutes: utils.Ptr(dynamicRoutes), + } + + return apiClient.AddRoutingTableToArea( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + ).AddRoutingTableToAreaPayload(payload), nil +} + +func outputResult(p *print.Printer, outputFormat string, routingTable *iaas.RoutingTable) error { + if routingTable == nil { + return fmt.Errorf("create routing-table response is empty") + } + + if routingTable.Id == nil { + return fmt.Errorf("routing-table Id is empty") + } + + return p.OutputResult(outputFormat, routingTable, func() error { + var labels []string + if routingTable.Labels != nil && len(*routingTable.Labels) > 0 { + for key, value := range *routingTable.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + } + + createdAt := "" + if routingTable.CreatedAt != nil { + createdAt = routingTable.CreatedAt.Format(time.RFC3339) + } + + updatedAt := "" + if routingTable.UpdatedAt != nil { + updatedAt = routingTable.UpdatedAt.Format(time.RFC3339) + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION", "CREATED_AT", "UPDATED_AT", "DEFAULT", "LABELS", "SYSTEM_ROUTES", "DYNAMIC_ROUTES") + table.AddRow( + utils.PtrString(routingTable.Id), + utils.PtrString(routingTable.Name), + utils.PtrString(routingTable.Description), + createdAt, + updatedAt, + utils.PtrString(routingTable.Default), + strings.Join(labels, "\n"), + utils.PtrString(routingTable.SystemRoutes), + utils.PtrString(routingTable.DynamicRoutes), + ) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/routingtable/create/create_test.go b/internal/cmd/routingtable/create/create_test.go new file mode 100644 index 000000000..7d79c34fb --- /dev/null +++ b/internal/cmd/routingtable/create/create_test.go @@ -0,0 +1,350 @@ +package create + +import ( + "context" + "strconv" + "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" + pprint "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() + +const testRoutingTableName = "test" +const testRoutingTableDescription = "test" +const systemRoutesDisabled = true +const dynamicRoutesDisabled = true +const 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, + descriptionFlag: testRoutingTableDescription, + nameFlag: testRoutingTableName, + nonSystemRoutesFlag: strconv.FormatBool(systemRoutesDisabled), + nonDynamicRoutesFlag: strconv.FormatBool(dynamicRoutesDisabled), + 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), + Name: utils.Ptr(testRoutingTableName), + Description: utils.Ptr(testRoutingTableDescription), + NonSystemRoutes: systemRoutesDisabled, + NonDynamicRoutes: dynamicRoutesDisabled, + Labels: utils.Ptr(*testLabels), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiAddRoutingTableToAreaRequest)) iaas.ApiAddRoutingTableToAreaRequest { + request := testClient.AddRoutingTableToArea(testCtx, testOrgId, testNetworkAreaId, testRegion) + request = request.AddRoutingTableToAreaPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaas.AddRoutingTableToAreaPayload)) iaas.AddRoutingTableToAreaPayload { + systemRoutes := true + if dynamicRoutesDisabled { + systemRoutes = false + } + + dynamicRoutes := true + if systemRoutesDisabled { + dynamicRoutes = false + } + + payload := iaas.AddRoutingTableToAreaPayload{ + Description: utils.Ptr(testRoutingTableDescription), + Name: utils.Ptr(testRoutingTableName), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + SystemRoutes: utils.Ptr(systemRoutes), + DynamicRoutes: utils.Ptr(dynamicRoutes), + } + + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "dynamic_routes disabled", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nonDynamicRoutesFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NonDynamicRoutes = true + }), + }, + { + description: "system_routes disabled", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nonSystemRoutesFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NonSystemRoutes = true + }), + }, + { + description: "missing organization ID", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "invalid organization ID - empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid organization ID - format", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing network area ID", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "invalid network area ID - empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid network area ID - format", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "missing labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + }, + { + description: "missing description", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + }), + }, + { + description: "no flags provided", + flagValues: map[string]string{}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiAddRoutingTableToAreaRequest + }{ + { + description: "valid input", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "labels missing", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutingTableToAreaRequest) { + *request = (*request).AddRoutingTableToAreaPayload(fixturePayload(func(payload *iaas.AddRoutingTableToAreaPayload) { + payload.Labels = nil + })) + }), + }, + { + description: "system routes disabled", + model: fixtureInputModel(func(model *inputModel) { + model.NonSystemRoutes = true + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutingTableToAreaRequest) { + *request = (*request).AddRoutingTableToAreaPayload(fixturePayload(func(payload *iaas.AddRoutingTableToAreaPayload) { + payload.SystemRoutes = utils.Ptr(false) + })) + }), + }, + { + description: "dynamic routes disabled", + model: fixtureInputModel(func(model *inputModel) { + model.NonDynamicRoutes = true + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutingTableToAreaRequest) { + *request = (*request).AddRoutingTableToAreaPayload(fixturePayload(func(payload *iaas.AddRoutingTableToAreaPayload) { + payload.DynamicRoutes = utils.Ptr(false) + })) + }), + }, + } + + 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) { + dummyRoutingTable := iaas.RoutingTable{ + Id: utils.Ptr("id-foo"), + Name: utils.Ptr("route-table-foo"), + Description: utils.Ptr("description-foo"), + SystemRoutes: utils.Ptr(true), + DynamicRoutes: utils.Ptr(true), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routingTable *iaas.RoutingTable + wantErr bool + }{ + { + name: "nil routing-table should return error", + outputFormat: "", + routingTable: nil, + wantErr: true, + }, + { + name: "empty routing-table", + outputFormat: "", + routingTable: &iaas.RoutingTable{}, + wantErr: true, + }, + { + name: "table output routing-table", + outputFormat: "", + routingTable: &dummyRoutingTable, + wantErr: false, + }, + { + name: "json output routing-table", + outputFormat: pprint.JSONOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + { + name: "yaml output routing-table", + outputFormat: pprint.YAMLOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + } + + p := pprint.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/routingtable/delete/delete.go b/internal/cmd/routingtable/delete/delete.go new file mode 100644 index 000000000..9141f8f6f --- /dev/null +++ b/internal/cmd/routingtable/delete/delete.go @@ -0,0 +1,111 @@ +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" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routingTableIdArg = "ROUTING_TABLE_ARG" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + NetworkAreaId *string + OrganizationId *string + RoutingTableId *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", routingTableIdArg), + Short: "Deletes a routing-table", + Long: "Deletes a routing-table", + Args: args.SingleArg(routingTableIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Deletes a a routing-table`, + `$ stackit routing-table delete xxxx-xxxx-xxxx-xxxx --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.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the routing-table %q for network-area-id %q?", *model.RoutingTableId, *model.OrganizationId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := apiClient.DeleteRoutingTableFromArea( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + ) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete routing-table: %w", err) + } + + params.Printer.Outputf("Routing-table %q deleted.", *model.RoutingTableId) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) + 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") + } + routingTableId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + RoutingTableId: &routingTableId, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/routingtable/delete/delete_test.go b/internal/cmd/routingtable/delete/delete_test.go new file mode 100644 index 000000000..4fad17554 --- /dev/null +++ b/internal/cmd/routingtable/delete/delete_test.go @@ -0,0 +1,145 @@ +package delete + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const testRegion = "eu01" + +var ( + testOrgId = uuid.NewString() + testNetworkAreaId = uuid.NewString() + testRoutingTableId = uuid.NewString() +) + +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 TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(func(m *inputModel) { + m.RoutingTableId = &testRoutingTableId + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "missing organization ID", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "invalid organization ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid organization ID - format", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing network area ID", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "invalid network area ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid network area ID - format", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing routing-table ID", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid routing-table ID - format", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid routing-table ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdArg] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} diff --git a/internal/cmd/routingtable/describe/describe.go b/internal/cmd/routingtable/describe/describe.go new file mode 100644 index 000000000..573e0e100 --- /dev/null +++ b/internal/cmd/routingtable/describe/describe.go @@ -0,0 +1,152 @@ +package describe + +import ( + "context" + "fmt" + "strings" + "time" + + "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/iaas" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routingTableIdArg = "ROUTING_TABLE_ID_ARG" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + NetworkAreaId *string + OrganizationId *string + RoutingTableId *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", routingTableIdArg), + Short: "Describes a routing-table", + Long: "Describes a routing-table", + Args: args.SingleArg(routingTableIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a routing-table`, + `$ stackit 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.ConfigureClient(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, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, routingTable *iaas.RoutingTable) error { + if routingTable == nil { + return fmt.Errorf("describe routingtable response is empty") + } + + return p.OutputResult(outputFormat, routingTable, func() error { + var labels []string + if routingTable.Labels != nil && len(*routingTable.Labels) > 0 { + for key, value := range *routingTable.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + } + + createdAt := "" + if routingTable.CreatedAt != nil { + createdAt = routingTable.CreatedAt.Format(time.RFC3339) + } + + updatedAt := "" + if routingTable.UpdatedAt != nil { + updatedAt = routingTable.UpdatedAt.Format(time.RFC3339) + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION", "CREATED_AT", "UPDATED_AT", "DEFAULT", "LABELS", "SYSTEM_ROUTES", "DYNAMIC_ROUTES") + table.AddRow( + utils.PtrString(routingTable.Id), + utils.PtrString(routingTable.Name), + utils.PtrString(routingTable.Description), + createdAt, + updatedAt, + utils.PtrString(routingTable.Default), + strings.Join(labels, "\n"), + utils.PtrString(routingTable.SystemRoutes), + utils.PtrString(routingTable.DynamicRoutes), + ) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/routingtable/describe/describe_test.go b/internal/cmd/routingtable/describe/describe_test.go new file mode 100644 index 000000000..c598bc333 --- /dev/null +++ b/internal/cmd/routingtable/describe/describe_test.go @@ -0,0 +1,204 @@ +package describe + +import ( + "testing" + "time" + + "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/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const 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: "valid input", + flagValues: fixtureFlagValues(), + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "missing organization ID", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "invalid organization ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid organization ID - format", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing network area ID", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "invalid network area ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid network area ID - format", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing routing-table ID", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid routing-table ID - format", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid routing-table ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdArg] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRouteTable := iaas.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 iaas.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/routingtable/list/list.go b/internal/cmd/routingtable/list/list.go new file mode 100644 index 000000000..f5f857131 --- /dev/null +++ b/internal/cmd/routingtable/list/list.go @@ -0,0 +1,198 @@ +package list + +import ( + "context" + "fmt" + "strings" + "time" + + "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/iaas" +) + +const ( + labelSelectorFlag = "label-selector" + limitFlag = "limit" + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LabelSelector *string + Limit *int64 + NetworkAreaId *string + OrganizationId *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all routing-tables", + Long: "Lists all routing-tables", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all routing-tables`, + `$ stackit routing-table list --organization-id xxx --network-area-id yyy`, + ), + examples.NewExample( + `List all routing-tables with labels`, + `$ stackit 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 set limit to 10`, + `$ stackit 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, nil) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + 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().String(labelSelectorFlag, "", "Filter by label") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + Limit: limit, + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.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 []iaas.RoutingTable) error { + if len(items) == 0 { + return fmt.Errorf("list routingtable response is empty") + } + + return p.OutputResult(outputFormat, items, func() error { + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION", "CREATED_AT", "UPDATED_AT", "DEFAULT", "LABELS", "SYSTEM_ROUTES", "DYNAMIC_ROUTES") + + for _, item := range items { + var labels []string + for key, value := range *item.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + + createdAt := "" + if item.CreatedAt != nil { + createdAt = item.CreatedAt.Format(time.RFC3339) + } + + updatedAt := "" + if item.UpdatedAt != nil { + updatedAt = item.UpdatedAt.Format(time.RFC3339) + } + + table.AddRow( + utils.PtrString(item.Id), + utils.PtrString(item.Name), + utils.PtrString(item.Description), + createdAt, + updatedAt, + utils.PtrString(item.Default), + strings.Join(labels, "\n"), + utils.PtrString(item.SystemRoutes), + utils.PtrString(item.DynamicRoutes), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/routingtable/list/list_test.go b/internal/cmd/routingtable/list/list_test.go new file mode 100644 index 000000000..b3a4f3134 --- /dev/null +++ b/internal/cmd/routingtable/list/list_test.go @@ -0,0 +1,179 @@ +package list + +import ( + "strconv" + "testing" + "time" + + "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/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() + +const 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 + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "missing network area ID", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "missing organization ID", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "missing labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelSelectorFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + }, + { + description: "missing limit", + 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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRouteTable := iaas.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 []iaas.RoutingTable + wantErr bool + }{ + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + routingTable: []iaas.RoutingTable{dummyRouteTable}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + routingTable: []iaas.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/routingtable/route/create/create.go b/internal/cmd/routingtable/route/create/create.go new file mode 100644 index 000000000..86566d831 --- /dev/null +++ b/internal/cmd/routingtable/route/create/create.go @@ -0,0 +1,289 @@ +package create + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "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/iaas" +) + +const ( + destinationTypeFlag = "destination-type" + destinationValueFlag = "destination-value" + labelFlag = "labels" + networkAreaIdFlag = "network-area-id" + nextHopTypeFlag = "nexthop-type" + nextHopValueFlag = "nexthop-value" + organizationIdFlag = "organization-id" + routingTableIdFlag = "routing-table-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + DestinationType *string + DestinationValue *string + Labels *map[string]string + NetworkAreaId *string + NextHopType *string + NextHopValue *string + OrganizationId *string + RoutingTableId *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 routing-table 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 routing-table 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 routing-table 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, nil) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(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.CIDRFlag(), destinationValueFlag, "Destination value") + cmd.Flags().String(nextHopValueFlag, "", "NextHop value") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + 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, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := &inputModel{ + GlobalFlagModel: globalFlags, + DestinationType: flags.FlagToStringPointer(p, cmd, destinationTypeFlag), + DestinationValue: flags.FlagToStringPointer(p, cmd, destinationValueFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + NextHopType: flags.FlagToStringPointer(p, cmd, nextHopTypeFlag), + NextHopValue: flags.FlagToStringPointer(p, cmd, nextHopValueFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + } + + // Next Hop 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) + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) (iaas.ApiAddRoutesToRoutingTableRequest, error) { + destination := buildDestination(model) + nextHop := buildNextHop(model) + + if destination != nil && nextHop != nil { + payload := iaas.AddRoutesToRoutingTablePayload{ + Items: &[]iaas.Route{ + { + Destination: destination, + Nexthop: nextHop, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + }, + }, + } + + return apiClient.AddRoutesToRoutingTable( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + ).AddRoutesToRoutingTablePayload(payload), nil + } + + return nil, fmt.Errorf("invalid input") +} + +func buildDestination(model *inputModel) *iaas.RouteDestination { + if model.DestinationValue == nil { + return nil + } + + destinationType := strings.ToLower(*model.DestinationType) + switch destinationType { + case "cidrv4": + return &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: model.DestinationType, + Value: model.DestinationValue, + }, + } + case "cidrv6": + return &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: model.DestinationType, + Value: model.DestinationValue, + }, + } + default: + return nil + } +} + +func buildNextHop(model *inputModel) *iaas.RouteNexthop { + nextHopType := strings.ToLower(*model.NextHopType) + switch nextHopType { + case "ipv4": + return &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: model.NextHopType, + Value: model.NextHopValue, + }, + } + case "ipv6": + return &iaas.RouteNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: model.NextHopType, + Value: model.NextHopValue, + }, + } + case "internet": + return &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: model.NextHopType, + }, + } + case "blackhole": + return &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: model.NextHopType, + }, + } + default: + return nil + } +} + +func outputResult(p *print.Printer, outputFormat string, items []iaas.Route) error { + if len(items) == 0 { + return fmt.Errorf("create routes response is empty") + } + + return p.OutputResult(outputFormat, items, func() error { + 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) + + createdAt := "" + if item.CreatedAt != nil { + createdAt = item.CreatedAt.Format(time.RFC3339) + } + + updatedAt := "" + if item.UpdatedAt != nil { + updatedAt = item.UpdatedAt.Format(time.RFC3339) + } + + table.AddRow( + utils.PtrString(item.Id), + destType, + destValue, + hopType, + hopValue, + labels, + createdAt, + updatedAt, + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/routingtable/route/create/create_test.go b/internal/cmd/routingtable/route/create/create_test.go new file mode 100644 index 000000000..64b5eaced --- /dev/null +++ b/internal/cmd/routingtable/route/create/create_test.go @@ -0,0 +1,689 @@ +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/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +const testDestinationTypeFlag = "cidrv4" +const testDestinationValueFlag = "1.1.1.0/24" +const testNextHopTypeFlag = "ipv4" +const testNextHopValueFlag = "1.1.1.1" +const 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 *iaas.ApiAddRoutesToRoutingTableRequest)) iaas.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 *iaas.AddRoutesToRoutingTablePayload)) iaas.AddRoutesToRoutingTablePayload { + payload := iaas.AddRoutesToRoutingTablePayload{ + Items: &[]iaas.Route{ + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(testDestinationTypeFlag), + Value: utils.Ptr(testDestinationValueFlag), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.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 + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + 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: "next hop type missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nextHopTypeFlag) + }), + isValid: false, + }, + { + description: "next hop value missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "organization ID missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "organization ID invalid - empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "organization ID invalid - format", + 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 - empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "network area ID invalid - format", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid destination type enum", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationTypeFlag] = "ipv4" + }), + isValid: false, + }, + { + description: "destination value not IPv4 CIDR", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationValueFlag] = "0.0.0.0" + }), + isValid: false, + }, + { + description: "destination value not IPv6 CIDR", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationTypeFlag] = "cidrv6" + flagValues[destinationValueFlag] = "2001:db8::" + }), + isValid: false, + }, + { + description: "destination value is IPv6 CIDR", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationTypeFlag] = "cidrv6" + flagValues[destinationValueFlag] = "2001:db8::/32" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DestinationType = utils.Ptr("cidrv6") + model.DestinationValue = utils.Ptr("2001:db8::/32") + }), + }, + { + description: "invalid next hop type enum", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "cidrv4" + }), + isValid: false, + }, + { + description: "next hop type is internet and next hop 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: "next hop type is blackhole and next hop value is provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "blackhole" + flagValues[nextHopValueFlag] = "1.1.1.1" + }), + isValid: false, + }, + { + description: "next hop type is internet and next hop 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: "next hop type is blackhole and next hop 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: "next hop type is IPv4 and next hop value is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "ipv4" + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + description: "next hop type is IPv6 and next hop value is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "ipv6" + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + description: "invalid next hop type provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "invalid-type" + }), + isValid: false, + }, + { + description: "optional labels are 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, + }, + { + description: "argument value missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "argument value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildNextHop(t *testing.T) { + tests := []struct { + description string + model *inputModel + expected *iaas.RouteNexthop + }{ + { + description: "IPv4 next hop", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = utils.Ptr("ipv4") + m.NextHopValue = utils.Ptr("1.1.1.1") + }), + expected: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("1.1.1.1"), + }, + }, + }, + { + description: "IPv6 next hop", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = utils.Ptr("ipv6") + m.NextHopValue = utils.Ptr("::1") + }), + expected: &iaas.RouteNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: utils.Ptr("ipv6"), + Value: utils.Ptr("::1"), + }, + }, + }, + { + description: "Internet next hop", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = utils.Ptr("internet") + m.NextHopValue = nil + }), + expected: &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: utils.Ptr("internet"), + }, + }, + }, + { + description: "Blackhole next hop", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = utils.Ptr("blackhole") + m.NextHopValue = nil + }), + expected: &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: utils.Ptr("blackhole"), + }, + }, + }, + { + description: "Unsupported next hop type", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = utils.Ptr("unsupported") + }), + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildNextHop(tt.model) + if diff := cmp.Diff(tt.expected, got); diff != "" { + t.Errorf("buildNextHop() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestBuildDestination(t *testing.T) { + tests := []struct { + description string + model *inputModel + expected *iaas.RouteDestination + }{ + { + description: "CIDRv4 destination", + model: fixtureInputModel(func(m *inputModel) { + m.DestinationType = utils.Ptr("cidrv4") + m.DestinationValue = utils.Ptr("192.168.1.0/24") + }), + expected: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("192.168.1.0/24"), + }, + }, + }, + { + description: "CIDRv6 destination", + model: fixtureInputModel(func(m *inputModel) { + m.DestinationType = utils.Ptr("cidrv6") + m.DestinationValue = utils.Ptr("2001:db8::/32") + }), + expected: &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: utils.Ptr("cidrv6"), + Value: utils.Ptr("2001:db8::/32"), + }, + }, + }, + { + description: "unsupported destination type", + model: fixtureInputModel(func(m *inputModel) { + m.DestinationType = utils.Ptr("other") + m.DestinationValue = utils.Ptr("1.1.1.1") + }), + expected: nil, + }, + { + description: "nil destination value", + model: fixtureInputModel(func(m *inputModel) { + m.DestinationValue = nil + }), + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildDestination(tt.model) + if diff := cmp.Diff(tt.expected, got); diff != "" { + t.Errorf("buildDestination() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.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 *iaas.ApiAddRoutesToRoutingTableRequest) { + *request = (*request).AddRoutesToRoutingTablePayload(fixturePayload(func(payload *iaas.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 *iaas.ApiAddRoutesToRoutingTableRequest) { + *request = (*request).AddRoutesToRoutingTablePayload(iaas.AddRoutesToRoutingTablePayload{ + Items: &[]iaas.Route{ + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: utils.Ptr("cidrv6"), + Value: utils.Ptr("2001:db8::/32"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv6: &iaas.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 *iaas.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaas.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaas.RouteNexthop{ + NexthopInternet: &iaas.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 *iaas.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaas.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: utils.Ptr("blackhole"), + }, + } + }) + *request = (*request).AddRoutesToRoutingTablePayload(payload) + }), + }, + { + description: "nexthop type is ipv4 with value", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = utils.Ptr("ipv4") + model.NextHopValue = utils.Ptr("1.2.3.4") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaas.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("1.2.3.4"), + }, + } + }) + *request = (*request).AddRoutesToRoutingTablePayload(payload) + }), + }, + { + description: "nexthop type is ipv6 with value", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = utils.Ptr("ipv6") + model.NextHopValue = utils.Ptr("2001:db8::1") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaas.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaas.RouteNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: utils.Ptr("ipv6"), + Value: utils.Ptr("2001:db8::1"), + }, + } + }) + *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 := iaas.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.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 []iaas.Route + wantErr bool + }{ + { + name: "nil items should return error", + outputFormat: "", + items: nil, + wantErr: true, + }, + { + name: "empty items list", + outputFormat: "", + items: []iaas.Route{}, + wantErr: true, + }, + { + name: "table output with one route", + outputFormat: "", + items: []iaas.Route{dummyRoute}, + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + items: []iaas.Route{dummyRoute}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + items: []iaas.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/routingtable/route/delete/delete.go b/internal/cmd/routingtable/route/delete/delete.go new file mode 100644 index 000000000..699d5d06c --- /dev/null +++ b/internal/cmd/routingtable/route/delete/delete.go @@ -0,0 +1,116 @@ +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" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routeIdArg = "ROUTE_ID_ARG" + routingTableIdFlag = "routing-table-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + NetworkAreaId *string + OrganizationId *string + RouteID *string + RoutingTableId *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, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Deletes a route within a routing-table`, + `$ stackit 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.ConfigureClient(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(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization 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), + RouteID: &routeId, + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/routingtable/route/delete/delete_test.go b/internal/cmd/routingtable/route/delete/delete_test.go new file mode 100644 index 000000000..9d6146963 --- /dev/null +++ b/internal/cmd/routingtable/route/delete/delete_test.go @@ -0,0 +1,132 @@ +package delete + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +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 + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + argValues: []string{testRouteId}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(func(m *inputModel) { + m.RouteID = &testRouteId + }), + }, + { + description: "missing route id arg", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing organization-id flag", + argValues: []string{testRouteId}, + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, "organization-id") + }), + isValid: false, + }, + { + description: "missing network-area-id flag", + argValues: []string{testRouteId}, + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, "network-area-id") + }), + isValid: false, + }, + { + description: "missing routing-table-id flag", + argValues: []string{testRouteId}, + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, "routing-table-id") + }), + isValid: false, + }, + { + description: "arg value missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "invalid organization-id flag", + argValues: []string{testRouteId}, + flagValues: map[string]string{"organization-id": "invalid-org"}, + isValid: false, + }, + { + description: "invalid network-area-id flag", + argValues: []string{testRouteId}, + flagValues: map[string]string{"network-area-id": "invalid-area"}, + isValid: false, + }, + { + description: "invalid routing-table-id flag", + argValues: []string{testRouteId}, + flagValues: map[string]string{"routing-table-id": "invalid-table"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} diff --git a/internal/cmd/routingtable/route/describe/describe.go b/internal/cmd/routingtable/route/describe/describe.go new file mode 100644 index 000000000..68c6e6072 --- /dev/null +++ b/internal/cmd/routingtable/route/describe/describe.go @@ -0,0 +1,195 @@ +package describe + +import ( + "context" + "fmt" + "strings" + "time" + + "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/iaas" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routeIdArg = "ROUTE_ID_ARG" + routingTableIdFlag = "routing-table-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + NetworkAreaId *string + OrganizationId *string + RouteID *string + RoutingTableId *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", routeIdArg), + Short: "Describes a route within a routing-table", + Long: "Describes a route within a routing-table", + Args: args.SingleArg(routeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a route within a routing-table`, + `$ stackit 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.ConfigureClient(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(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization 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), + RouteID: &routeId, + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, routingTable *iaas.Route) error { + if routingTable == nil { + return fmt.Errorf("describe routes response is empty") + } + + return p.OutputResult(outputFormat, routingTable, func() error { + var labels []string + if routingTable.Labels != nil && len(*routingTable.Labels) > 0 { + 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 + } + } + + createdAt := "" + if routingTable.CreatedAt != nil { + createdAt = routingTable.CreatedAt.Format(time.RFC3339) + } + + updatedAt := "" + if routingTable.UpdatedAt != nil { + updatedAt = routingTable.UpdatedAt.Format(time.RFC3339) + } + + 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), + createdAt, + updatedAt, + 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/routingtable/route/describe/describe_test.go b/internal/cmd/routingtable/route/describe/describe_test.go new file mode 100644 index 000000000..03a46071f --- /dev/null +++ b/internal/cmd/routingtable/route/describe/describe_test.go @@ -0,0 +1,209 @@ +package describe + +import ( + "testing" + "time" + + "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/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const 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: "invalid routing-table-id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdFlag] = "invalid-id" + }), + isValid: false, + }, + { + description: "arg value missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "invalid organization-id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-org" + }), + isValid: false, + }, + { + description: "invalid network-area-id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-area" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaas.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.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 iaas.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/routingtable/route/list/list.go b/internal/cmd/routingtable/route/list/list.go new file mode 100644 index 000000000..7af433e4b --- /dev/null +++ b/internal/cmd/routingtable/route/list/list.go @@ -0,0 +1,183 @@ +package list + +import ( + "context" + "fmt" + "time" + + "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/iaas" +) + +const ( + labelSelectorFlag = "label-selector" + limitFlag = "limit" + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routingTableIdFlag = "routing-table-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LabelSelector *string + Limit *int64 + NetworkAreaId *string + OrganizationId *string + RoutingTableId *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all routes within a routing-table", + Long: "Lists all routes within a routing-table", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all routes within a routing-table`, + `$ stackit 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 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 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.ConfigureClient(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.GetItems() + 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().String(labelSelectorFlag, "", "Filter by label") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization 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) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + Limit: limit, + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, items []iaas.Route) error { + if len(items) == 0 { + return fmt.Errorf("create routes response is empty") + } + + return p.OutputResult(outputFormat, items, func() error { + 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) + + createdAt := "" + if item.CreatedAt != nil { + createdAt = item.CreatedAt.Format(time.RFC3339) + } + + updatedAt := "" + if item.UpdatedAt != nil { + updatedAt = item.UpdatedAt.Format(time.RFC3339) + } + + table.AddRow( + utils.PtrString(item.Id), + destType, + destValue, + hopType, + hopValue, + labels, + createdAt, + updatedAt, + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/routingtable/route/list/list_test.go b/internal/cmd/routingtable/route/list/list_test.go new file mode 100644 index 000000000..ad03fa226 --- /dev/null +++ b/internal/cmd/routingtable/route/list/list_test.go @@ -0,0 +1,242 @@ +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/iaas" +) + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +const 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: "labels missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelSelectorFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + }, + { + description: "limit missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + }, + { + description: "invalid limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "negative limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-10" + }), + isValid: false, + }, + { + description: "limit zero flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + 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 := iaas.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.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 []iaas.Route + wantErr bool + }{ + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + routes: []iaas.Route{dummyRoute}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + routes: []iaas.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/routingtable/route/route.go b/internal/cmd/routingtable/route/route.go new file mode 100644 index 000000000..13cbaddd4 --- /dev/null +++ b/internal/cmd/routingtable/route/route.go @@ -0,0 +1,33 @@ +package route + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/route/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/route/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/route/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/route/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/route/update" + "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: "Manages routes of a routing-table", + Long: "Manages 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/routingtable/route/update/update.go b/internal/cmd/routingtable/route/update/update.go new file mode 100644 index 000000000..5942af7ef --- /dev/null +++ b/internal/cmd/routingtable/route/update/update.go @@ -0,0 +1,142 @@ +package update + +import ( + "context" + "fmt" + + "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/iaas" +) + +const ( + labelFlag = "labels" + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routeIdArg = "ROUTE_ID_ARG" + routingTableIdFlag = "routing-table-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Labels *map[string]string + NetworkAreaId *string + OrganizationId *string + RouteId *string + RoutingTableId *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 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.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update route %q for routing-table with id %q?", *model.RouteId, *model.RoutingTableId) + if err := params.Printer.PromptForConfirmation(prompt); err != nil { + return err + } + } + + // Call API + req := apiClient.UpdateRouteOfRoutingTable( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + *model.RouteId, + ) + + payload := iaas.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().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") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + 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, + Labels: labels, + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + RouteId: &routeId, + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, routingTableId, networkAreaId string, route *iaas.Route) error { + if route == nil { + return fmt.Errorf("update route response is empty") + } + + return p.OutputResult(outputFormat, route, func() error { + p.Outputf("Updated route %q for routing-table %q in network-area %q.", *route.Id, routingTableId, networkAreaId) + return nil + }) +} diff --git a/internal/cmd/routingtable/route/update/update_test.go b/internal/cmd/routingtable/route/update/update_test.go new file mode 100644 index 000000000..2d0e5b7b2 --- /dev/null +++ b/internal/cmd/routingtable/route/update/update_test.go @@ -0,0 +1,211 @@ +package update + +import ( + "testing" + "time" + + "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/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() +var testRouteId = uuid.NewString() + +const 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: "arg value missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "labels are missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelFlag) + }), + isValid: false, + }, + { + description: "invalid label format", + argValues: []string{}, + flagValues: map[string]string{labelFlag: "invalid-label"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaas.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.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 iaas.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/routingtable/routingtable.go b/internal/cmd/routingtable/routingtable.go new file mode 100644 index 000000000..301c5a8e3 --- /dev/null +++ b/internal/cmd/routingtable/routingtable.go @@ -0,0 +1,40 @@ +package routingtable + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + rtCreate "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/create" + rtDelete "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/delete" + rtDescribe "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/describe" + rtList "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/route" + rtUpdate "github.com/stackitcloud/stackit-cli/internal/cmd/routingtable/update" + "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 their associated routes. + +This API is currently available only to selected customers. +To request access, please contact your account manager or submit a support ticket.`, + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand( + rtCreate.NewCmd(params), + rtUpdate.NewCmd(params), + rtList.NewCmd(params), + rtDescribe.NewCmd(params), + rtDelete.NewCmd(params), + route.NewCmd(params), + ) +} diff --git a/internal/cmd/routingtable/update/update.go b/internal/cmd/routingtable/update/update.go new file mode 100644 index 000000000..859c516a6 --- /dev/null +++ b/internal/cmd/routingtable/update/update.go @@ -0,0 +1,167 @@ +package update + +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" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + descriptionFlag = "description" + labelFlag = "labels" + nameFlag = "name" + networkAreaIdFlag = "network-area-id" + nonDynamicRoutesFlag = "non-dynamic-routes" + organizationIdFlag = "organization-id" + routingTableIdArg = "ROUTE_TABLE_ID_ARG" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId *string + NetworkAreaId *string + NonDynamicRoutes bool + RoutingTableId *string + Description *string + Labels *map[string]string + Name *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", routingTableIdArg), + Short: "Updates a routing-table", + Long: "Updates a routing-table.", + Args: args.SingleArg(routingTableIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Updates the label(s) of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit routing-table update xxx --labels key=value,foo=bar --organization-id yyy --network-area-id zzz", + ), + examples.NewExample( + `Updates the name of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit routing-table update xxx --name foo --organization-id yyy --network-area-id zzz", + ), + examples.NewExample( + `Updates the description of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit routing-table update xxx --description foo --organization-id yyy --network-area-id zzz", + ), + examples.NewExample( + `Disables the dynamic_routes of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit routing-table update xxx --organization-id yyy --network-area-id zzz --non-dynamic-routes", + ), + ), + 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.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update routing-table %q?", *model.RoutingTableId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := apiClient.UpdateRoutingTableOfArea( + ctx, + *model.OrganizationId, + *model.NetworkAreaId, + model.Region, + *model.RoutingTableId, + ) + + dynamicRoutes := true + if model.NonDynamicRoutes { + dynamicRoutes = false + } + + payload := iaas.UpdateRoutingTableOfAreaPayload{ + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + Name: model.Name, + Description: model.Description, + DynamicRoutes: &dynamicRoutes, + } + req = req.UpdateRoutingTableOfAreaPayload(payload) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update routing-table %q : %w", *model.RoutingTableId, err) + } + + return outputResult(params.Printer, model.OutputFormat, *model.NetworkAreaId, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(descriptionFlag, "", "Description of the routing-table") + cmd.Flags().String(nameFlag, "", "Name of the routing-table") + cmd.Flags().StringToString(labelFlag, nil, "Key=value labels") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Bool(nonDynamicRoutesFlag, false, "If true, preventing dynamic routes from propagating to the routing-table.") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) + 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") + } + routeTableId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + NonDynamicRoutes: flags.FlagToBoolValue(p, cmd, nonDynamicRoutesFlag), + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + RoutingTableId: &routeTableId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, networkAreaId string, routingTable *iaas.RoutingTable) error { + if routingTable == nil { + return fmt.Errorf("update routing-table response is empty") + } + + if routingTable.Id == nil { + return fmt.Errorf("update routing-table response is empty") + } + + return p.OutputResult(outputFormat, routingTable, func() error { + p.Outputf("Updated routing-table %q in network-area %q.", *routingTable.Id, networkAreaId) + return nil + }) +} diff --git a/internal/cmd/routingtable/update/update_test.go b/internal/cmd/routingtable/update/update_test.go new file mode 100644 index 000000000..aef2de6e7 --- /dev/null +++ b/internal/cmd/routingtable/update/update_test.go @@ -0,0 +1,221 @@ +package update + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + pprint "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +const testRoutingTableName = "test" +const testRoutingTableDescription = "test" +const 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, + descriptionFlag: testRoutingTableDescription, + nameFlag: testRoutingTableName, + 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), + Name: utils.Ptr(testRoutingTableName), + Description: utils.Ptr(testRoutingTableDescription), + Labels: utils.Ptr(*testLabels), + } + 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(func(model *inputModel) { + model.RoutingTableId = &testRoutingTableId + }), + }, + { + description: "dynamic_routes disabled", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nonDynamicRoutesFlag] = "true" + }), + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NonDynamicRoutes = true + model.RoutingTableId = &testRoutingTableId + }), + }, + { + 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, + }, + { + description: "arg value missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "labels are missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelFlag) + }), + isValid: false, + }, + { + description: "invalid label format", + argValues: []string{}, + flagValues: map[string]string{labelFlag: "invalid-label"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoutingTable := iaas.RoutingTable{ + Id: utils.Ptr("id-foo"), + Name: utils.Ptr("route-table-foo"), + Description: utils.Ptr("description-foo"), + SystemRoutes: utils.Ptr(true), + DynamicRoutes: utils.Ptr(true), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routingTable *iaas.RoutingTable + wantErr bool + }{ + { + name: "nil routing-table should return error", + outputFormat: "", + routingTable: nil, + wantErr: true, + }, + { + name: "empty routing-table", + outputFormat: "", + routingTable: &iaas.RoutingTable{}, + wantErr: true, + }, + { + name: "table output routing-table", + outputFormat: "", + routingTable: &dummyRoutingTable, + wantErr: false, + }, + { + name: "json output routing-table", + outputFormat: pprint.JSONOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + { + name: "yaml output routing-table", + outputFormat: pprint.YAMLOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + } + + p := pprint.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, "network-area-id", tt.routingTable); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} 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..b6d38f6c2 --- /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/iaas" +) + +func ExtractRouteDetails(item iaas.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..a9210154e --- /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/iaas" +) + +func TestExtractRouteDetails(t *testing.T) { + tests := []struct { + description string + input *iaas.Route + wantDestType string + wantDestValue string + wantHopType string + wantHopValue string + wantLabels string + }{ + { + description: "CIDRv4 destination, IPv4 nexthop, with labels", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("CIDRv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.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: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: utils.Ptr("CIDRv6"), + Value: utils.Ptr("2001:db8::/32"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.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: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("CIDRv4"), + Value: utils.Ptr("0.0.0.0/0"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopInternet: &iaas.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: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: utils.Ptr("CIDRv6"), + Value: utils.Ptr("::/0"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.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) + } + }) + } +}