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/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/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/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/describe/describe.go b/internal/cmd/beta/vpn/gateway/describe/describe.go new file mode 100644 index 000000000..360602064 --- /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, 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("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) + } + 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 new file mode 100644 index 000000000..b6c1b582d --- /dev/null +++ b/internal/cmd/beta/vpn/gateway/gateway.go @@ -0,0 +1,34 @@ +package gateway + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/gateway/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" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "gateway", + Short: "Provides functionality for VPN gateway", + Long: "Provides functionality for VPN gateway.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.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..b0571f09d --- /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( + utils.PtrString(gateway.Id), + gateway.DisplayName, + 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..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/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) + } + }) + } +} 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)) +} 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) + } + }) + } +}