From 5a7f5c428cc00375c3b751affb3734fe35dd9b29 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 20 May 2026 13:51:18 +0200 Subject: [PATCH 1/7] feat(vpn): onboard gateway relates to STACKITCLI-353 --- go.mod | 1 + go.sum | 2 + 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 | 3 + internal/pkg/services/vpn/client/client.go | 15 ++++ internal/pkg/services/vpn/utils/utils.go | 23 +++++ internal/pkg/services/vpn/utils/utils_test.go | 87 +++++++++++++++++++ 9 files changed, 155 insertions(+) create mode 100644 internal/pkg/services/vpn/client/client.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/go.mod b/go.mod index cf7759725..979c17f0b 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.7 github.com/stackitcloud/stackit-sdk-go/services/ske v1.11.0 github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.3 + github.com/stackitcloud/stackit-sdk-go/services/vpn v0.9.0 github.com/zalando/go-keyring v0.2.6 golang.org/x/mod v0.34.0 golang.org/x/oauth2 v0.35.0 diff --git a/go.sum b/go.sum index bf1428b79..bbf69ffcf 100644 --- a/go.sum +++ b/go.sum @@ -656,6 +656,8 @@ github.com/stackitcloud/stackit-sdk-go/services/ske v1.11.0 h1:QoKyQPe8FqDqJLNgE github.com/stackitcloud/stackit-sdk-go/services/ske v1.11.0/go.mod h1:KhVYCR58wETqdI7Quwhe3OR3BhB2T/b7DzaMsfDnr8g= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.3 h1:AQrcr+qeIuZob+3TT2q1L4WOPtpsu5SEpkTnOUHDqfE= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.3/go.mod h1:8BBGC69WFXWWmKgzSjgE4HvsI7pEgO0RN2cASwuPJ18= +github.com/stackitcloud/stackit-sdk-go/services/vpn v0.9.0 h1:ZqZ0Wbkyz1rnclTTvnAKNalo0oSNWq9pyS4lO/athaQ= +github.com/stackitcloud/stackit-sdk-go/services/vpn v0.9.0/go.mod h1:toIjQk1dhxdUFVyCWJJja0w/0nFpDid8MWX0ukQfvfo= github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 9bb2713b5..e5c1ce6b0 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 ad57f8113..6ae5290d1 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -58,6 +58,7 @@ const ( intakeCustomEndpointFlag = "intake-custom-endpoint" logsCustomEndpointFlag = "logs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" + vpnCustomEndpointFlag = "vpn-custom-endpoint" ) type inputModel struct { @@ -102,6 +103,7 @@ type inputModel struct { IntakeCustomEndpoint bool LogsCustomEndpoint bool CDNCustomEndpoint bool + VpnCustomEndpoint bool } func NewCmd(params *types.CmdParams) *cobra.Command { @@ -243,6 +245,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 { @@ -297,6 +302,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 { @@ -342,6 +348,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 a2852b59f..a030d6b76 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -54,6 +54,7 @@ const ( CDNCustomEndpointKey = "cdn_custom_endpoint" IntakeCustomEndpointKey = "intake_custom_endpoint" LogsCustomEndpointKey = "logs_custom_endpoint" + VpnCustomEndpointKey = "vpn_custom_endpoint" ProjectNameKey = "project_name" DefaultProfileName = "default" @@ -121,6 +122,7 @@ var ConfigKeys = []string{ ServiceEnablementCustomEndpointKey, SfsCustomEndpointKey, TokenCustomEndpointKey, + VpnCustomEndpointKey, } var defaultConfigFolderPath string @@ -212,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/client/client.go b/internal/pkg/services/vpn/client/client.go new file mode 100644 index 000000000..92d07a1a6 --- /dev/null +++ b/internal/pkg/services/vpn/client/client.go @@ -0,0 +1,15 @@ +package client + +import ( + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*vpn.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.VpnCustomEndpointKey), false, genericclient.CreateApiClient[*vpn.APIClient](vpn.NewAPIClient)) +} diff --git a/internal/pkg/services/vpn/utils/utils.go b/internal/pkg/services/vpn/utils/utils.go new file mode 100644 index 000000000..96f4bac34 --- /dev/null +++ b/internal/pkg/services/vpn/utils/utils.go @@ -0,0 +1,23 @@ +package utils + +import ( + "context" + "fmt" + + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" +) + +func GetGatewayName(ctx context.Context, client vpn.DefaultAPI, projectId, regionString, gatewayId string) (string, error) { + region := vpn.Region(regionString) + if !region.IsValid() { + return "", fmt.Errorf("region %q not found", region) + } + 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 faed3cc30be4e84536b715cafe21e982d87ef074 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 20 May 2026 14:59:45 +0200 Subject: [PATCH 2/7] add list command --- internal/cmd/beta/beta.go | 2 + internal/cmd/beta/vpn/gateway/gateway.go | 26 +++ internal/cmd/beta/vpn/gateway/list/list.go | 134 ++++++++++++ .../cmd/beta/vpn/gateway/list/list_test.go | 196 ++++++++++++++++++ internal/cmd/beta/vpn/vpn.go | 26 +++ 5 files changed, 384 insertions(+) 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/vpn.go diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 1bcb3ae55..f739b0c03 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -11,6 +11,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -47,4 +48,5 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(edge.NewCmd(params)) cmd.AddCommand(intake.NewCmd(params)) cmd.AddCommand(cdn.NewCmd(params)) + cmd.AddCommand(vpn.NewCmd(params)) } diff --git a/internal/cmd/beta/vpn/gateway/gateway.go b/internal/cmd/beta/vpn/gateway/gateway.go new file mode 100644 index 000000000..315a8ed39 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/gateway.go @@ -0,0 +1,26 @@ +package gateway + +import ( + "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(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..565b12fca --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/list/list.go @@ -0,0 +1,134 @@ +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, vpn.Region(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", "STATE") + + for _, gateway := range gateways { + table.AddRow( + gateway.Id, + gateway.DisplayName, + 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..e52008090 --- /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, vpn.Region(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/vpn.go b/internal/cmd/beta/vpn/vpn.go new file mode 100644 index 000000000..c0670f3a1 --- /dev/null +++ b/internal/cmd/beta/vpn/vpn.go @@ -0,0 +1,26 @@ +package vpn + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway" + "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: "vpn", + Short: "Provides functionality for VPN", + Long: "Provides functionality for VPN.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(gateway.NewCmd(params)) +} From bc1c7af0fc8f2deafa9af8f78dbf38d061ee7964 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 20 May 2026 15:00:21 +0200 Subject: [PATCH 3/7] add describe command --- .../cmd/beta/vpn/gateway/describe/describe.go | 115 ++++++++++ .../vpn/gateway/describe/describe_test.go | 212 ++++++++++++++++++ internal/cmd/beta/vpn/gateway/gateway.go | 2 + 3 files changed, 329 insertions(+) create mode 100644 internal/cmd/beta/vpn/gateway/describe/describe.go create mode 100644 internal/cmd/beta/vpn/gateway/describe/describe_test.go 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..585d5b03a --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/describe/describe.go @@ -0,0 +1,115 @@ +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, vpn.Region(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("STATE", utils.PtrString(gateway.State)) + + 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..4719c8ea1 --- /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, vpn.Region(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 index 315a8ed39..108e6fde4 100644 --- a/internal/cmd/beta/vpn/gateway/gateway.go +++ b/internal/cmd/beta/vpn/gateway/gateway.go @@ -1,6 +1,7 @@ package gateway import ( + "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" @@ -23,4 +24,5 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) } From 6d7009afa11ed9c5b3df26a3e11843424e550f77 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 20 May 2026 17:27:04 +0200 Subject: [PATCH 4/7] improve list and describe --- internal/cmd/beta/vpn/gateway/describe/describe.go | 10 ++++++++++ internal/cmd/beta/vpn/gateway/list/list.go | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/cmd/beta/vpn/gateway/describe/describe.go b/internal/cmd/beta/vpn/gateway/describe/describe.go index 585d5b03a..360602064 100644 --- a/internal/cmd/beta/vpn/gateway/describe/describe.go +++ b/internal/cmd/beta/vpn/gateway/describe/describe.go @@ -105,7 +105,17 @@ func outputResult(p *print.Printer, outputFormat, gatewayId, projectLabel string table.AddSeparator() table.AddRow("NAME", gateway.DisplayName) table.AddSeparator() + table.AddRow("Labels", gateway.Labels) + 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) diff --git a/internal/cmd/beta/vpn/gateway/list/list.go b/internal/cmd/beta/vpn/gateway/list/list.go index 565b12fca..b0571f09d 100644 --- a/internal/cmd/beta/vpn/gateway/list/list.go +++ b/internal/cmd/beta/vpn/gateway/list/list.go @@ -123,9 +123,9 @@ func outputResult(p *print.Printer, outputFormat string, gateways []vpn.GatewayR for _, gateway := range gateways { table.AddRow( - gateway.Id, + utils.PtrString(gateway.Id), gateway.DisplayName, - gateway.State, + utils.PtrString(gateway.State), ) } p.Outputln(table.Render()) From 27e06c0972b8fd50ea28f12fc11355af523bb4e3 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 20 May 2026 17:46:28 +0200 Subject: [PATCH 5/7] delete command --- .../cmd/beta/vpn/gateway/delete/delete.go | 120 ++++++++++++ .../beta/vpn/gateway/delete/delete_test.go | 174 ++++++++++++++++++ internal/cmd/beta/vpn/gateway/gateway.go | 2 + 3 files changed, 296 insertions(+) create mode 100644 internal/cmd/beta/vpn/gateway/delete/delete.go create mode 100644 internal/cmd/beta/vpn/gateway/delete/delete_test.go 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..3c73867bd --- /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, vpn.Region(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, vpn.Region(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..d9350d286 --- /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, vpn.Region(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/gateway.go b/internal/cmd/beta/vpn/gateway/gateway.go index 108e6fde4..511a500d7 100644 --- a/internal/cmd/beta/vpn/gateway/gateway.go +++ b/internal/cmd/beta/vpn/gateway/gateway.go @@ -1,6 +1,7 @@ package gateway 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/pkg/args" @@ -25,4 +26,5 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(list.NewCmd(params)) cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) } From 754cab42f05c33e0b00d593a0ae90cd498628ab9 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 20 May 2026 19:25:06 +0200 Subject: [PATCH 6/7] add create command --- .../cmd/beta/vpn/gateway/create/create.go | 200 +++++++++++++++++ .../beta/vpn/gateway/create/create_test.go | 207 ++++++++++++++++++ internal/cmd/beta/vpn/gateway/gateway.go | 2 + 3 files changed, 409 insertions(+) create mode 100644 internal/cmd/beta/vpn/gateway/create/create.go create mode 100644 internal/cmd/beta/vpn/gateway/create/create_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..a8e02313c --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/create/create.go @@ -0,0 +1,200 @@ +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"`, + "$ stackit beta vpn gateway create --name xxx", + ), + ), + 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, vpn.Region(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, "", "Routing Type: \"POLICY_BASED\", \"ROUTE_BASED\" or \"BGP_ROUTE_BASED\"") + + 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) + + 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, vpn.Region(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..b82abb4a0 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/create/create_test.go @@ -0,0 +1,207 @@ +package create + +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 testName = "test-name" +var testPlanId = "planId" +var testRoutingType = vpn.ROUTINGTYPE_POLICY_BASED +var testAvailabilityZoneTunnel1 = "eu01-1" +var testAvailabilityZoneTunnel2 = "eu01-2" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + availabilityZoneTunnel1Flag: testAvailabilityZoneTunnel1, + availabilityZoneTunnel2Flag: testAvailabilityZoneTunnel2, + 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: nil, + 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, vpn.Region(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: nil, + 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) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + }, + { + 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/gateway.go b/internal/cmd/beta/vpn/gateway/gateway.go index 511a500d7..e7e48a6b2 100644 --- a/internal/cmd/beta/vpn/gateway/gateway.go +++ b/internal/cmd/beta/vpn/gateway/gateway.go @@ -1,6 +1,7 @@ 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" @@ -26,5 +27,6 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(list.NewCmd(params)) cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) cmd.AddCommand(delete.NewCmd(params)) } From 458d05185f3dcd47806978a66a39e43dad93f675 Mon Sep 17 00:00:00 2001 From: Manuel Vaas Date: Wed, 20 May 2026 19:50:33 +0200 Subject: [PATCH 7/7] add update command --- internal/cmd/beta/vpn/gateway/gateway.go | 6 +- .../cmd/beta/vpn/gateway/update/update.go | 208 +++++++++++++ .../beta/vpn/gateway/update/update_test.go | 275 ++++++++++++++++++ 3 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/beta/vpn/gateway/update/update.go create mode 100644 internal/cmd/beta/vpn/gateway/update/update_test.go diff --git a/internal/cmd/beta/vpn/gateway/gateway.go b/internal/cmd/beta/vpn/gateway/gateway.go index e7e48a6b2..b6c1b582d 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/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -25,8 +26,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { } func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { - cmd.AddCommand(list.NewCmd(params)) - cmd.AddCommand(describe.NewCmd(params)) cmd.AddCommand(create.NewCmd(params)) cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/beta/vpn/gateway/update/update.go b/internal/cmd/beta/vpn/gateway/update/update.go new file mode 100644 index 000000000..73645dc9d --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/update/update.go @@ -0,0 +1,208 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" + 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" + + 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 + GatewayId string + AvailabilityZone vpn.UpdateGatewayPayloadAvailabilityZones + 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: fmt.Sprintf("update %s", gatewayIdArg), + Short: "Updates a vpn gateway", + Long: "Updates a vpn gateway.", + Args: args.SingleArg(gatewayIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update vpn gateway with ID "xxx"`, + "$ stackit beta vpn gateway update 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 + } + + 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 update vpn gateway %q for the project %q?", gatewayLabel, 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("update vpn gateway: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Updating gateway", func() error { + _, err = wait.UpdateGatewayWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, vpn.Region(model.Region), model.GatewayId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("waiting for gateway update: %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, "", "Routing Type: \"POLICY_BASED\", \"ROUTE_BASED\" or \"BGP_ROUTE_BASED\"") + + err := flags.MarkFlagsRequired(cmd, availabilityZoneTunnel1Flag, availabilityZoneTunnel2Flag, nameFlag, planIdFlag, routingTypeFlag) + cobra.CheckErr(err) +} + +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{} + } + + 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) + + var bgp *vpn.BGPGatewayConfig + if bgpLocalAsn != nil || bgpOverrideAdvertisedRoutes != nil { + bgp = &vpn.BGPGatewayConfig{ + LocalAsn: bgpLocalAsn, + OverrideAdvertisedRoutes: flags.FlagToStringArrayValue(p, cmd, bgpOverrideAdvertisedRoutesFlag), + } + } + + model := inputModel{ + GatewayId: gatewayId, + GlobalFlagModel: globalFlags, + AvailabilityZone: vpn.UpdateGatewayPayloadAvailabilityZones{ + 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.ApiUpdateGatewayRequest { + req := apiClient.DefaultAPI.UpdateGateway(ctx, model.ProjectId, vpn.Region(model.Region), model.GatewayId) + req = req.UpdateGatewayPayload(vpn.UpdateGatewayPayload{ + 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 := "Updated" + if async { + operation = "Triggered update of" + } + p.Outputf( + "%s vpn gateway %q in project %q.\n", + operation, + item.DisplayName, + projectLabel, + ) + return nil + }) +} diff --git a/internal/cmd/beta/vpn/gateway/update/update_test.go b/internal/cmd/beta/vpn/gateway/update/update_test.go new file mode 100644 index 000000000..09b5f1325 --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/update/update_test.go @@ -0,0 +1,275 @@ +package update + +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() +var testName = "test-name" +var testPlanId = "planId" +var testRoutingType = vpn.ROUTINGTYPE_POLICY_BASED +var testAvailabilityZoneTunnel1 = "eu01-1" +var testAvailabilityZoneTunnel2 = "eu01-2" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + availabilityZoneTunnel1Flag: testAvailabilityZoneTunnel1, + availabilityZoneTunnel2Flag: testAvailabilityZoneTunnel2, + nameFlag: testName, + labelsFlag: "foo=bar", + planIdFlag: testPlanId, + routingTypeFlag: string(testRoutingType), + } + 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, + Name: testName, + AvailabilityZone: vpn.UpdateGatewayPayloadAvailabilityZones{ + Tunnel1: testAvailabilityZoneTunnel1, + Tunnel2: testAvailabilityZoneTunnel2, + }, + Bgp: nil, + Labels: &map[string]string{ + "foo": "bar", + }, + PlanId: testPlanId, + RoutingType: testRoutingType, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiUpdateGatewayRequest)) vpn.ApiUpdateGatewayRequest { + request := testClient.DefaultAPI.UpdateGateway(testCtx, testProjectId, vpn.Region(testRegion), testGatewayId) + request = request.UpdateGatewayPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *vpn.UpdateGatewayPayload)) vpn.UpdateGatewayPayload { + payload := vpn.UpdateGatewayPayload{ + AvailabilityZones: vpn.UpdateGatewayPayloadAvailabilityZones{ + Tunnel1: testAvailabilityZoneTunnel1, + Tunnel2: testAvailabilityZoneTunnel2, + }, + Bgp: nil, + 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", + 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: "only required flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + }, + { + 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, + }, + { + description: "missing required nameFlag", + argValues: fixtureArgValues(), + 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.ApiUpdateGatewayRequest + }{ + { + 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{}, vpn.NullableString{}, vpn.NullableInt32{}), + cmpopts.EquateComparable(testCtx), + ) + 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) + } + }) + } +}