From 09c59324e9d56d0340b073469d3ac44b469ba116 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Thu, 2 Jul 2026 13:22:49 +0200 Subject: [PATCH 1/3] feat(vpn): onboard gateway relates to STACKITCLI-353 --- .../cmd/beta/vpn/gateway/create/create.go | 217 ++++++++++++++++ .../beta/vpn/gateway/create/create_test.go | 238 ++++++++++++++++++ .../cmd/beta/vpn/gateway/delete/delete.go | 120 +++++++++ .../beta/vpn/gateway/delete/delete_test.go | 174 +++++++++++++ .../cmd/beta/vpn/gateway/describe/describe.go | 125 +++++++++ .../vpn/gateway/describe/describe_test.go | 212 ++++++++++++++++ internal/cmd/beta/vpn/gateway/gateway.go | 32 +++ internal/cmd/beta/vpn/gateway/list/list.go | 136 ++++++++++ .../cmd/beta/vpn/gateway/list/list_test.go | 196 +++++++++++++++ internal/cmd/beta/vpn/plans/plans.go | 136 ++++++++++ internal/cmd/beta/vpn/plans/plans_test.go | 196 +++++++++++++++ internal/cmd/beta/vpn/quotas/quotas.go | 121 +++++++++ internal/cmd/beta/vpn/quotas/quotas_test.go | 189 ++++++++++++++ internal/cmd/beta/vpn/vpn.go | 6 + internal/cmd/config/set/set.go | 4 + internal/cmd/config/unset/unset.go | 7 + internal/cmd/config/unset/unset_test.go | 13 + internal/pkg/config/config.go | 2 + internal/pkg/services/vpn/utils/utils.go | 19 ++ internal/pkg/services/vpn/utils/utils_test.go | 87 +++++++ 20 files changed, 2230 insertions(+) create mode 100644 internal/cmd/beta/vpn/gateway/create/create.go create mode 100644 internal/cmd/beta/vpn/gateway/create/create_test.go create mode 100644 internal/cmd/beta/vpn/gateway/delete/delete.go create mode 100644 internal/cmd/beta/vpn/gateway/delete/delete_test.go create mode 100644 internal/cmd/beta/vpn/gateway/describe/describe.go create mode 100644 internal/cmd/beta/vpn/gateway/describe/describe_test.go create mode 100644 internal/cmd/beta/vpn/gateway/gateway.go create mode 100644 internal/cmd/beta/vpn/gateway/list/list.go create mode 100644 internal/cmd/beta/vpn/gateway/list/list_test.go create mode 100644 internal/cmd/beta/vpn/plans/plans.go create mode 100644 internal/cmd/beta/vpn/plans/plans_test.go create mode 100644 internal/cmd/beta/vpn/quotas/quotas.go create mode 100644 internal/cmd/beta/vpn/quotas/quotas_test.go create mode 100644 internal/pkg/services/vpn/utils/utils.go create mode 100644 internal/pkg/services/vpn/utils/utils_test.go 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..6aa98e38e --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/create/create.go @@ -0,0 +1,217 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "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" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api/wait" +) + +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" + routingTypeFlag = "routing-type" +) + +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") + cmd.Flags().String(routingTypeFlag, "", fmt.Sprintf("Routing Type: %q", vpn.AllowedRoutingTypeEnumValues)) + // cmd.Flags().Var(flags.EnumFlag(false, "", sdkUtils.EnumSliceToStringSlice(vpn.AllowedRoutingTypeEnumValues)...), routingTypeFlag, fmt.Sprintf("Routing Type, one of: %q", vpn.AllowedRoutingTypeEnumValues)) + + err := flags.MarkFlagsRequired(cmd, availabilityZoneTunnel1Flag, availabilityZoneTunnel2Flag, nameFlag, planIdFlag, routingTypeFlag) + 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(flags.FlagToStringValue(p, cmd, routingTypeFlag)), + } + + 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..0a389c551 --- /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: 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..5fdc74a6d --- /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 a 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..874fcee27 --- /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 a gateway with 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", gateway.GetLabels()) + 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..c86a0894a --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/gateway.go @@ -0,0 +1,32 @@ +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/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)) +} 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..a424eb241 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/list/list.go @@ -0,0 +1,136 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "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" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" +) + +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", + ), + ), + 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/plans/plans.go b/internal/cmd/beta/vpn/plans/plans.go new file mode 100644 index 000000000..8f94a6a30 --- /dev/null +++ b/internal/cmd/beta/vpn/plans/plans.go @@ -0,0 +1,136 @@ +package plans + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "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" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" +) + +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] + } + + 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.ApiListPlansRequest { + return apiClient.DefaultAPI.ListPlans(ctx, model.Region) +} + +func outputResult(p *print.Printer, outputFormat string, plans []vpn.Plan, projectLabel string) error { + return p.OutputResult(outputFormat, plans, func() error { + + if len(plans) == 0 { + p.Info("No plans found for %q\n", projectLabel) + 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..db017d48f --- /dev/null +++ b/internal/cmd/beta/vpn/plans/plans_test.go @@ -0,0 +1,196 @@ +package plans + +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.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{}, + 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.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..5a338fee3 --- /dev/null +++ b/internal/cmd/beta/vpn/quotas/quotas.go @@ -0,0 +1,121 @@ +package quotas + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "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" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" +) + +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) + } + }) + } +} From f0fe08fe7d2b086541b96c708105faa9a79dbdd4 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Mon, 6 Jul 2026 14:46:43 +0200 Subject: [PATCH 2/3] fixed remaining issues --- docs/stackit_beta_vpn.md | 3 ++ docs/stackit_beta_vpn_gateway.md | 37 +++++++++++++ docs/stackit_beta_vpn_gateway_create.md | 54 +++++++++++++++++++ docs/stackit_beta_vpn_gateway_delete.md | 40 ++++++++++++++ docs/stackit_beta_vpn_gateway_describe.md | 40 ++++++++++++++ docs/stackit_beta_vpn_gateway_list.md | 44 +++++++++++++++ docs/stackit_beta_vpn_plans.md | 41 ++++++++++++++ docs/stackit_beta_vpn_quotas.md | 41 ++++++++++++++ docs/stackit_config_set.md | 1 + docs/stackit_config_unset.md | 1 + .../cmd/beta/vpn/gateway/create/create.go | 21 +++++--- .../beta/vpn/gateway/create/create_test.go | 2 +- .../cmd/beta/vpn/gateway/describe/describe.go | 4 +- internal/cmd/beta/vpn/gateway/list/list.go | 8 ++- internal/cmd/beta/vpn/plans/plans.go | 19 ++----- internal/cmd/beta/vpn/plans/plans_test.go | 39 ++++---------- internal/cmd/beta/vpn/quotas/quotas.go | 4 +- 17 files changed, 341 insertions(+), 58 deletions(-) create mode 100644 docs/stackit_beta_vpn_gateway.md create mode 100644 docs/stackit_beta_vpn_gateway_create.md create mode 100644 docs/stackit_beta_vpn_gateway_delete.md create mode 100644 docs/stackit_beta_vpn_gateway_describe.md create mode 100644 docs/stackit_beta_vpn_gateway_list.md create mode 100644 docs/stackit_beta_vpn_plans.md create mode 100644 docs/stackit_beta_vpn_quotas.md 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..10c37aebc --- /dev/null +++ b/docs/stackit_beta_vpn_gateway.md @@ -0,0 +1,37 @@ +## 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 + 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..986503e35 --- /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 a 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..82b8f23bf --- /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 a 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_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 index 6aa98e38e..245a0d1ea 100644 --- a/internal/cmd/beta/vpn/gateway/create/create.go +++ b/internal/cmd/beta/vpn/gateway/create/create.go @@ -6,6 +6,9 @@ import ( "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" @@ -17,8 +20,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" - "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api/wait" ) const ( @@ -29,7 +30,14 @@ const ( nameFlag = "name" labelsFlag = "labels" planIdFlag = "plan-id" - routingTypeFlag = "routing-type" +) + +var ( + routingTypeFlag = flags.StringEnumFlag( + "routing-type", + vpn.AllowedRoutingTypeEnumValues, + "Routing Type of the VPN", + ) ) type inputModel struct { @@ -123,10 +131,9 @@ func configureFlags(cmd *cobra.Command) { 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") - cmd.Flags().String(routingTypeFlag, "", fmt.Sprintf("Routing Type: %q", vpn.AllowedRoutingTypeEnumValues)) - // cmd.Flags().Var(flags.EnumFlag(false, "", sdkUtils.EnumSliceToStringSlice(vpn.AllowedRoutingTypeEnumValues)...), routingTypeFlag, fmt.Sprintf("Routing Type, one of: %q", vpn.AllowedRoutingTypeEnumValues)) + routingTypeFlag.Register(cmd.Flags()) - err := flags.MarkFlagsRequired(cmd, availabilityZoneTunnel1Flag, availabilityZoneTunnel2Flag, nameFlag, planIdFlag, routingTypeFlag) + err := flags.MarkFlagsRequired(cmd, availabilityZoneTunnel1Flag, availabilityZoneTunnel2Flag, nameFlag, planIdFlag, routingTypeFlag.Name()) cobra.CheckErr(err) } @@ -173,7 +180,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, Name: flags.FlagToStringValue(p, cmd, nameFlag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), PlanId: flags.FlagToStringValue(p, cmd, planIdFlag), - RoutingType: vpn.RoutingType(flags.FlagToStringValue(p, cmd, routingTypeFlag)), + RoutingType: vpn.RoutingType(routingTypeFlag.Get()), } p.DebugInputModel(model) diff --git a/internal/cmd/beta/vpn/gateway/create/create_test.go b/internal/cmd/beta/vpn/gateway/create/create_test.go index 0a389c551..09f9b2bee 100644 --- a/internal/cmd/beta/vpn/gateway/create/create_test.go +++ b/internal/cmd/beta/vpn/gateway/create/create_test.go @@ -46,7 +46,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st nameFlag: testName, labelsFlag: "foo=bar", planIdFlag: testPlanId, - routingTypeFlag: string(testRoutingType), + routingTypeFlag.Name(): string(testRoutingType), } for _, mod := range mods { mod(flagValues) diff --git a/internal/cmd/beta/vpn/gateway/describe/describe.go b/internal/cmd/beta/vpn/gateway/describe/describe.go index 874fcee27..357fb81de 100644 --- a/internal/cmd/beta/vpn/gateway/describe/describe.go +++ b/internal/cmd/beta/vpn/gateway/describe/describe.go @@ -36,7 +36,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Args: args.SingleArg(gatewayIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( - `Describe a gateway with ID "xxx"`, + `Describe a gateway with the ID "xxx"`, "$ stackit beta vpn gateway describe xxx", ), ), @@ -105,7 +105,7 @@ func outputResult(p *print.Printer, outputFormat, gatewayId, projectLabel string table.AddSeparator() table.AddRow("NAME", gateway.DisplayName) table.AddSeparator() - table.AddRow("Labels", gateway.GetLabels()) + table.AddRow("Labels", utils.JoinStringMap(gateway.GetLabels(), ": ", "\n")) table.AddSeparator() table.AddRow("STATE", utils.PtrString(gateway.State)) table.AddSeparator() diff --git a/internal/cmd/beta/vpn/gateway/list/list.go b/internal/cmd/beta/vpn/gateway/list/list.go index a424eb241..42e465c8e 100644 --- a/internal/cmd/beta/vpn/gateway/list/list.go +++ b/internal/cmd/beta/vpn/gateway/list/list.go @@ -6,6 +6,8 @@ import ( "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" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" ) const ( @@ -40,6 +41,10 @@ func NewCmd(params *types.CmdParams) *cobra.Command { `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() @@ -112,7 +117,6 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClie 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 diff --git a/internal/cmd/beta/vpn/plans/plans.go b/internal/cmd/beta/vpn/plans/plans.go index 8f94a6a30..d18fd73ab 100644 --- a/internal/cmd/beta/vpn/plans/plans.go +++ b/internal/cmd/beta/vpn/plans/plans.go @@ -6,18 +6,18 @@ import ( "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" - vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" ) const ( @@ -67,12 +67,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { 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) + return outputResult(params.Printer, model.OutputFormat, items, model.Region) }, } configureFlags(cmd) @@ -85,9 +80,6 @@ func configureFlags(cmd *cobra.Command) { 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 { @@ -110,11 +102,10 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClie return apiClient.DefaultAPI.ListPlans(ctx, model.Region) } -func outputResult(p *print.Printer, outputFormat string, plans []vpn.Plan, projectLabel string) error { +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 %q\n", projectLabel) + p.Info("No plans found for Region %q\n", region) return nil } diff --git a/internal/cmd/beta/vpn/plans/plans_test.go b/internal/cmd/beta/vpn/plans/plans_test.go index db017d48f..7513ab482 100644 --- a/internal/cmd/beta/vpn/plans/plans_test.go +++ b/internal/cmd/beta/vpn/plans/plans_test.go @@ -7,7 +7,6 @@ import ( "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" @@ -16,7 +15,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) -var projectIdFlag = globalflags.ProjectIdFlag var regionFlag = globalflags.RegionFlag type testCtxKey struct{} @@ -24,16 +22,14 @@ 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), + // Project ID is not necessary for this request + regionFlag: testRegion, + limitFlag: strconv.FormatInt(testLimit, 10), } for _, mod := range mods { mod(flagValues) @@ -44,7 +40,6 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ - ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, Region: testRegion, }, @@ -81,28 +76,12 @@ func TestParseInput(t *testing.T) { { 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, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + }, + isValid: true, }, { description: "invalid limit 1", diff --git a/internal/cmd/beta/vpn/quotas/quotas.go b/internal/cmd/beta/vpn/quotas/quotas.go index 5a338fee3..48d1067ec 100644 --- a/internal/cmd/beta/vpn/quotas/quotas.go +++ b/internal/cmd/beta/vpn/quotas/quotas.go @@ -6,6 +6,8 @@ import ( "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" @@ -16,7 +18,6 @@ import ( "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" - vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" ) const ( @@ -105,7 +106,6 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClie 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 From 9bfa88a0a3a9a498852745f3d47b4d3318d42d76 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Tue, 7 Jul 2026 15:23:17 +0200 Subject: [PATCH 3/3] add status endpoint --- docs/stackit_beta_vpn_gateway.md | 1 + docs/stackit_beta_vpn_gateway_delete.md | 2 +- docs/stackit_beta_vpn_gateway_describe.md | 2 +- docs/stackit_beta_vpn_gateway_status.md | 40 ++++ .../cmd/beta/vpn/gateway/delete/delete.go | 2 +- .../cmd/beta/vpn/gateway/describe/describe.go | 2 +- internal/cmd/beta/vpn/gateway/gateway.go | 2 + .../cmd/beta/vpn/gateway/status/status.go | 181 +++++++++++++++ .../beta/vpn/gateway/status/status_test.go | 212 ++++++++++++++++++ 9 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 docs/stackit_beta_vpn_gateway_status.md create mode 100644 internal/cmd/beta/vpn/gateway/status/status.go create mode 100644 internal/cmd/beta/vpn/gateway/status/status_test.go diff --git a/docs/stackit_beta_vpn_gateway.md b/docs/stackit_beta_vpn_gateway.md index 10c37aebc..17ea3a627 100644 --- a/docs/stackit_beta_vpn_gateway.md +++ b/docs/stackit_beta_vpn_gateway.md @@ -34,4 +34,5 @@ stackit beta vpn gateway [flags] * [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_delete.md b/docs/stackit_beta_vpn_gateway_delete.md index 986503e35..65cc5feed 100644 --- a/docs/stackit_beta_vpn_gateway_delete.md +++ b/docs/stackit_beta_vpn_gateway_delete.md @@ -13,7 +13,7 @@ stackit beta vpn gateway delete GATEWAY_ID [flags] ### Examples ``` - Delete a vpn gateway with the ID "xxx" + Delete the vpn gateway with the ID "xxx" $ stackit beta vpn gateway delete xxx ``` diff --git a/docs/stackit_beta_vpn_gateway_describe.md b/docs/stackit_beta_vpn_gateway_describe.md index 82b8f23bf..0c029c81a 100644 --- a/docs/stackit_beta_vpn_gateway_describe.md +++ b/docs/stackit_beta_vpn_gateway_describe.md @@ -13,7 +13,7 @@ stackit beta vpn gateway describe GATEWAY_ID [flags] ### Examples ``` - Describe a gateway with the ID "xxx" + Describe the gateway with the ID "xxx" $ stackit beta vpn gateway describe xxx ``` 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/internal/cmd/beta/vpn/gateway/delete/delete.go b/internal/cmd/beta/vpn/gateway/delete/delete.go index 5fdc74a6d..d5785dd43 100644 --- a/internal/cmd/beta/vpn/gateway/delete/delete.go +++ b/internal/cmd/beta/vpn/gateway/delete/delete.go @@ -37,7 +37,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Args: args.SingleArg(gatewayIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( - `Delete a vpn gateway with the ID "xxx"`, + `Delete the vpn gateway with the ID "xxx"`, "$ stackit beta vpn gateway delete xxx", ), ), diff --git a/internal/cmd/beta/vpn/gateway/describe/describe.go b/internal/cmd/beta/vpn/gateway/describe/describe.go index 357fb81de..2549f4661 100644 --- a/internal/cmd/beta/vpn/gateway/describe/describe.go +++ b/internal/cmd/beta/vpn/gateway/describe/describe.go @@ -36,7 +36,7 @@ func NewCmd(params *types.CmdParams) *cobra.Command { Args: args.SingleArg(gatewayIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( - `Describe a gateway with the ID "xxx"`, + `Describe the gateway with the ID "xxx"`, "$ stackit beta vpn gateway describe xxx", ), ), diff --git a/internal/cmd/beta/vpn/gateway/gateway.go b/internal/cmd/beta/vpn/gateway/gateway.go index c86a0894a..0038106c6 100644 --- a/internal/cmd/beta/vpn/gateway/gateway.go +++ b/internal/cmd/beta/vpn/gateway/gateway.go @@ -5,6 +5,7 @@ import ( "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" @@ -29,4 +30,5 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { 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/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) + } + }) + } +}