diff --git a/docs/stackit_beta_vpn.md b/docs/stackit_beta_vpn.md index 861daa4dd..a0752f708 100644 --- a/docs/stackit_beta_vpn.md +++ b/docs/stackit_beta_vpn.md @@ -31,4 +31,7 @@ stackit beta vpn [flags] * [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands * [stackit beta vpn connection](./stackit_beta_vpn_connection.md) - Provides functionality for VPN connections +* [stackit beta vpn gateway](./stackit_beta_vpn_gateway.md) - Provides functionality for VPN gateway +* [stackit beta vpn plans](./stackit_beta_vpn_plans.md) - Lists all available plans +* [stackit beta vpn quotas](./stackit_beta_vpn_quotas.md) - Lists all vpn quotas diff --git a/docs/stackit_beta_vpn_gateway.md b/docs/stackit_beta_vpn_gateway.md new file mode 100644 index 000000000..17ea3a627 --- /dev/null +++ b/docs/stackit_beta_vpn_gateway.md @@ -0,0 +1,38 @@ +## stackit beta vpn gateway + +Provides functionality for VPN gateway + +### Synopsis + +Provides functionality for VPN gateway. + +``` +stackit beta vpn gateway [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta vpn gateway" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn](./stackit_beta_vpn.md) - Provides functionality for VPN +* [stackit beta vpn gateway create](./stackit_beta_vpn_gateway_create.md) - Creates a vpn gateway +* [stackit beta vpn gateway delete](./stackit_beta_vpn_gateway_delete.md) - Deletes a vpn gateway +* [stackit beta vpn gateway describe](./stackit_beta_vpn_gateway_describe.md) - Shows details of a gateway +* [stackit beta vpn gateway list](./stackit_beta_vpn_gateway_list.md) - Lists all vpn gateways +* [stackit beta vpn gateway status](./stackit_beta_vpn_gateway_status.md) - Shows the status of a gateway + diff --git a/docs/stackit_beta_vpn_gateway_create.md b/docs/stackit_beta_vpn_gateway_create.md new file mode 100644 index 000000000..e9de973cd --- /dev/null +++ b/docs/stackit_beta_vpn_gateway_create.md @@ -0,0 +1,54 @@ +## stackit beta vpn gateway create + +Creates a vpn gateway + +### Synopsis + +Creates a vpn gateway. + +``` +stackit beta vpn gateway create [flags] +``` + +### Examples + +``` + Create a vpn gateway with name "xxx", plan "p500", policy based routing and both tunnels in availability-zone eu01-1 + $ stackit beta vpn gateway create --name xxx --plan-id p500 --routing-type POLICY_BASED --availability-zone-tunnel-1 eu01-1 --availability-zone-tunnel-2 eu01-1 + + Create a vpn gateway with the labels foo=bar and x=y + $ stackit beta vpn gateway create --name xxx --plan-id p500 --routing-type POLICY_BASED --availability-zone-tunnel-1 eu01-1 --availability-zone-tunnel-2 eu01-1 --label foo=bar,x=y + + Create a vpn gateway with bgp enabled, yyy as local asn and [aaa, bbb] as override advertised routes + $ stackit beta vpn gateway create --name xxx --plan-id p500 --routing-type POLICY_BASED --availability-zone-tunnel-1 eu01-1 --availability-zone-tunnel-2 eu01-1 --bgp-local-asn yyy --bgp-override-advertised-routes aaa,bbb +``` + +### Options + +``` + --availability-zone-tunnel-1 string Availability Zone of Tunnel 1 + --availability-zone-tunnel-2 string Availability Zone of Tunnel 2 + --bgp-local-asn int ASN for private use (reserved by IANA), both 16Bit and 32Bit ranges are valid (RFC 6996) + --bgp-override-advertised-routes stringArray A list of IPv4 Prefixes to advertise via BGP + -h, --help Help for "stackit beta vpn gateway create" + --labels stringToString Labels in key=value format, separated by commas (default []) + --name string Gateway name + --plan-id string Plan ID + --routing-type string Routing Type of the VPN (one of: [POLICY_BASED, ROUTE_BASED, BGP_ROUTE_BASED]) +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn gateway](./stackit_beta_vpn_gateway.md) - Provides functionality for VPN gateway + diff --git a/docs/stackit_beta_vpn_gateway_delete.md b/docs/stackit_beta_vpn_gateway_delete.md new file mode 100644 index 000000000..65cc5feed --- /dev/null +++ b/docs/stackit_beta_vpn_gateway_delete.md @@ -0,0 +1,40 @@ +## stackit beta vpn gateway delete + +Deletes a vpn gateway + +### Synopsis + +Deletes a vpn gateway. + +``` +stackit beta vpn gateway delete GATEWAY_ID [flags] +``` + +### Examples + +``` + Delete the vpn gateway with the ID "xxx" + $ stackit beta vpn gateway delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta vpn gateway delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn gateway](./stackit_beta_vpn_gateway.md) - Provides functionality for VPN gateway + diff --git a/docs/stackit_beta_vpn_gateway_describe.md b/docs/stackit_beta_vpn_gateway_describe.md new file mode 100644 index 000000000..0c029c81a --- /dev/null +++ b/docs/stackit_beta_vpn_gateway_describe.md @@ -0,0 +1,40 @@ +## stackit beta vpn gateway describe + +Shows details of a gateway + +### Synopsis + +Shows details of a gateway. + +``` +stackit beta vpn gateway describe GATEWAY_ID [flags] +``` + +### Examples + +``` + Describe the gateway with the ID "xxx" + $ stackit beta vpn gateway describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta vpn gateway describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn gateway](./stackit_beta_vpn_gateway.md) - Provides functionality for VPN gateway + diff --git a/docs/stackit_beta_vpn_gateway_list.md b/docs/stackit_beta_vpn_gateway_list.md new file mode 100644 index 000000000..0c72b56b1 --- /dev/null +++ b/docs/stackit_beta_vpn_gateway_list.md @@ -0,0 +1,44 @@ +## stackit beta vpn gateway list + +Lists all vpn gateways + +### Synopsis + +Lists all vpn gateways. + +``` +stackit beta vpn gateway list [flags] +``` + +### Examples + +``` + List all vpn gateways + $ stackit beta vpn gateway list + + List up to 4 vpn gateways + $ stackit beta vpn gateway list --limit 4 +``` + +### Options + +``` + -h, --help Help for "stackit beta vpn gateway list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn gateway](./stackit_beta_vpn_gateway.md) - Provides functionality for VPN gateway + diff --git a/docs/stackit_beta_vpn_gateway_status.md b/docs/stackit_beta_vpn_gateway_status.md new file mode 100644 index 000000000..412462183 --- /dev/null +++ b/docs/stackit_beta_vpn_gateway_status.md @@ -0,0 +1,40 @@ +## stackit beta vpn gateway status + +Shows the status of a gateway + +### Synopsis + +Shows the status of a gateway. + +``` +stackit beta vpn gateway status GATEWAY_ID [flags] +``` + +### Examples + +``` + Show the status of the gateway with the ID "xxx" + $ stackit beta vpn gateway describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta vpn gateway status" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn gateway](./stackit_beta_vpn_gateway.md) - Provides functionality for VPN gateway + diff --git a/docs/stackit_beta_vpn_plans.md b/docs/stackit_beta_vpn_plans.md new file mode 100644 index 000000000..a56b2022f --- /dev/null +++ b/docs/stackit_beta_vpn_plans.md @@ -0,0 +1,41 @@ +## stackit beta vpn plans + +Lists all available plans + +### Synopsis + +Lists all available plans. + +``` +stackit beta vpn plans [flags] +``` + +### Examples + +``` + List all available plans + $ stackit beta vpn plans +``` + +### Options + +``` + -h, --help Help for "stackit beta vpn plans" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn](./stackit_beta_vpn.md) - Provides functionality for VPN + diff --git a/docs/stackit_beta_vpn_quotas.md b/docs/stackit_beta_vpn_quotas.md new file mode 100644 index 000000000..8ca5f68f2 --- /dev/null +++ b/docs/stackit_beta_vpn_quotas.md @@ -0,0 +1,41 @@ +## stackit beta vpn quotas + +Lists all vpn quotas + +### Synopsis + +Lists all vpn quotas. + +``` +stackit beta vpn quotas [flags] +``` + +### Examples + +``` + List all vpn quotas + $ stackit beta vpn quotas +``` + +### Options + +``` + -h, --help Help for "stackit beta vpn quotas" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn](./stackit_beta_vpn.md) - Provides functionality for VPN + diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index 2a49e8ea6..ab3d79834 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -63,6 +63,7 @@ stackit config set [flags] --ske-custom-endpoint string SKE API base URL, used in calls to this API --sqlserverflex-custom-endpoint string SQLServer Flex API base URL, used in calls to this API --token-custom-endpoint string Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication. + --vpn-custom-endpoint string VPN API base URL, used in calls to this API ``` ### Options inherited from parent commands diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index d76f3068c..ea816220f 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -66,6 +66,7 @@ stackit config unset [flags] --sqlserverflex-custom-endpoint SQLServer Flex API base URL. If unset, uses the default base URL --token-custom-endpoint Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication. --verbosity Verbosity of the CLI + --vpn-custom-endpoint VPN API base URL. If unset, uses the default base URL ``` ### SEE ALSO diff --git a/internal/cmd/beta/vpn/gateway/create/create.go b/internal/cmd/beta/vpn/gateway/create/create.go new file mode 100644 index 000000000..245a0d1ea --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/create/create.go @@ -0,0 +1,224 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + availabilityZoneTunnel1Flag = "availability-zone-tunnel-1" + availabilityZoneTunnel2Flag = "availability-zone-tunnel-2" + bgpLocalAsnFlag = "bgp-local-asn" + bgpOverrideAdvertisedRoutesFlag = "bgp-override-advertised-routes" + nameFlag = "name" + labelsFlag = "labels" + planIdFlag = "plan-id" +) + +var ( + routingTypeFlag = flags.StringEnumFlag( + "routing-type", + vpn.AllowedRoutingTypeEnumValues, + "Routing Type of the VPN", + ) +) + +type inputModel struct { + *globalflags.GlobalFlagModel + AvailabilityZone vpn.CreateGatewayPayloadAvailabilityZones + Bgp *vpn.BGPGatewayConfig + Name string + Labels *map[string]string + PlanId string + RoutingType vpn.RoutingType +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a vpn gateway", + Long: "Creates a vpn gateway.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a vpn gateway with name "xxx", plan "p500", policy based routing and both tunnels in availability-zone eu01-1`, + "$ stackit beta vpn gateway create --name xxx --plan-id p500 --routing-type POLICY_BASED --availability-zone-tunnel-1 eu01-1 --availability-zone-tunnel-2 eu01-1", + ), + examples.NewExample( + `Create a vpn gateway with the labels foo=bar and x=y`, + "$ stackit beta vpn gateway create --name xxx --plan-id p500 --routing-type POLICY_BASED --availability-zone-tunnel-1 eu01-1 --availability-zone-tunnel-2 eu01-1 --label foo=bar,x=y", + ), + examples.NewExample( + `Create a vpn gateway with bgp enabled, yyy as local asn and [aaa, bbb] as override advertised routes`, + "$ stackit beta vpn gateway create --name xxx --plan-id p500 --routing-type POLICY_BASED --availability-zone-tunnel-1 eu01-1 --availability-zone-tunnel-2 eu01-1 --bgp-local-asn yyy --bgp-override-advertised-routes aaa,bbb", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil || projectLabel == "" { + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a vpn gateway for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create vpn gateway: %w", err) + } + var gatewayId string + if resp != nil && resp.HasId() { + gatewayId = *resp.Id + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating vpn gateway", func() error { + _, err = wait.CreateGatewayWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, gatewayId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("waiting for vpn gateway creation: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(availabilityZoneTunnel1Flag, "", "Availability Zone of Tunnel 1") + cmd.Flags().String(availabilityZoneTunnel2Flag, "", "Availability Zone of Tunnel 2") + cmd.Flags().Int64(bgpLocalAsnFlag, 0, "ASN for private use (reserved by IANA), both 16Bit and 32Bit ranges are valid (RFC 6996)") + cmd.Flags().StringArray(bgpOverrideAdvertisedRoutesFlag, nil, "A list of IPv4 Prefixes to advertise via BGP") + cmd.Flags().String(nameFlag, "", "Gateway name") + cmd.Flags().StringToString(labelsFlag, nil, "Labels in key=value format, separated by commas") + cmd.Flags().String(planIdFlag, "", "Plan ID") + routingTypeFlag.Register(cmd.Flags()) + + err := flags.MarkFlagsRequired(cmd, availabilityZoneTunnel1Flag, availabilityZoneTunnel2Flag, nameFlag, planIdFlag, routingTypeFlag.Name()) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + bgpLocalAsn := flags.FlagToInt64Pointer(p, cmd, bgpLocalAsnFlag) + if bgpLocalAsn != nil { + if *bgpLocalAsn < 0 { + return nil, &errors.FlagValidationError{ + Flag: bgpLocalAsnFlag, + Details: "must be a positive integer", + } + } + } + bgpOverrideAdvertisedRoutes := flags.FlagToStringArrayValue(p, cmd, bgpOverrideAdvertisedRoutesFlag) + if bgpOverrideAdvertisedRoutes != nil { + if bgpLocalAsn == nil { + return nil, &errors.DependingFlagIsMissing{ + MissingFlag: bgpLocalAsnFlag, + SetFlag: bgpOverrideAdvertisedRoutesFlag, + } + } + } + + var bgp *vpn.BGPGatewayConfig + if bgpLocalAsn != nil || bgpOverrideAdvertisedRoutes != nil { + bgp = &vpn.BGPGatewayConfig{ + LocalAsn: *bgpLocalAsn, + OverrideAdvertisedRoutes: flags.FlagToStringArrayValue(p, cmd, bgpOverrideAdvertisedRoutesFlag), + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AvailabilityZone: vpn.CreateGatewayPayloadAvailabilityZones{ + Tunnel1: flags.FlagToStringValue(p, cmd, availabilityZoneTunnel1Flag), + Tunnel2: flags.FlagToStringValue(p, cmd, availabilityZoneTunnel2Flag), + }, + Bgp: bgp, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + PlanId: flags.FlagToStringValue(p, cmd, planIdFlag), + RoutingType: vpn.RoutingType(routingTypeFlag.Get()), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) vpn.ApiCreateGatewayRequest { + req := apiClient.DefaultAPI.CreateGateway(ctx, model.ProjectId, model.Region) + req = req.CreateGatewayPayload( + vpn.CreateGatewayPayload{ + AvailabilityZones: model.AvailabilityZone, + Bgp: model.Bgp, + DisplayName: model.Name, + Labels: model.Labels, + PlanId: model.PlanId, + RoutingType: model.RoutingType, + }, + ) + return req +} + +func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, item *vpn.GatewayResponse) error { + return p.OutputResult(outputFormat, item, func() error { + if item == nil { + p.Outputln("vpn gateway response is empty") + return nil + } + operation := "Created" + if async { + operation = "Triggered creation of" + } + p.Outputf( + "%s vpn gateway %q in project %q.\nGateway ID: %s\n", + operation, + item.DisplayName, + projectLabel, + utils.PtrString(item.Id), + ) + return nil + }) +} diff --git a/internal/cmd/beta/vpn/gateway/create/create_test.go b/internal/cmd/beta/vpn/gateway/create/create_test.go new file mode 100644 index 000000000..09f9b2bee --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/create/create_test.go @@ -0,0 +1,238 @@ +package create + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &vpn.APIClient{DefaultAPI: &vpn.DefaultAPIService{}} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testName = "test-name" +var testPlanId = "planId" +var testRoutingType = vpn.ROUTINGTYPE_POLICY_BASED +var testAvailabilityZoneTunnel1 = "eu01-1" +var testAvailabilityZoneTunnel2 = "eu01-2" +var testBgpLocalAsn = 64512 +var testBgpOverrideAdvertisedRoutes = "10.10.10.10/32" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + availabilityZoneTunnel1Flag: testAvailabilityZoneTunnel1, + availabilityZoneTunnel2Flag: testAvailabilityZoneTunnel2, + bgpLocalAsnFlag: strconv.Itoa(testBgpLocalAsn), + bgpOverrideAdvertisedRoutesFlag: testBgpOverrideAdvertisedRoutes, + nameFlag: testName, + labelsFlag: "foo=bar", + planIdFlag: testPlanId, + routingTypeFlag.Name(): string(testRoutingType), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Name: testName, + AvailabilityZone: vpn.CreateGatewayPayloadAvailabilityZones{ + Tunnel1: testAvailabilityZoneTunnel1, + Tunnel2: testAvailabilityZoneTunnel2, + }, + Bgp: &vpn.BGPGatewayConfig{ + LocalAsn: int64(testBgpLocalAsn), + OverrideAdvertisedRoutes: []string{ + testBgpOverrideAdvertisedRoutes, + }, + }, + Labels: &map[string]string{ + "foo": "bar", + }, + PlanId: testPlanId, + RoutingType: testRoutingType, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiCreateGatewayRequest)) vpn.ApiCreateGatewayRequest { + request := testClient.DefaultAPI.CreateGateway(testCtx, testProjectId, testRegion) + request = request.CreateGatewayPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(request *vpn.CreateGatewayPayload)) vpn.CreateGatewayPayload { + payload := vpn.CreateGatewayPayload{ + AvailabilityZones: vpn.CreateGatewayPayloadAvailabilityZones{ + Tunnel1: testAvailabilityZoneTunnel1, + Tunnel2: testAvailabilityZoneTunnel2, + }, + Bgp: &vpn.BGPGatewayConfig{ + LocalAsn: int64(testBgpLocalAsn), + OverrideAdvertisedRoutes: []string{ + testBgpOverrideAdvertisedRoutes, + }, + }, + DisplayName: testName, + Labels: &map[string]string{ + "foo": "bar", + }, + PlanId: testPlanId, + RoutingType: testRoutingType, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "required only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelsFlag) + delete(flagValues, bgpLocalAsnFlag) + delete(flagValues, bgpOverrideAdvertisedRoutesFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + model.Bgp = nil + }), + }, + { + description: "multiple labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelsFlag] = "label1=foo,label2=bar" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = &map[string]string{ + "label1": "foo", + "label2": "bar", + } + }), + }, + { + description: "missing required name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + 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 vpn.ApiCreateGatewayRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(vpn.NullableString{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + projectLabel string + item *vpn.GatewayResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + item: &vpn.GatewayResponse{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.item); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/gateway/delete/delete.go b/internal/cmd/beta/vpn/gateway/delete/delete.go new file mode 100644 index 000000000..d5785dd43 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/delete/delete.go @@ -0,0 +1,120 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" + vpnUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + gatewayIdArg = "GATEWAY_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + GatewayId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", gatewayIdArg), + Short: "Deletes a vpn gateway", + Long: "Deletes a vpn gateway.", + Args: args.SingleArg(gatewayIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete the vpn gateway with the ID "xxx"`, + "$ stackit beta vpn gateway delete xxx", + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + gatewayLabel, err := vpnUtils.GetGatewayName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.GatewayId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get gateway name: %v", err) + gatewayLabel = model.GatewayId + } else if gatewayLabel == "" { + gatewayLabel = model.GatewayId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the vpn gateway %q? (This cannot be undone)", gatewayLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete vpn gateway: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting gateway", func() error { + _, err = wait.DeleteGatewayWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.GatewayId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("waiting for gateway deletion: %w", err) + } + } + + operation := "Deleted" + if model.Async { + operation = "Triggered deletion of" + } + + params.Printer.Outputf("%s gateway %q\n", operation, gatewayLabel) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + gatewayId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + GatewayId: gatewayId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) vpn.ApiDeleteGatewayRequest { + return apiClient.DefaultAPI.DeleteGateway(ctx, model.ProjectId, model.Region, model.GatewayId) +} diff --git a/internal/cmd/beta/vpn/gateway/delete/delete_test.go b/internal/cmd/beta/vpn/gateway/delete/delete_test.go new file mode 100644 index 000000000..145a49ca8 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/delete/delete_test.go @@ -0,0 +1,174 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &vpn.APIClient{DefaultAPI: &vpn.DefaultAPIService{}} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" +var testGatewayId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testGatewayId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + GatewayId: testGatewayId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiDeleteGatewayRequest)) vpn.ApiDeleteGatewayRequest { + request := testClient.DefaultAPI.DeleteGateway(testCtx, testProjectId, testRegion, testGatewayId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "gateway id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "gateway id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + 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 vpn.ApiDeleteGatewayRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/gateway/describe/describe.go b/internal/cmd/beta/vpn/gateway/describe/describe.go new file mode 100644 index 000000000..2549f4661 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/describe/describe.go @@ -0,0 +1,125 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + gatewayIdArg = "GATEWAY_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + GatewayId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", gatewayIdArg), + Short: "Shows details of a gateway", + Long: "Shows details of a gateway.", + Args: args.SingleArg(gatewayIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe the gateway with the ID "xxx"`, + "$ stackit beta vpn gateway describe xxx", + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe vpn gateway: %w", err) + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil || projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, model.GatewayId, projectLabel, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + gatewayId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + GatewayId: gatewayId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) vpn.ApiGetGatewayRequest { + return apiClient.DefaultAPI.GetGateway(ctx, model.ProjectId, model.Region, model.GatewayId) +} + +func outputResult(p *print.Printer, outputFormat, gatewayId, projectLabel string, gateway *vpn.GatewayResponse) error { + return p.OutputResult(outputFormat, gateway, func() error { + if gateway == nil { + p.Outputf("gateway %q not found in project %q\n", gatewayId, projectLabel) + return nil + } + + table := tables.NewTable() + table.SetTitle("Gateway") + + table.AddRow("ID", utils.PtrString(gateway.Id)) + table.AddSeparator() + table.AddRow("NAME", gateway.DisplayName) + table.AddSeparator() + table.AddRow("Labels", utils.JoinStringMap(gateway.GetLabels(), ": ", "\n")) + table.AddSeparator() + table.AddRow("STATE", utils.PtrString(gateway.State)) + table.AddSeparator() + table.AddRow("Plan ID", gateway.PlanId) + table.AddSeparator() + table.AddRow("Routing Type", gateway.RoutingType) + table.AddSeparator() + table.AddRow("Availability Zones Tunnel 1", gateway.AvailabilityZones.Tunnel1) + table.AddSeparator() + table.AddRow("Availability Zones Tunnel 2", gateway.AvailabilityZones.Tunnel2) + + if err := table.Display(p); err != nil { + return fmt.Errorf("render tables: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/vpn/gateway/describe/describe_test.go b/internal/cmd/beta/vpn/gateway/describe/describe_test.go new file mode 100644 index 000000000..d0ca98f5a --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/describe/describe_test.go @@ -0,0 +1,212 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &vpn.APIClient{DefaultAPI: &vpn.DefaultAPIService{}} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testGatewayId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testGatewayId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + GatewayId: testGatewayId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiGetGatewayRequest)) vpn.ApiGetGatewayRequest { + request := testClient.DefaultAPI.GetGateway(testCtx, testProjectId, testRegion, testGatewayId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "gateway id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "gateway id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + 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 vpn.ApiGetGatewayRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + gatewayId string + projectLabel string + gateway *vpn.GatewayResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + gateway: &vpn.GatewayResponse{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.gatewayId, tt.args.gateway); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/gateway/gateway.go b/internal/cmd/beta/vpn/gateway/gateway.go new file mode 100644 index 000000000..0038106c6 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/gateway.go @@ -0,0 +1,34 @@ +package gateway + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/status" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "gateway", + Short: "Provides functionality for VPN gateway", + Long: "Provides functionality for VPN gateway.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(status.NewCmd(params)) +} diff --git a/internal/cmd/beta/vpn/gateway/list/list.go b/internal/cmd/beta/vpn/gateway/list/list.go new file mode 100644 index 000000000..42e465c8e --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/list/list.go @@ -0,0 +1,140 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all vpn gateways", + Long: "Lists all vpn gateways.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all vpn gateways`, + "$ stackit beta vpn gateway list", + ), + examples.NewExample( + `List up to 4 vpn gateways`, + "$ stackit beta vpn gateway list --limit 4", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list vpn gateways: %w", err) + } + + // Truncate output + items := utils.GetSliceFromPointer(&resp.Gateways) + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil || projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, items, projectLabel) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be grater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) vpn.ApiListGatewaysRequest { + return apiClient.DefaultAPI.ListGateways(ctx, model.ProjectId, model.Region) +} + +func outputResult(p *print.Printer, outputFormat string, gateways []vpn.GatewayResponse, projectLabel string) error { + return p.OutputResult(outputFormat, gateways, func() error { + if len(gateways) == 0 { + p.Info("No gateways found for %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "PLAN ID", "ROUTING TYPE", "STATE") + + for _, gateway := range gateways { + table.AddRow( + utils.PtrString(gateway.Id), + gateway.DisplayName, + gateway.PlanId, + gateway.RoutingType, + utils.PtrString(gateway.State), + ) + } + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/beta/vpn/gateway/list/list_test.go b/internal/cmd/beta/vpn/gateway/list/list_test.go new file mode 100644 index 000000000..9674587aa --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/list/list_test.go @@ -0,0 +1,196 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &vpn.APIClient{DefaultAPI: &vpn.DefaultAPIService{}} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testLimit int64 = 10 + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + limitFlag: strconv.FormatInt(testLimit, 10), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Limit: utils.Ptr(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiListGatewaysRequest)) vpn.ApiListGatewaysRequest { + request := testClient.DefaultAPI.ListGateways(testCtx, testProjectId, testRegion) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flag values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid limit 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "invalid limit 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + 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 vpn.ApiListGatewaysRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + gateways []vpn.GatewayResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty gateway in gateways", + args: args{ + gateways: []vpn.GatewayResponse{{}}, + }, + wantErr: false, + }, + { + name: "set empty gateway", + args: args{ + gateways: []vpn.GatewayResponse{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.gateways, tt.args.projectLabel); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/gateway/status/status.go b/internal/cmd/beta/vpn/gateway/status/status.go new file mode 100644 index 000000000..b9ff5ceb2 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/status/status.go @@ -0,0 +1,181 @@ +package status + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + gatewayIdArg = "GATEWAY_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + GatewayId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("status %s", gatewayIdArg), + Short: "Shows the status of a gateway", + Long: "Shows the status of a gateway.", + Args: args.SingleArg(gatewayIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Show the status of the gateway with the ID "xxx"`, + "$ stackit beta vpn gateway describe xxx", + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe vpn gateway: %w", err) + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil || projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, model.GatewayId, projectLabel, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + gatewayId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + GatewayId: gatewayId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) vpn.ApiGetGatewayStatusRequest { + return apiClient.DefaultAPI.GetGatewayStatus(ctx, model.ProjectId, model.Region, model.GatewayId) +} + +func outputResult(p *print.Printer, outputFormat, gatewayId, projectLabel string, gateway *vpn.GatewayStatusResponse) error { + return p.OutputResult(outputFormat, gateway, func() error { + if gateway == nil { + p.Outputf("gateway %q not found in project %q\n", gatewayId, projectLabel) + return nil + } + + mainTable := tables.NewTable() + mainTable.SetTitle("Gateway Status") + + mainTable.AddRow("ID", gateway.GetId()) + mainTable.AddSeparator() + mainTable.AddRow("NAME", gateway.GetDisplayName()) + mainTable.AddSeparator() + mainTable.AddRow("STATUS", gateway.GetGatewayStatus()) + if gateway.ErrorMessage != nil { + mainTable.AddSeparator() + mainTable.AddRow("ERROR MESSAGE", *gateway.ErrorMessage) + } + + ts := []tables.Table{ + mainTable, + } + for _, tunnel := range gateway.Tunnels { + ts = append(ts, tunnelTable(tunnel)) + } + + return tables.DisplayTables(p, ts) + }) +} + +func tunnelTable(tunnel vpn.VPNTunnels) tables.Table { + title := "Tunnel" + if tunnel.Name != nil { + title = string(*tunnel.Name) + } + + table := tables.NewTable() + table.SetTitle(title) + + table.AddSeparator() + table.AddRow("PUBLIC IP", tunnel.GetPublicIP()) + table.AddSeparator() + table.AddRow("INTERNAL NEXT HOP IP", tunnel.GetInternalNextHopIP()) + table.AddSeparator() + table.AddRow("STATE", tunnel.GetInstanceState()) + + if tunnel.BgpStatus.IsSet() { + table.AddSeparator() + routeString := "" + for _, route := range tunnel.BgpStatus.Get().Routes { + if route.Network != "" { + routeString += fmt.Sprintf("Network: %s; ", route.Network) + } + if route.Origin != "" { + routeString += fmt.Sprintf("Origin: %s; ", route.Origin) + } + if route.Path != "" { + routeString += fmt.Sprintf("Path: %s; ", route.Path) + } + if route.PeerId != "" { + routeString += fmt.Sprintf("PeerId: %s; ", route.PeerId) + } + routeString += fmt.Sprintf("Weight: %d\n", route.Weight) + } + table.AddRow("BGP Routes", routeString) + table.AddSeparator() + bgpPeers := "" + for _, peer := range tunnel.BgpStatus.Get().Peers { + if peer.PeerUptime != "" { + bgpPeers += fmt.Sprintf("PeerUptime: %s; ", peer.PeerUptime) + } + if peer.RemoteIP != "" { + bgpPeers += fmt.Sprintf("RemoteIP: %s; ", peer.RemoteIP) + } + if peer.State != "" { + bgpPeers += fmt.Sprintf("State: %s; ", peer.State) + } + bgpPeers += fmt.Sprintf("LocalAsn: %d; ", peer.LocalAs) + bgpPeers += fmt.Sprintf("PfxRcd: %d; ", peer.PfxRcd) + bgpPeers += fmt.Sprintf("PfxSnt: %d; ", peer.PfxSnt) + bgpPeers += fmt.Sprintf("RemoteAs: %d\n", peer.RemoteAs) + } + table.AddRow("BGP Peers", bgpPeers) + } + + return table +} diff --git a/internal/cmd/beta/vpn/gateway/status/status_test.go b/internal/cmd/beta/vpn/gateway/status/status_test.go new file mode 100644 index 000000000..4552144e8 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/status/status_test.go @@ -0,0 +1,212 @@ +package status + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &vpn.APIClient{DefaultAPI: &vpn.DefaultAPIService{}} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testGatewayId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testGatewayId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + GatewayId: testGatewayId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiGetGatewayStatusRequest)) vpn.ApiGetGatewayStatusRequest { + request := testClient.DefaultAPI.GetGatewayStatus(testCtx, testProjectId, testRegion, testGatewayId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "gateway id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "gateway id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + 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 vpn.ApiGetGatewayStatusRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + gatewayId string + projectLabel string + gateway *vpn.GatewayStatusResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + gateway: &vpn.GatewayStatusResponse{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.gatewayId, tt.args.gateway); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/plans/plans.go b/internal/cmd/beta/vpn/plans/plans.go new file mode 100644 index 000000000..d18fd73ab --- /dev/null +++ b/internal/cmd/beta/vpn/plans/plans.go @@ -0,0 +1,127 @@ +package plans + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "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/vpn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Lists all available plans", + Long: "Lists all available plans.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all available plans`, + "$ stackit beta vpn plans", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list vpn plans: %w", err) + } + + // Truncate output + items := utils.GetSliceFromPointer(&resp.Plans) + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, items, model.Region) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +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 grater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) vpn.ApiListPlansRequest { + return apiClient.DefaultAPI.ListPlans(ctx, model.Region) +} + +func outputResult(p *print.Printer, outputFormat string, plans []vpn.Plan, region string) error { + return p.OutputResult(outputFormat, plans, func() error { + if len(plans) == 0 { + p.Info("No plans found for Region %q\n", region) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "MAX BANDWIDTH", "MAX CONNECTIONS", "SKU") + + for _, plan := range plans { + table.AddRow( + utils.PtrString(plan.PlanId), + utils.PtrString(plan.Name), + utils.PtrString(plan.MaxBandwidth), + utils.PtrString(plan.MaxConnections), + utils.PtrString(plan.Sku), + ) + } + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/beta/vpn/plans/plans_test.go b/internal/cmd/beta/vpn/plans/plans_test.go new file mode 100644 index 000000000..7513ab482 --- /dev/null +++ b/internal/cmd/beta/vpn/plans/plans_test.go @@ -0,0 +1,175 @@ +package plans + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &vpn.APIClient{DefaultAPI: &vpn.DefaultAPIService{}} + +var testRegion = "eu01" +var testLimit int64 = 10 + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + // Project ID is not necessary for this request + regionFlag: testRegion, + limitFlag: strconv.FormatInt(testLimit, 10), + } + 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, + }, + Limit: utils.Ptr(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiListPlansRequest)) vpn.ApiListPlansRequest { + request := testClient.DefaultAPI.ListPlans(testCtx, testRegion) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flag values", + flagValues: map[string]string{}, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + }, + isValid: true, + }, + { + description: "invalid limit 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "invalid limit 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + 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 vpn.ApiListPlansRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + plans []vpn.Plan + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty plan in plans", + args: args{ + plans: []vpn.Plan{{}}, + }, + wantErr: false, + }, + { + name: "set empty plan", + args: args{ + plans: []vpn.Plan{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.plans, tt.args.projectLabel); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/quotas/quotas.go b/internal/cmd/beta/vpn/quotas/quotas.go new file mode 100644 index 000000000..48d1067ec --- /dev/null +++ b/internal/cmd/beta/vpn/quotas/quotas.go @@ -0,0 +1,121 @@ +package quotas + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "quotas", + Short: "Lists all vpn quotas", + Long: "Lists all vpn quotas.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all vpn quotas`, + "$ stackit beta vpn quotas", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list vpn quotas: %w", err) + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil || projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, resp, projectLabel) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be grater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) vpn.ApiListQuotasRequest { + return apiClient.DefaultAPI.ListQuotas(ctx, model.ProjectId, model.Region) +} + +func outputResult(p *print.Printer, outputFormat string, quota *vpn.QuotaListResponse, projectLabel string) error { + return p.OutputResult(outputFormat, quota, func() error { + if quota == nil { + p.Info("No quotas for %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("NAME", "LIMIT", "CURRENT USAGE") + + table.AddRow("Gateway Instances", quota.Quotas.Gateways.Limit, quota.Quotas.Gateways.Usage) + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/beta/vpn/quotas/quotas_test.go b/internal/cmd/beta/vpn/quotas/quotas_test.go new file mode 100644 index 000000000..d5579ff09 --- /dev/null +++ b/internal/cmd/beta/vpn/quotas/quotas_test.go @@ -0,0 +1,189 @@ +package quotas + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &vpn.APIClient{DefaultAPI: &vpn.DefaultAPIService{}} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testLimit int64 = 10 + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + limitFlag: strconv.FormatInt(testLimit, 10), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Limit: utils.Ptr(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiListQuotasRequest)) vpn.ApiListQuotasRequest { + request := testClient.DefaultAPI.ListQuotas(testCtx, testProjectId, testRegion) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flag values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid limit 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "invalid limit 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + 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 vpn.ApiListQuotasRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + quota *vpn.QuotaListResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty quotas", + args: args{ + quota: &vpn.QuotaListResponse{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.quota, tt.args.projectLabel); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/vpn.go b/internal/cmd/beta/vpn/vpn.go index 8ee353974..499d41d45 100644 --- a/internal/cmd/beta/vpn/vpn.go +++ b/internal/cmd/beta/vpn/vpn.go @@ -4,6 +4,9 @@ import ( "github.com/spf13/cobra" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/plans" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/quotas" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -22,4 +25,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(connection.NewCmd(params)) + cmd.AddCommand(gateway.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) + cmd.AddCommand(quotas.NewCmd(params)) } diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 9bb2713b5..2895ecbe7 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -53,6 +53,7 @@ const ( logsCustomEndpointFlag = "logs-custom-endpoint" sfsCustomEndpointFlag = "sfs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" + vpnCustomEndpointFlag = "vpn-custom-endpoint" ) type inputModel struct { @@ -172,6 +173,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(logsCustomEndpointFlag, "", "Logs API base URL, used in calls to this API") cmd.Flags().String(sfsCustomEndpointFlag, "", "SFS API base URL, used in calls to this API") cmd.Flags().String(cdnCustomEndpointFlag, "", "CDN API base URL, used in calls to this API") + cmd.Flags().String(vpnCustomEndpointFlag, "", "VPN API base URL, used in calls to this API") err := viper.BindPFlag(config.SessionTimeLimitKey, cmd.Flags().Lookup(sessionTimeLimitFlag)) cobra.CheckErr(err) @@ -240,6 +242,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.CDNCustomEndpointKey, cmd.Flags().Lookup(cdnCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.VPNCustomEndpointKey, cmd.Flags().Lookup(vpnCustomEndpointFlag)) + cobra.CheckErr(err) } func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 580292b2e..da43bfe9e 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -56,6 +56,7 @@ const ( intakeCustomEndpointFlag = "intake-custom-endpoint" logsCustomEndpointFlag = "logs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" + vpnCustomEndpointFlag = "vpn-custom-endpoint" ) var ( @@ -105,6 +106,7 @@ type inputModel struct { IntakeCustomEndpoint bool LogsCustomEndpoint bool CDNCustomEndpoint bool + VpnCustomEndpoint bool } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -246,6 +248,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if model.CDNCustomEndpoint { viper.Set(config.CDNCustomEndpointKey, "") } + if model.VpnCustomEndpoint { + viper.Set(config.VPNCustomEndpointKey, "") + } err := config.Write() if err != nil { @@ -300,6 +305,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(logsCustomEndpointFlag, false, "Logs API base URL. If unset, uses the default base URL") cmd.Flags().Bool(sfsCustomEndpointFlag, false, "SFS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(cdnCustomEndpointFlag, false, "Custom CDN endpoint URL. If unset, uses the default base URL") + cmd.Flags().Bool(vpnCustomEndpointFlag, false, "VPN API base URL. If unset, uses the default base URL") } func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { @@ -345,6 +351,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { IntakeCustomEndpoint: flags.FlagToBoolValue(p, cmd, intakeCustomEndpointFlag), LogsCustomEndpoint: flags.FlagToBoolValue(p, cmd, logsCustomEndpointFlag), CDNCustomEndpoint: flags.FlagToBoolValue(p, cmd, cdnCustomEndpointFlag), + VpnCustomEndpoint: flags.FlagToBoolValue(p, cmd, vpnCustomEndpointFlag), } p.DebugInputModel(model) diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 17edcc9b7..feee86e38 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -48,6 +48,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool intakeCustomEndpointFlag: true, logsCustomEndpointFlag: true, cdnCustomEndpointFlag: true, + vpnCustomEndpointFlag: true, } for _, mod := range mods { mod(flagValues) @@ -94,6 +95,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { IntakeCustomEndpoint: true, LogsCustomEndpoint: true, CDNCustomEndpoint: true, + VpnCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -156,6 +158,7 @@ func TestParseInput(t *testing.T) { model.IntakeCustomEndpoint = false model.LogsCustomEndpoint = false model.CDNCustomEndpoint = false + model.VpnCustomEndpoint = false }), }, { @@ -358,6 +361,16 @@ func TestParseInput(t *testing.T) { model.CDNCustomEndpoint = false }), }, + { + description: "vpn custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[vpnCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.VpnCustomEndpoint = false + }), + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 9e1084dac..effb68051 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -122,6 +122,7 @@ var ConfigKeys = []string{ ServiceEnablementCustomEndpointKey, SfsCustomEndpointKey, TokenCustomEndpointKey, + VPNCustomEndpointKey, } var defaultConfigFolderPath string @@ -213,6 +214,7 @@ func setConfigDefaults() { viper.SetDefault(AlbCustomEndpoint, "") viper.SetDefault(LogsCustomEndpointKey, "") viper.SetDefault(CDNCustomEndpointKey, "") + viper.SetDefault(VPNCustomEndpointKey, "") } func getConfigFilePath(configFolder string) string { diff --git a/internal/pkg/services/vpn/utils/utils.go b/internal/pkg/services/vpn/utils/utils.go new file mode 100644 index 000000000..dfba86dd9 --- /dev/null +++ b/internal/pkg/services/vpn/utils/utils.go @@ -0,0 +1,19 @@ +package utils + +import ( + "context" + "fmt" + + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" +) + +func GetGatewayName(ctx context.Context, client vpn.DefaultAPI, projectId, region, gatewayId string) (string, error) { + resp, err := client.GetGateway(ctx, projectId, region, gatewayId).Execute() + if err != nil { + return "", fmt.Errorf("get gateway: %w", err) + } + if resp != nil { + return resp.DisplayName, nil + } + return "", nil +} diff --git a/internal/pkg/services/vpn/utils/utils_test.go b/internal/pkg/services/vpn/utils/utils_test.go new file mode 100644 index 000000000..27d173ca4 --- /dev/null +++ b/internal/pkg/services/vpn/utils/utils_test.go @@ -0,0 +1,87 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + testGatewayName = "test-gateway-01" + testRegion = "eu01" +) + +var ( + testProjectId = uuid.NewString() + testGatewayId = uuid.NewString() +) + +type mockSettings struct { + getGatewayFails bool + getGatewayResp *vpn.GatewayResponse +} + +func newAPIMock(settings *mockSettings) vpn.DefaultAPI { + return &vpn.DefaultAPIServiceMock{ + GetGatewayExecuteMock: utils.Ptr(func(_ vpn.ApiGetGatewayRequest) (*vpn.GatewayResponse, error) { + if settings.getGatewayFails { + return nil, fmt.Errorf("could not get gateway details") + } + + return settings.getGatewayResp, nil + }), + } +} + +func TestGetGatewayName(t *testing.T) { + tests := []struct { + description string + getGatewayResp *vpn.GatewayResponse + getGatewayFails bool + isValid bool + expectedOutput string + }{ + { + description: "base", + getGatewayResp: &vpn.GatewayResponse{ + DisplayName: testGatewayName, + }, + isValid: true, + expectedOutput: testGatewayName, + }, + { + description: "get gateway fails", + getGatewayFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := newAPIMock(&mockSettings{ + getGatewayFails: tt.getGatewayFails, + getGatewayResp: tt.getGatewayResp, + }) + + output, err := GetGatewayName(context.Background(), client, testProjectId, testRegion, testGatewayId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +}