From b3907e9daf1d7bd1d284bde59c7a7dc8bd6715cf Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 9 Jun 2026 14:27:07 +0200 Subject: [PATCH 1/2] feat(vpn): basic connection commands - implement create, describe, list, delete for vpn connection - add helpers for string based enum flags - make JoinStringPtr generic to accept string based enum slices --- go.mod | 1 + go.sum | 2 + internal/cmd/beta/beta.go | 2 + .../cmd/beta/vpn/connection/connection.go | 31 ++ .../cmd/beta/vpn/connection/create/create.go | 426 ++++++++++++++++++ .../beta/vpn/connection/create/create_test.go | 321 +++++++++++++ .../cmd/beta/vpn/connection/delete/delete.go | 118 +++++ .../beta/vpn/connection/delete/delete_test.go | 155 +++++++ .../beta/vpn/connection/describe/describe.go | 169 +++++++ .../vpn/connection/describe/describe_test.go | 242 ++++++++++ internal/cmd/beta/vpn/connection/list/list.go | 126 ++++++ .../cmd/beta/vpn/connection/list/list_test.go | 206 +++++++++ internal/cmd/beta/vpn/vpn.go | 24 + internal/pkg/config/config.go | 1 + internal/pkg/flags/string_enum.go | 117 +++++ internal/pkg/flags/string_enum_test.go | 149 ++++++ internal/pkg/flags/string_enumslice.go | 126 ++++++ internal/pkg/flags/string_enumslice_test.go | 161 +++++++ internal/pkg/services/vpn/client/client.go | 13 + internal/pkg/utils/strings.go | 17 +- 20 files changed, 2405 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/beta/vpn/connection/connection.go create mode 100644 internal/cmd/beta/vpn/connection/create/create.go create mode 100644 internal/cmd/beta/vpn/connection/create/create_test.go create mode 100644 internal/cmd/beta/vpn/connection/delete/delete.go create mode 100644 internal/cmd/beta/vpn/connection/delete/delete_test.go create mode 100644 internal/cmd/beta/vpn/connection/describe/describe.go create mode 100644 internal/cmd/beta/vpn/connection/describe/describe_test.go create mode 100644 internal/cmd/beta/vpn/connection/list/list.go create mode 100644 internal/cmd/beta/vpn/connection/list/list_test.go create mode 100644 internal/cmd/beta/vpn/vpn.go create mode 100644 internal/pkg/flags/string_enum.go create mode 100644 internal/pkg/flags/string_enum_test.go create mode 100644 internal/pkg/flags/string_enumslice.go create mode 100644 internal/pkg/flags/string_enumslice_test.go create mode 100644 internal/pkg/services/vpn/client/client.go diff --git a/go.mod b/go.mod index 0c86b177a..139ce6030 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.14.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 bfce03556..3e1d05f4a 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.14.0 h1:LMgbzhPunuelsIsfyEj/5O/aYfNcg/eGHsnZ7AZOhYg= +github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.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/connection/connection.go b/internal/cmd/beta/vpn/connection/connection.go new file mode 100644 index 000000000..ff2cf32d8 --- /dev/null +++ b/internal/cmd/beta/vpn/connection/connection.go @@ -0,0 +1,31 @@ +package connection + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection/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" +) + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "connection", + Short: "Provides functionality for VPN connections", + Long: "Provides functionality for VPN connections.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *types.CmdParams) { + cmd.AddCommand(create.NewCmd(p)) + cmd.AddCommand(delete.NewCmd(p)) + cmd.AddCommand(describe.NewCmd(p)) + cmd.AddCommand(list.NewCmd(p)) +} diff --git a/internal/cmd/beta/vpn/connection/create/create.go b/internal/cmd/beta/vpn/connection/create/create.go new file mode 100644 index 000000000..540758262 --- /dev/null +++ b/internal/cmd/beta/vpn/connection/create/create.go @@ -0,0 +1,426 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" + "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 ( + gatewayIdFlag = "gateway-id" + + displayNameFlag = "display-name" + enabledFlag = "enabled" + labelsFlag = "labels" + localSubnetsFlag = "local-subnets" + remoteSubnetsFlag = "remote-subnets" + staticRoutesFlag = "static-routes" + + tunnel1BgpRemoteAsnFlag = "tunnel1-bgp-remote-asn" + tunnel1PeeringLocalAddressFlag = "tunnel1-peering-local-address" + tunnel1PeeringRemoteAddressFlag = "tunnel1-peering-remote-address" + tunnel1Phase1RekeyTimeFlag = "tunnel1-phase1-rekey-time" + tunnel1Phase2RekeyTimeFlag = "tunnel1-phase2-rekey-time" + tunnel1PreSharedKeyFlag = "tunnel1-pre-shared-key" + tunnel1RemoteAddressFlag = "tunnel1-remote-address" + + tunnel2BgpRemoteAsnFlag = "tunnel2-bgp-remote-asn" + tunnel2PeeringLocalAddressFlag = "tunnel2-peering-local-address" + tunnel2PeeringRemoteAddressFlag = "tunnel2-peering-remote-address" + tunnel2Phase1RekeyTimeFlag = "tunnel2-phase1-rekey-time" + tunnel2Phase2RekeyTimeFlag = "tunnel2-phase2-rekey-time" + tunnel2PreSharedKeyFlag = "tunnel2-pre-shared-key" + tunnel2RemoteAddressFlag = "tunnel2-remote-address" +) + +var ( + // tunnel 1 + tunnel1Phase1DhGroupsFlag = flags.StringEnumSliceFlag( + "tunnel1-phase1-dh-groups", + vpn.AllowedPhaseDhGroupsInnerEnumValues, + "Tunnel 1 Phase 1 DH Groups.\nThe Diffie-Hellman Group. Required, except if AEAD algorithms are selected.", + ) + tunnel1Phase1EncryptionAlgorithmsFlag = flags.StringEnumSliceFlag( + "tunnel1-phase1-encryption-algorithms", + vpn.AllowedPhaseEncryptionAlgorithmsInnerEnumValues, + "Required: Tunnel 1 Phase 1 Encryption Algorithms", + ) + tunnel1Phase1IntegrityAlgorithmsFlag = flags.StringEnumSliceFlag( + "tunnel1-phase1-integrity-algorithms", + vpn.AllowedPhaseIntegrityAlgorithmsInnerEnumValues, + "Required: Tunnel 1 Phase 1 Integrity Algorithms", + ) + tunnel1Phase2DhGroupsFlag = flags.StringEnumSliceFlag( + "tunnel1-phase2-dh-groups", + vpn.AllowedPhaseDhGroupsInnerEnumValues, + "Tunnel 1 Phase 2 DH Groups", + ) + tunnel1Phase2EncryptionAlgorithmsFlag = flags.StringEnumSliceFlag( + "tunnel1-phase2-encryption-algorithms", + vpn.AllowedPhaseEncryptionAlgorithmsInnerEnumValues, + "Required: Tunnel 1 Phase 2 Encryption Algorithms", + ) + tunnel1Phase2IntegrityAlgorithmsFlag = flags.StringEnumSliceFlag( + "tunnel1-phase2-integrity-algorithms", + vpn.AllowedPhaseIntegrityAlgorithmsInnerEnumValues, + "Required: Tunnel 1 Phase 2 Integrity Algorithms", + ) + tunnel1Phase2DpdActionFlag = flags.StringEnumFlag( + "tunnel1-phase2-dpd-action", + vpn.AllowedTunnelConfigurationPhase2AllOfDpdActionEnumValues, + "Tunnel 1 Phase 2 DPD Action.\nAction to perform for this CHILD_SA on DPD timeout. \"clear\": Closes the CHILD_SA and does not take further action. \"restart\": immediately tries to re-negotiate the CILD_SA under a fresh IKE_SA.", + ) + tunnel1Phase2StartActionFlag = flags.StringEnumFlag( + "tunnel1-phase2-start-action", + vpn.AllowedTunnelConfigurationPhase2AllOfStartActionEnumValues, + "Tunnel 1 Phase 2 Start Action.\nAction to perform after loading the connection configuration. \"none\": The connection will be loaded but needs to be manually initiated. \"start\": initiates the connection actively.", + ) + // tunnel 2 + tunnel2Phase1DhGroupsFlag = flags.StringEnumSliceFlag( + "tunnel2-phase1-dh-groups", + vpn.AllowedPhaseDhGroupsInnerEnumValues, + "Tunnel 2 Phase 1 DH Groups\nThe Diffie-Hellman Group. Required, except if AEAD algorithms are selected.", + ) + tunnel2Phase1EncryptionAlgorithmsFlag = flags.StringEnumSliceFlag( + "tunnel2-phase1-encryption-algorithms", + vpn.AllowedPhaseEncryptionAlgorithmsInnerEnumValues, + "Required: Tunnel 2 Phase 1 Encryption Algorithms", + ) + tunnel2Phase1IntegrityAlgorithmsFlag = flags.StringEnumSliceFlag( + "tunnel2-phase1-integrity-algorithms", + vpn.AllowedPhaseIntegrityAlgorithmsInnerEnumValues, + "Required: Tunnel 2 Phase 1 Integrity Algorithms", + ) + tunnel2Phase2DhGroupsFlag = flags.StringEnumSliceFlag( + "tunnel2-phase2-dh-groups", + vpn.AllowedPhaseDhGroupsInnerEnumValues, + "Tunnel 2 Phase 2 DH Groups", + ) + tunnel2Phase2EncryptionAlgorithmsFlag = flags.StringEnumSliceFlag( + "tunnel2-phase2-encryption-algorithms", + vpn.AllowedPhaseEncryptionAlgorithmsInnerEnumValues, + "Required: Tunnel 2 Phase 2 Encryption Algorithms", + ) + tunnel2Phase2IntegrityAlgorithmsFlag = flags.StringEnumSliceFlag( + "tunnel2-phase2-integrity-algorithms", + vpn.AllowedPhaseIntegrityAlgorithmsInnerEnumValues, + "Required: Tunnel 2 Phase 2 Integrity Algorithms", + ) + tunnel2Phase2DpdActionFlag = flags.StringEnumFlag( + "tunnel2-phase2-dpd-action", + vpn.AllowedTunnelConfigurationPhase2AllOfDpdActionEnumValues, + "Tunnel 2 Phase 2 DPD Action.\nAction to perform for this CHILD_SA on DPD timeout. \"clear\": Closes the CHILD_SA and does not take further action. \"restart\": immediately tries to re-negotiate the CILD_SA under a fresh IKE_SA.", + ) + tunnel2Phase2StartActionFlag = flags.StringEnumFlag( + "tunnel2-phase2-start-action", + vpn.AllowedTunnelConfigurationPhase2AllOfStartActionEnumValues, + "Tunnel 2 Phase 2 Start Action.\nDefault: \"start\"\nEnum: \"none\" \"start\"\nAction to perform after loading the connection configuration. \"none\": The connection will be loaded but needs to be manually initiated. \"start\": initiates the connection actively.", + ) +) + +type inputModel struct { + *globalflags.GlobalFlagModel + GatewayId string + + DisplayName string + Enabled *bool + Labels *map[string]string + LocalSubnets []string + RemoteSubnets []string + StaticRoutes []string + + Tunnel1BgpRemoteAsn *int64 + Tunnel1PeeringLocalAddress *string + Tunnel1PeeringRemoteAddress *string + Tunnel1Phase1DhGroups []vpn.PhaseDhGroupsInner + Tunnel1Phase1EncryptionAlgorithms []vpn.PhaseEncryptionAlgorithmsInner + Tunnel1Phase1IntegrityAlgorithms []vpn.PhaseIntegrityAlgorithmsInner + Tunnel1Phase1RekeyTime *int32 + Tunnel1Phase2DhGroups []vpn.PhaseDhGroupsInner + Tunnel1Phase2EncryptionAlgorithms []vpn.PhaseEncryptionAlgorithmsInner + Tunnel1Phase2IntegrityAlgorithms []vpn.PhaseIntegrityAlgorithmsInner + Tunnel1Phase2RekeyTime *int32 + Tunnel1Phase2DpdAction *vpn.TunnelConfigurationPhase2AllOfDpdAction + Tunnel1Phase2StartAction *vpn.TunnelConfigurationPhase2AllOfStartAction + Tunnel1PreSharedKey string + Tunnel1RemoteAddress string + + Tunnel2BgpRemoteAsn *int64 + Tunnel2PeeringLocalAddress *string + Tunnel2PeeringRemoteAddress *string + Tunnel2Phase1DhGroups []vpn.PhaseDhGroupsInner + Tunnel2Phase1EncryptionAlgorithms []vpn.PhaseEncryptionAlgorithmsInner + Tunnel2Phase1IntegrityAlgorithms []vpn.PhaseIntegrityAlgorithmsInner + Tunnel2Phase1RekeyTime *int32 + Tunnel2Phase2DhGroups []vpn.PhaseDhGroupsInner + Tunnel2Phase2EncryptionAlgorithms []vpn.PhaseEncryptionAlgorithmsInner + Tunnel2Phase2IntegrityAlgorithms []vpn.PhaseIntegrityAlgorithmsInner + Tunnel2Phase2RekeyTime *int32 + Tunnel2Phase2DpdAction *vpn.TunnelConfigurationPhase2AllOfDpdAction + Tunnel2Phase2StartAction *vpn.TunnelConfigurationPhase2AllOfStartAction + Tunnel2PreSharedKey string + Tunnel2RemoteAddress string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a VPN connection", + Long: "Creates a VPN connection.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a VPN connection`, + "$ stackit beta vpn connection create --gateway-id xxx --display-name my-connection --tunnel1-remote-address 1.2.3.4 --tunnel2-remote-address 5.6.7.8"), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a VPN connection for gateway %q?", model.GatewayId) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create VPN connection: %w", err) + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), gatewayIdFlag, "Required: Gateway ID") + cmd.Flags().String(displayNameFlag, "", "Required: A user friendly name for the connection.") + cmd.Flags().Bool(enabledFlag, true, "Enable the connection") + cmd.Flags().StringToString(labelsFlag, nil, "Map of custom labels. Key and values must be a string with max 63 chars, start/end with alphanumeric. The key of a label follows the same rules as the LabelValue except that it cannot be empty. (example: foo=bar)") + cmd.Flags().StringSlice(localSubnetsFlag, nil, "Defaults to 0.0.0.0/0 for Route-based VPN configurations. Mandatory for Policy-based.") + cmd.Flags().StringSlice(remoteSubnetsFlag, nil, "Defaults to 0.0.0.0/0 for Route-based VPN configurations. Mandatory for Policy-based.") + cmd.Flags().StringSlice(staticRoutesFlag, nil, "Use this for route-based VPN.") + + cmd.Flags().Int64(tunnel1BgpRemoteAsnFlag, 0, "Required: Tunnel 1 BGP Remote ASN.\nASN for private use (reserved by IANA), both 16Bit and 32Bit ranges are valid (RFC 6996).") + cmd.Flags().String(tunnel1PeeringLocalAddressFlag, "", "Tunnel 1 Peering Local Address.\nThe peering object defines the point-to-point IP configuration for the Tunnel Interface. These addresses serve as next-hop identifiers and are used for BGP peering sessions and can be used in Static Route-Based connectivity.") + cmd.Flags().String(tunnel1PeeringRemoteAddressFlag, "", "Tunnel 1 Peering Remote Address") + tunnel1Phase1DhGroupsFlag.Register(cmd) + tunnel1Phase1EncryptionAlgorithmsFlag.Register(cmd) + tunnel1Phase1IntegrityAlgorithmsFlag.Register(cmd) + cmd.Flags().Int64(tunnel1Phase1RekeyTimeFlag, 0, "Tunnel 1 Phase 1 Rekey Time.\nTime to schedule a IKE re-keying (in seconds).") + tunnel1Phase2DhGroupsFlag.Register(cmd) + tunnel1Phase2EncryptionAlgorithmsFlag.Register(cmd) + tunnel1Phase2IntegrityAlgorithmsFlag.Register(cmd) + cmd.Flags().Int64(tunnel1Phase2RekeyTimeFlag, 0, "Tunnel 1 Phase 2 Rekey Time.\nTime to schedule a Child SA re-keying (in seconds).") + tunnel1Phase2DpdActionFlag.Register(cmd) + tunnel1Phase2StartActionFlag.Register(cmd) + cmd.Flags().String(tunnel1PreSharedKeyFlag, "", "Required: Tunnel 1 Pre Shared Key.\nA Pre-Shared Key for authentication. Required in create-requests, optional in update-requests and omitted in every response.") + cmd.Flags().String(tunnel1RemoteAddressFlag, "", "Tunnel 1 Remote Address") + + cmd.Flags().Int64(tunnel2BgpRemoteAsnFlag, 0, "Tunnel 2 BGP Remote ASN") + cmd.Flags().String(tunnel2PeeringLocalAddressFlag, "", "Tunnel 2 Peering Local Address.\nThe peering object defines the point-to-point IP configuration for the Tunnel Interface. These addresses serve as next-hop identifiers and are used for BGP peering sessions and can be used in Static Route-Based connectivity.") + cmd.Flags().String(tunnel2PeeringRemoteAddressFlag, "", "Tunnel 2 Peering Remote Address") + tunnel2Phase1DhGroupsFlag.Register(cmd) + tunnel2Phase1EncryptionAlgorithmsFlag.Register(cmd) + tunnel2Phase1IntegrityAlgorithmsFlag.Register(cmd) + cmd.Flags().Int64(tunnel2Phase1RekeyTimeFlag, 0, "Tunnel 2 Phase 1 Rekey Time.\nTime to schedule a IKE re-keying (in seconds).") + tunnel2Phase2DhGroupsFlag.Register(cmd) + tunnel2Phase2EncryptionAlgorithmsFlag.Register(cmd) + tunnel2Phase2IntegrityAlgorithmsFlag.Register(cmd) + cmd.Flags().Int64(tunnel2Phase2RekeyTimeFlag, 0, "Tunnel 2 Phase 2 Rekey Time.\nTime to schedule a Child SA re-keying (in seconds).") + tunnel2Phase2DpdActionFlag.Register(cmd) + tunnel2Phase2StartActionFlag.Register(cmd) + cmd.Flags().String(tunnel2PreSharedKeyFlag, "", "Required: Tunnel 2 Pre Shared Key.\nA Pre-Shared Key for authentication. Required in create-requests, optional in update-requests and omitted in every response.") + cmd.Flags().String(tunnel2RemoteAddressFlag, "", "Tunnel 2 Remote Address") + + err := flags.MarkFlagsRequired( + cmd, + gatewayIdFlag, displayNameFlag, + tunnel1RemoteAddressFlag, + tunnel1PreSharedKeyFlag, + tunnel1Phase1EncryptionAlgorithmsFlag.Name(), tunnel1Phase1IntegrityAlgorithmsFlag.Name(), + tunnel1Phase2EncryptionAlgorithmsFlag.Name(), tunnel1Phase2IntegrityAlgorithmsFlag.Name(), + tunnel2RemoteAddressFlag, + tunnel2PreSharedKeyFlag, + tunnel2Phase1EncryptionAlgorithmsFlag.Name(), tunnel2Phase1IntegrityAlgorithmsFlag.Name(), + tunnel2Phase2EncryptionAlgorithmsFlag.Name(), tunnel2Phase2IntegrityAlgorithmsFlag.Name(), + ) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + GatewayId: flags.FlagToStringValue(p, cmd, gatewayIdFlag), + + DisplayName: flags.FlagToStringValue(p, cmd, displayNameFlag), + Enabled: flags.FlagToBoolPointer(p, cmd, enabledFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + LocalSubnets: flags.FlagToStringSliceValue(p, cmd, localSubnetsFlag), + RemoteSubnets: flags.FlagToStringSliceValue(p, cmd, remoteSubnetsFlag), + StaticRoutes: flags.FlagToStringSliceValue(p, cmd, staticRoutesFlag), + + Tunnel1BgpRemoteAsn: flags.FlagToInt64Pointer(p, cmd, tunnel1BgpRemoteAsnFlag), + Tunnel1PeeringLocalAddress: flags.FlagToStringPointer(p, cmd, tunnel1PeeringLocalAddressFlag), + Tunnel1PeeringRemoteAddress: flags.FlagToStringPointer(p, cmd, tunnel1PeeringRemoteAddressFlag), + Tunnel1Phase1DhGroups: tunnel1Phase1DhGroupsFlag.Get(), + Tunnel1Phase1EncryptionAlgorithms: tunnel1Phase1EncryptionAlgorithmsFlag.Get(), + Tunnel1Phase1IntegrityAlgorithms: tunnel1Phase1IntegrityAlgorithmsFlag.Get(), + Tunnel1Phase1RekeyTime: flags.FlagToInt32Pointer(p, cmd, tunnel1Phase1RekeyTimeFlag), + Tunnel1Phase2DhGroups: tunnel1Phase2DhGroupsFlag.Get(), + Tunnel1Phase2EncryptionAlgorithms: tunnel1Phase2EncryptionAlgorithmsFlag.Get(), + Tunnel1Phase2IntegrityAlgorithms: tunnel1Phase2IntegrityAlgorithmsFlag.Get(), + Tunnel1Phase2RekeyTime: flags.FlagToInt32Pointer(p, cmd, tunnel1Phase2RekeyTimeFlag), + Tunnel1Phase2DpdAction: tunnel1Phase2DpdActionFlag.Ptr(), + Tunnel1Phase2StartAction: tunnel1Phase2StartActionFlag.Ptr(), + Tunnel1PreSharedKey: flags.FlagToStringValue(p, cmd, tunnel1PreSharedKeyFlag), + Tunnel1RemoteAddress: flags.FlagToStringValue(p, cmd, tunnel1RemoteAddressFlag), + + Tunnel2BgpRemoteAsn: flags.FlagToInt64Pointer(p, cmd, tunnel2BgpRemoteAsnFlag), + Tunnel2PeeringLocalAddress: flags.FlagToStringPointer(p, cmd, tunnel2PeeringLocalAddressFlag), + Tunnel2PeeringRemoteAddress: flags.FlagToStringPointer(p, cmd, tunnel2PeeringRemoteAddressFlag), + Tunnel2Phase1DhGroups: tunnel2Phase1DhGroupsFlag.Get(), + Tunnel2Phase1EncryptionAlgorithms: tunnel2Phase1EncryptionAlgorithmsFlag.Get(), + Tunnel2Phase1IntegrityAlgorithms: tunnel2Phase1IntegrityAlgorithmsFlag.Get(), + Tunnel2Phase1RekeyTime: flags.FlagToInt32Pointer(p, cmd, tunnel2Phase1RekeyTimeFlag), + Tunnel2Phase2DhGroups: tunnel2Phase2DhGroupsFlag.Get(), + Tunnel2Phase2EncryptionAlgorithms: tunnel2Phase2EncryptionAlgorithmsFlag.Get(), + Tunnel2Phase2IntegrityAlgorithms: tunnel2Phase2IntegrityAlgorithmsFlag.Get(), + Tunnel2Phase2RekeyTime: flags.FlagToInt32Pointer(p, cmd, tunnel2Phase2RekeyTimeFlag), + Tunnel2Phase2DpdAction: tunnel2Phase2DpdActionFlag.Ptr(), + Tunnel2Phase2StartAction: tunnel2Phase2StartActionFlag.Ptr(), + Tunnel2PreSharedKey: flags.FlagToStringValue(p, cmd, tunnel2PreSharedKeyFlag), + Tunnel2RemoteAddress: flags.FlagToStringValue(p, cmd, tunnel2RemoteAddressFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) (vpn.ApiCreateGatewayConnectionRequest, error) { + req := apiClient.DefaultAPI.CreateGatewayConnection(ctx, model.ProjectId, model.Region, model.GatewayId) + + payload := vpn.CreateGatewayConnectionPayload{ + DisplayName: model.DisplayName, + Enabled: model.Enabled, + Labels: model.Labels, + LocalSubnets: model.LocalSubnets, + RemoteSubnets: model.RemoteSubnets, + StaticRoutes: model.StaticRoutes, + } + + tunnel1 := vpn.TunnelConfiguration{ + RemoteAddress: model.Tunnel1RemoteAddress, + } + if model.Tunnel1BgpRemoteAsn != nil { + tunnel1.Bgp = &vpn.BGPTunnelConfig{ + RemoteAsn: *model.Tunnel1BgpRemoteAsn, + } + } + if model.Tunnel1PeeringLocalAddress != nil || model.Tunnel1PeeringRemoteAddress != nil { + tunnel1.Peering = &vpn.PeeringConfig{ + LocalAddress: model.Tunnel1PeeringLocalAddress, + RemoteAddress: model.Tunnel1PeeringRemoteAddress, + } + } + tunnel1.Phase1 = vpn.TunnelConfigurationPhase1{ + DhGroups: model.Tunnel1Phase1DhGroups, + EncryptionAlgorithms: model.Tunnel1Phase1EncryptionAlgorithms, + IntegrityAlgorithms: model.Tunnel1Phase1IntegrityAlgorithms, + RekeyTime: model.Tunnel1Phase1RekeyTime, + } + tunnel1.Phase2 = vpn.TunnelConfigurationPhase2{ + DhGroups: model.Tunnel1Phase2DhGroups, + EncryptionAlgorithms: model.Tunnel1Phase2EncryptionAlgorithms, + IntegrityAlgorithms: model.Tunnel1Phase2IntegrityAlgorithms, + RekeyTime: model.Tunnel1Phase2RekeyTime, + DpdAction: model.Tunnel1Phase2DpdAction, + StartAction: model.Tunnel1Phase2StartAction, + } + tunnel1.PreSharedKey = &model.Tunnel1PreSharedKey + payload.Tunnel1 = tunnel1 + + tunnel2 := vpn.TunnelConfiguration{ + RemoteAddress: model.Tunnel2RemoteAddress, + } + if model.Tunnel2BgpRemoteAsn != nil { + tunnel2.Bgp = &vpn.BGPTunnelConfig{ + RemoteAsn: *model.Tunnel2BgpRemoteAsn, + } + } + if model.Tunnel2PeeringLocalAddress != nil || model.Tunnel2PeeringRemoteAddress != nil { + tunnel2.Peering = &vpn.PeeringConfig{ + LocalAddress: model.Tunnel2PeeringLocalAddress, + RemoteAddress: model.Tunnel2PeeringRemoteAddress, + } + } + tunnel2.Phase1 = vpn.TunnelConfigurationPhase1{ + DhGroups: model.Tunnel2Phase1DhGroups, + EncryptionAlgorithms: model.Tunnel2Phase1EncryptionAlgorithms, + IntegrityAlgorithms: model.Tunnel2Phase1IntegrityAlgorithms, + RekeyTime: model.Tunnel2Phase1RekeyTime, + } + + tunnel2.Phase2 = vpn.TunnelConfigurationPhase2{ + DhGroups: model.Tunnel2Phase2DhGroups, + EncryptionAlgorithms: model.Tunnel2Phase2EncryptionAlgorithms, + IntegrityAlgorithms: model.Tunnel2Phase2IntegrityAlgorithms, + RekeyTime: model.Tunnel2Phase2RekeyTime, + DpdAction: model.Tunnel2Phase2DpdAction, + StartAction: model.Tunnel2Phase2StartAction, + } + tunnel2.PreSharedKey = &model.Tunnel2PreSharedKey + payload.Tunnel2 = tunnel2 + + return req.CreateGatewayConnectionPayload(payload), nil +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *vpn.ConnectionResponse) error { + if resp == nil { + return fmt.Errorf("create response is empty") + } + return p.OutputResult(model.OutputFormat, resp, func() error { + p.Outputf("Created VPN connection %q for gateway %q in project %q.\n", utils.PtrString(resp.Id), model.GatewayId, projectLabel) + return nil + }) +} diff --git a/internal/cmd/beta/vpn/connection/create/create_test.go b/internal/cmd/beta/vpn/connection/create/create_test.go new file mode 100644 index 000000000..485eb5c63 --- /dev/null +++ b/internal/cmd/beta/vpn/connection/create/create_test.go @@ -0,0 +1,321 @@ +package create + +import ( + "context" + "fmt" + "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/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testGatewayID = uuid.NewString() + testClient, _ = vpn.NewAPIClient( + sdkConfig.WithoutAuthentication(), + ) +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + gatewayIdFlag: testGatewayID, + displayNameFlag: "test-connection", + tunnel1RemoteAddressFlag: "1.2.3.4", + tunnel1PreSharedKeyFlag: "test-psk-1", + tunnel1Phase1EncryptionAlgorithmsFlag.Name(): "aes256", + tunnel1Phase1IntegrityAlgorithmsFlag.Name(): "sha2_256", + tunnel1Phase2EncryptionAlgorithmsFlag.Name(): "aes256", + tunnel1Phase2IntegrityAlgorithmsFlag.Name(): "sha2_256", + tunnel2RemoteAddressFlag: "5.6.7.8", + tunnel2PreSharedKeyFlag: "test-psk-2", + tunnel2Phase1EncryptionAlgorithmsFlag.Name(): "aes256", + tunnel2Phase1IntegrityAlgorithmsFlag.Name(): "sha2_256", + tunnel2Phase2EncryptionAlgorithmsFlag.Name(): "aes256", + tunnel2Phase2IntegrityAlgorithmsFlag.Name(): "sha2_256", + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + GatewayId: testGatewayID, + DisplayName: "test-connection", + Enabled: nil, + Tunnel1RemoteAddress: "1.2.3.4", + Tunnel1PreSharedKey: "test-psk-1", + Tunnel1Phase1EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + Tunnel1Phase1IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + Tunnel1Phase2EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + Tunnel1Phase2IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + Tunnel2RemoteAddress: "5.6.7.8", + Tunnel2PreSharedKey: "test-psk-2", + Tunnel2Phase1EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + Tunnel2Phase1IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + Tunnel2Phase2EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + Tunnel2Phase2IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiCreateGatewayConnectionRequest)) vpn.ApiCreateGatewayConnectionRequest { + request := testClient.DefaultAPI.CreateGatewayConnection(testCtx, testProjectId, "", testGatewayID) + payload := vpn.CreateGatewayConnectionPayload{ + DisplayName: "test-connection", + Enabled: nil, + Tunnel1: vpn.TunnelConfiguration{ + RemoteAddress: "1.2.3.4", + PreSharedKey: utils.Ptr("test-psk-1"), + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + RemoteAddress: "5.6.7.8", + PreSharedKey: utils.Ptr("test-psk-2"), + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + }, + }, + } + request = request.CreateGatewayConnectionPayload(payload) + 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: []string{}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flags", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "missing project id", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "missing gateway id", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, gatewayIdFlag) + }), + isValid: false, + }, + { + description: "missing display name", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "missing tunnel1 remote address", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, tunnel1RemoteAddressFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(printer *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(printer, cmd) + }, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedResult vpn.ApiCreateGatewayConnectionRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedResult: fixtureRequest(), + }, + { + description: "with optional fields", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = &map[string]string{"env": "prod"} + model.LocalSubnets = []string{"10.0.0.0/24"} + model.RemoteSubnets = []string{"192.168.0.0/24"} + model.StaticRoutes = []string{"10.1.0.0/24"} + model.Tunnel1BgpRemoteAsn = utils.Ptr(int64(65000)) + model.Tunnel1PeeringLocalAddress = utils.Ptr("169.254.0.1") + model.Tunnel1PeeringRemoteAddress = utils.Ptr("169.254.0.2") + model.Tunnel1Phase1DhGroups = []vpn.PhaseDhGroupsInner{"14"} + model.Tunnel1Phase1RekeyTime = utils.Ptr(int32(3600)) + model.Tunnel1Phase2DhGroups = []vpn.PhaseDhGroupsInner{"14"} + model.Tunnel1Phase2RekeyTime = utils.Ptr(int32(3600)) + model.Tunnel1Phase2DpdAction = utils.Ptr(vpn.TunnelConfigurationPhase2AllOfDpdAction("restart")) + model.Tunnel1Phase2StartAction = utils.Ptr(vpn.TunnelConfigurationPhase2AllOfStartAction("start")) + }), + expectedResult: fixtureRequest(func(request *vpn.ApiCreateGatewayConnectionRequest) { + payload := vpn.CreateGatewayConnectionPayload{ + DisplayName: "test-connection", + Enabled: nil, + Labels: &map[string]string{"env": "prod"}, + LocalSubnets: []string{"10.0.0.0/24"}, + RemoteSubnets: []string{"192.168.0.0/24"}, + StaticRoutes: []string{"10.1.0.0/24"}, + Tunnel1: vpn.TunnelConfiguration{ + RemoteAddress: "1.2.3.4", + PreSharedKey: utils.Ptr("test-psk-1"), + Bgp: &vpn.BGPTunnelConfig{ + RemoteAsn: 65000, + }, + Peering: &vpn.PeeringConfig{ + LocalAddress: utils.Ptr("169.254.0.1"), + RemoteAddress: utils.Ptr("169.254.0.2"), + }, + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: []vpn.PhaseDhGroupsInner{"14"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: utils.Ptr(int32(3600)), + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: []vpn.PhaseDhGroupsInner{"14"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: utils.Ptr(int32(3600)), + DpdAction: utils.Ptr(vpn.TunnelConfigurationPhase2AllOfDpdAction("restart")), + StartAction: utils.Ptr(vpn.TunnelConfigurationPhase2AllOfStartAction("start")), + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + RemoteAddress: "5.6.7.8", + PreSharedKey: utils.Ptr("test-psk-2"), + Phase1: vpn.TunnelConfigurationPhase1{ + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + }, + Phase2: vpn.TunnelConfigurationPhase2{ + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + }, + }, + } + *request = request.CreateGatewayConnectionPayload(payload) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + diff := cmp.Diff(request, tt.expectedResult, + cmp.AllowUnexported(tt.expectedResult), + cmpopts.IgnoreUnexported(vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model *inputModel + resp *vpn.ConnectionResponse + expected string + wantErr bool + }{ + { + description: "nil response", + model: fixtureInputModel(), + resp: nil, + wantErr: true, + expected: "", + }, + { + description: "success", + model: fixtureInputModel(), + resp: &vpn.ConnectionResponse{ + Id: utils.Ptr("conn-1234"), + }, + expected: fmt.Sprintf("Created VPN connection \"conn-1234\" for gateway %q in project %q.\n", testGatewayID, testProjectId), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + params := testparams.NewTestParams() + err := outputResult(params.Printer, tt.model, testProjectId, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && params.Out.String() != tt.expected { + t.Errorf("want:\n%s\ngot:\n%s", tt.expected, params.Out.String()) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/connection/delete/delete.go b/internal/cmd/beta/vpn/connection/delete/delete.go new file mode 100644 index 000000000..21707c4c4 --- /dev/null +++ b/internal/cmd/beta/vpn/connection/delete/delete.go @@ -0,0 +1,118 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" +) + +const ( + connectionIdArg = "CONNECTION_ID" + + gatewayIdFlag = "gateway-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + GatewayId *string + ConnectionId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", connectionIdArg), + Short: "Deletes a VPN connection", + Long: "Deletes a VPN connection.", + Args: args.SingleArg(connectionIdArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete a VPN connection`, + "$ stackit beta vpn connection delete xxx --gateway-id yyy"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to delete VPN connection %q from gateway %q?", model.ConnectionId, *model.GatewayId) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + err = req.Execute() + if err != nil { + return fmt.Errorf("delete VPN connection: %w", err) + } + + return outputResult(p.Printer, model, projectLabel) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), gatewayIdFlag, "Gateway ID") + + err := flags.MarkFlagsRequired(cmd, gatewayIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + connectionId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + GatewayId: flags.FlagToStringPointer(p, cmd, gatewayIdFlag), + ConnectionId: connectionId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) (vpn.ApiDeleteGatewayConnectionRequest, error) { + req := apiClient.DefaultAPI.DeleteGatewayConnection(ctx, model.ProjectId, model.Region, *model.GatewayId, model.ConnectionId) + return req, nil +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string) error { + p.Outputf("deleted VPN connection %q for gateway %q in project %q.\n", model.ConnectionId, *model.GatewayId, projectLabel) + return nil +} diff --git a/internal/cmd/beta/vpn/connection/delete/delete_test.go b/internal/cmd/beta/vpn/connection/delete/delete_test.go new file mode 100644 index 000000000..3633838b9 --- /dev/null +++ b/internal/cmd/beta/vpn/connection/delete/delete_test.go @@ -0,0 +1,155 @@ +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" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testGatewayID = uuid.NewString() + testConnectionID = uuid.NewString() + testClient, _ = vpn.NewAPIClient( + sdkConfig.WithoutAuthentication(), + ) +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testConnectionID, + } + for _, m := range mods { + m(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + gatewayIdFlag: testGatewayID, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + GatewayId: &testGatewayID, + ConnectionId: testConnectionID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiDeleteGatewayConnectionRequest)) vpn.ApiDeleteGatewayConnectionRequest { + request := testClient.DefaultAPI.DeleteGatewayConnection(testCtx, testProjectId, "", testGatewayID, testConnectionID) + 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 gateway id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, gatewayIdFlag) + }), + isValid: false, + }, + { + description: "no project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + 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 + expectedResult vpn.ApiDeleteGatewayConnectionRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedResult: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + diff := cmp.Diff(request, tt.expectedResult, + cmp.AllowUnexported(tt.expectedResult), + cmpopts.IgnoreUnexported(vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/connection/describe/describe.go b/internal/cmd/beta/vpn/connection/describe/describe.go new file mode 100644 index 000000000..e59a89412 --- /dev/null +++ b/internal/cmd/beta/vpn/connection/describe/describe.go @@ -0,0 +1,169 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/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 ( + connectionIdArg = "CONNECTION_ID" + + gatewayIdFlag = "gateway-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + GatewayId *string + ConnectionId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", connectionIdArg), + Short: "Shows details of a VPN connection", + Long: "Shows details of a VPN connection.", + Args: args.SingleArg(connectionIdArg, nil), + Example: examples.Build( + examples.NewExample( + `Show details of a VPN connection`, + "$ stackit beta vpn connection describe xxx --gateway-id yyy"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe VPN connection: %w", err) + } + + return outputResult(p.Printer, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), gatewayIdFlag, "Gateway ID") + + err := flags.MarkFlagsRequired(cmd, gatewayIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + connectionId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + GatewayId: flags.FlagToStringPointer(p, cmd, gatewayIdFlag), + ConnectionId: connectionId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) (vpn.ApiGetGatewayConnectionRequest, error) { + req := apiClient.DefaultAPI.GetGatewayConnection(ctx, model.ProjectId, model.Region, *model.GatewayId, model.ConnectionId) + return req, nil +} + +func outputResult(p *print.Printer, model *inputModel, resp *vpn.ConnectionResponse) error { + if resp == nil { + return fmt.Errorf("describe response is empty") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + mainTable := tables.NewTable() + mainTable.AddRow("ID", utils.PtrString(resp.Id)) + mainTable.AddRow("Name", resp.DisplayName) + mainTable.AddRow("Enabled", utils.PtrString(resp.Enabled)) + var labels string + if resp.Labels != nil { + labels = utils.JoinStringMap(*resp.Labels, "=", ", ") + } + mainTable.AddRow("Labels", labels) + mainTable.AddRow("Local Subnets", strings.Join(resp.LocalSubnets, ", ")) + mainTable.AddRow("Remote Subnets", strings.Join(resp.RemoteSubnets, ", ")) + mainTable.AddRow("Static Routes", strings.Join(resp.StaticRoutes, ", ")) + + ts := []tables.Table{ + mainTable, + } + ts = append(ts, tunnelTables(resp.Tunnel1, "Tunnel 1")...) + ts = append(ts, tunnelTables(resp.Tunnel2, "Tunnel 2")...) + return tables.DisplayTables(p, ts) + }) +} + +func tunnelTables(tunnel vpn.TunnelConfiguration, title string) []tables.Table { + table := tables.NewTable() + table.SetTitle(title) + table.AddRow("IP Address", tunnel.RemoteAddress) + var bgp string + if tunnel.Bgp != nil { + bgp = fmt.Sprintf("%d", tunnel.Bgp.RemoteAsn) + } + table.AddRow("BGP ASN", bgp) + var peering string + if tunnel.Peering != nil { + peering = fmt.Sprintf("%s/%s", utils.PtrString(tunnel.Peering.LocalAddress), utils.PtrString(tunnel.Peering.RemoteAddress)) + } + table.AddRow("Peering (local/remote)", peering) + + phase1Table := tables.NewTable() + phase1Table.SetTitle(fmt.Sprintf("%s Phase 1", title)) + phase1Table.AddRow("DH Groups", utils.JoinStringPtr(&tunnel.Phase1.DhGroups, ", ")) + phase1Table.AddRow("Encryption Algos", utils.JoinStringPtr(&tunnel.Phase1.EncryptionAlgorithms, ", ")) + phase1Table.AddRow("Integrity Algos", utils.JoinStringPtr(&tunnel.Phase1.IntegrityAlgorithms, ", ")) + phase1Table.AddRow("Rekey Time", utils.PtrString(tunnel.Phase1.RekeyTime)) + + phase2Table := tables.NewTable() + phase2Table.SetTitle(fmt.Sprintf("%s Phase 2", title)) + phase2Table.AddRow("DH Groups", utils.JoinStringPtr(&tunnel.Phase2.DhGroups, ", ")) + phase2Table.AddRow("Encryption Algos", utils.JoinStringPtr(&tunnel.Phase2.EncryptionAlgorithms, ", ")) + phase2Table.AddRow("Integrity Algos", utils.JoinStringPtr(&tunnel.Phase2.IntegrityAlgorithms, ", ")) + phase2Table.AddRow("Rekey Time", utils.PtrString(tunnel.Phase1.RekeyTime)) + phase2Table.AddRow("Dpd Action", utils.PtrString(tunnel.Phase2.DpdAction)) + phase2Table.AddRow("Start Action", utils.PtrString(tunnel.Phase2.StartAction)) + + return []tables.Table{ + table, + phase1Table, + phase2Table, + } +} diff --git a/internal/cmd/beta/vpn/connection/describe/describe_test.go b/internal/cmd/beta/vpn/connection/describe/describe_test.go new file mode 100644 index 000000000..af4a596a9 --- /dev/null +++ b/internal/cmd/beta/vpn/connection/describe/describe_test.go @@ -0,0 +1,242 @@ +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" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testGatewayID = uuid.NewString() + testConnectionID = uuid.NewString() + testClient, _ = vpn.NewAPIClient( + sdkConfig.WithoutAuthentication(), + ) +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testConnectionID, + } + for _, m := range mods { + m(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + gatewayIdFlag: testGatewayID, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + GatewayId: utils.Ptr(testGatewayID), + ConnectionId: testConnectionID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiGetGatewayConnectionRequest)) vpn.ApiGetGatewayConnectionRequest { + request := testClient.DefaultAPI.GetGatewayConnection(testCtx, testProjectId, "", testGatewayID, testConnectionID) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureResponse(mods ...func(resp *vpn.ConnectionResponse)) *vpn.ConnectionResponse { + resp := &vpn.ConnectionResponse{ + Id: utils.Ptr(testConnectionID), + DisplayName: "test-connection", + Enabled: utils.Ptr(true), + Labels: &map[string]string{ + "env": "prod", + }, + LocalSubnets: []string{"10.0.0.0/24"}, + RemoteSubnets: []string{"192.168.0.0/24"}, + StaticRoutes: []string{"10.1.0.0/24"}, + Tunnel1: vpn.TunnelConfiguration{ + RemoteAddress: "1.2.3.4", + Bgp: &vpn.BGPTunnelConfig{ + RemoteAsn: 65000, + }, + Peering: &vpn.PeeringConfig{ + LocalAddress: utils.Ptr("169.254.0.1"), + RemoteAddress: utils.Ptr("169.254.0.2"), + }, + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: []vpn.PhaseDhGroupsInner{"14"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: utils.Ptr(int32(3600)), + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: []vpn.PhaseDhGroupsInner{"14"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: utils.Ptr(int32(3600)), + DpdAction: utils.Ptr(vpn.TunnelConfigurationPhase2AllOfDpdAction("restart")), + StartAction: utils.Ptr(vpn.TunnelConfigurationPhase2AllOfStartAction("start")), + }, + }, + Tunnel2: vpn.TunnelConfiguration{ + RemoteAddress: "5.6.7.8", + Phase1: vpn.TunnelConfigurationPhase1{ + DhGroups: []vpn.PhaseDhGroupsInner{"14"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: utils.Ptr(int32(3600)), + }, + Phase2: vpn.TunnelConfigurationPhase2{ + DhGroups: []vpn.PhaseDhGroupsInner{"14"}, + EncryptionAlgorithms: []vpn.PhaseEncryptionAlgorithmsInner{"aes256"}, + IntegrityAlgorithms: []vpn.PhaseIntegrityAlgorithmsInner{"sha2_256"}, + RekeyTime: utils.Ptr(int32(3600)), + DpdAction: utils.Ptr(vpn.TunnelConfigurationPhase2AllOfDpdAction("restart")), + StartAction: utils.Ptr(vpn.TunnelConfigurationPhase2AllOfStartAction("start")), + }, + }, + } + + for _, mod := range mods { + mod(resp) + } + return resp +} + +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 args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no gateway id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, gatewayIdFlag) + }), + isValid: false, + }, + { + description: "no project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + 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 + expectedResult vpn.ApiGetGatewayConnectionRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedResult: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + diff := cmp.Diff(request, tt.expectedResult, + cmp.AllowUnexported(tt.expectedResult), + cmpopts.IgnoreUnexported(vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model *inputModel + resp *vpn.ConnectionResponse + wantErr bool + }{ + { + description: "nil response", + model: fixtureInputModel(), + resp: nil, + wantErr: true, + }, + { + description: "full response", + model: fixtureInputModel(), + resp: fixtureResponse(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + params := testparams.NewTestParams() + err := outputResult(params.Printer, tt.model, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/vpn/connection/list/list.go b/internal/cmd/beta/vpn/connection/list/list.go new file mode 100644 index 000000000..0bd2cfa32 --- /dev/null +++ b/internal/cmd/beta/vpn/connection/list/list.go @@ -0,0 +1,126 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/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 ( + gatewayIdFlag = "gateway-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + GatewayId *string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all VPN connections of a gateway", + Long: "Lists all VPN connections of a gateway.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all VPN connections of a gateway`, + "$ stackit beta vpn connection list --gateway-id xxx"), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list VPN connections: %w", err) + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), gatewayIdFlag, "Gateway ID") + + err := flags.MarkFlagsRequired(cmd, gatewayIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + GatewayId: flags.FlagToStringPointer(p, cmd, gatewayIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClient) (vpn.ApiListGatewayConnectionsRequest, error) { + req := apiClient.DefaultAPI.ListGatewayConnections(ctx, model.ProjectId, model.Region, *model.GatewayId) + return req, nil +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *vpn.ConnectionList) error { + if resp == nil || resp.Connections == nil { + return fmt.Errorf("list connections response is empty") + } + + return p.OutputResult(model.OutputFormat, resp.Connections, func() error { + table := tables.NewTable() + table.SetHeader("ID", "NAME", "ENABLED", "LABELS") + for _, c := range resp.Connections { + id := utils.PtrString(c.Id) + name := c.DisplayName + enabled := utils.PtrString(c.Enabled) + var labels string + if c.Labels != nil { + labels = utils.JoinStringMap(*c.Labels, "=", ", ") + } + table.AddRow(id, name, enabled, labels) + } + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/beta/vpn/connection/list/list_test.go b/internal/cmd/beta/vpn/connection/list/list_test.go new file mode 100644 index 000000000..1dbf641c7 --- /dev/null +++ b/internal/cmd/beta/vpn/connection/list/list_test.go @@ -0,0 +1,206 @@ +package list + +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/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testGatewayID = uuid.NewString() + testClient, _ = vpn.NewAPIClient( + sdkConfig.WithoutAuthentication(), + ) +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + gatewayIdFlag: testGatewayID, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + GatewayId: utils.Ptr(testGatewayID), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *vpn.ApiListGatewayConnectionsRequest)) vpn.ApiListGatewayConnectionsRequest { + request := testClient.DefaultAPI.ListGatewayConnections(testCtx, testProjectId, "", 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: []string{}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flags", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no project id", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "no gateway id", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, gatewayIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(printer *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(printer, cmd) + }, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedResult vpn.ApiListGatewayConnectionsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedResult: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + diff := cmp.Diff(request, tt.expectedResult, + cmp.AllowUnexported(tt.expectedResult), + cmpopts.IgnoreUnexported(vpn.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model *inputModel + resp *vpn.ConnectionList + wantErr bool + }{ + { + description: "empty list", + model: fixtureInputModel(), + resp: &vpn.ConnectionList{ + Connections: []vpn.ConnectionResponse{}, + }, + }, + { + description: "nil response", + model: fixtureInputModel(), + resp: nil, + wantErr: true, + }, + { + description: "nil connections", + model: fixtureInputModel(), + resp: &vpn.ConnectionList{ + Connections: nil, + }, + wantErr: true, + }, + { + description: "with entries", + model: fixtureInputModel(), + resp: &vpn.ConnectionList{ + Connections: []vpn.ConnectionResponse{ + { + Id: utils.Ptr("conn-1"), + DisplayName: "test-conn-1", + Enabled: utils.Ptr(true), + Labels: &map[string]string{ + "env": "prod", + }, + }, + { + Id: utils.Ptr("conn-2"), + DisplayName: "test-conn-2", + Enabled: utils.Ptr(false), + Labels: nil, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + params := testparams.NewTestParams() + err := outputResult(params.Printer, tt.model, testProjectId, tt.resp) + if (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..46a5821c3 --- /dev/null +++ b/internal/cmd/beta/vpn/vpn.go @@ -0,0 +1,24 @@ +package vpn + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "vpn", + Short: "Provides functionality for VPN", + Long: "Provides functionality for VPN.", + Args: cobra.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(connection.NewCmd(params)) +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index a2852b59f..9e1084dac 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" diff --git a/internal/pkg/flags/string_enum.go b/internal/pkg/flags/string_enum.go new file mode 100644 index 000000000..760c5f6d8 --- /dev/null +++ b/internal/pkg/flags/string_enum.go @@ -0,0 +1,117 @@ +package flags + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type stringEnumFlag[T ~string] struct { + ignoreCase bool + options []T + value T + valueSet bool + docs string + name string +} + +type StringEnumFlagOption[T ~string] func(*stringEnumFlag[T]) + +func StringEnumIgnoreCase[T ~string]() StringEnumFlagOption[T] { + return func(f *stringEnumFlag[T]) { + f.ignoreCase = true + } +} + +func StringEnumDefaultValue[T ~string](value T) StringEnumFlagOption[T] { + return func(f *stringEnumFlag[T]) { + f.value = value + f.valueSet = true + } +} + +func StringEnumFlag[T ~string](name string, possibleValues []T, docs string, opts ...StringEnumFlagOption[T]) *stringEnumFlag[T] { + f := &stringEnumFlag[T]{ + name: name, + docs: docs, + } + for _, v := range possibleValues { + if string(v) != "unknown_default_open_api" { + f.options = append(f.options, v) + } + } + for _, opt := range opts { + opt(f) + } + return f +} + +var _ pflag.Value = &stringEnumFlag[string]{} + +func (s *stringEnumFlag[T]) Register(cmd *cobra.Command) { + cmd.Flags().Var(s, s.name, s.Usage()) +} + +func (s *stringEnumFlag[T]) Usage() string { + return s.docs + fmt.Sprintf(" (possible values: %s)", s.fmtValues(s.options)) +} + +func (s *stringEnumFlag[T]) Get() T { + return s.value +} + +func (s *stringEnumFlag[T]) Ptr() *T { + if s.valueSet { + return &s.value + } + return nil +} + +func (s *stringEnumFlag[T]) Name() string { + return s.name +} + +func (s *stringEnumFlag[T]) String() string { + return string(s.value) +} + +func (s *stringEnumFlag[T]) fmtValues(xs []T) string { + var sb strings.Builder + sb.WriteString("[") + for i, v := range xs { + sb.WriteString(string(v)) + if i != len(xs)-1 { + sb.WriteString(", ") + } + } + sb.WriteString("]") + return sb.String() +} + +func (s *stringEnumFlag[T]) Set(value string) error { + v := strings.TrimSpace(value) + + if v == "" { + return fmt.Errorf("value cannot be empty") + } + + for _, o := range s.options { + if !s.ignoreCase && v == string(o) { + s.value = T(v) + s.valueSet = true + return nil + } else if s.ignoreCase && strings.EqualFold(v, string(o)) { + s.value = T(strings.ToLower(v)) + s.valueSet = true + return nil + } + } + + return fmt.Errorf("found value %q, expected one of %q", v, s.options) +} + +func (s *stringEnumFlag[T]) Type() string { + return "string" +} diff --git a/internal/pkg/flags/string_enum_test.go b/internal/pkg/flags/string_enum_test.go new file mode 100644 index 000000000..efe79fed2 --- /dev/null +++ b/internal/pkg/flags/string_enum_test.go @@ -0,0 +1,149 @@ +package flags + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestStringEnumFlag_Set(t *testing.T) { + tests := []struct { + name string + options []string + ignoreCase bool + setValue string + want string + wantErr bool + }{ + { + name: "valid value", + options: []string{"a", "b", "c"}, + setValue: "a", + want: "a", + }, + { + name: "invalid value", + options: []string{"a", "b", "c"}, + setValue: "d", + wantErr: true, + }, + { + name: "empty value", + options: []string{"a", "b", "c"}, + setValue: "", + wantErr: true, + }, + { + name: "case sensitive mismatch", + options: []string{"A", "B"}, + setValue: "a", + ignoreCase: false, + wantErr: true, + }, + { + name: "case insensitive match", + options: []string{"A", "B"}, + setValue: "a", + ignoreCase: true, + want: "a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := []StringEnumFlagOption[string]{} + if tt.ignoreCase { + opts = append(opts, StringEnumIgnoreCase[string]()) + } + f := StringEnumFlag("test", tt.options, "docs", opts...) + + err := f.Set(tt.setValue) + if (err != nil) != tt.wantErr { + t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + got := f.Get() + if got != tt.want { + t.Errorf("Set() got = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestStringEnumFlag_DefaultValue(t *testing.T) { + f := StringEnumFlag("test", []string{"a", "b"}, "docs", StringEnumDefaultValue("a")) + + got := f.Get() + if got != "a" { + t.Errorf("Expected default value a, got %v", got) + } + + // Setting a value should override the default + err := f.Set("b") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + got = f.Get() + if got != "b" { + t.Errorf("Expected value b after Set, got %v", got) + } +} + +func TestStringEnumFlag_Usage(t *testing.T) { + f := StringEnumFlag("test", []string{"a", "b"}, "docs") + usage := f.Usage() + if usage != "docs (possible values: [a, b])" { + t.Errorf("Expected usage 'docs (possible values: [a, b])', got %q", usage) + } +} + +func TestStringEnumFlag_UnknownDefaultOpenAPI(t *testing.T) { + f := StringEnumFlag("test", []string{"a", "unknown_default_open_api", "b"}, "docs") + usage := f.Usage() + if usage != "docs (possible values: [a, b])" { + t.Errorf("Expected unknown_default_open_api to be filtered out, got %q", usage) + } +} + +func TestStringEnumFlag_Register(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + f := StringEnumFlag("my-flag", []string{"a", "b"}, "docs") + f.Register(cmd) + + flag := cmd.Flags().Lookup("my-flag") + if flag == nil { + t.Errorf("Expected flag 'my-flag' to be registered") + } + if flag.Usage != "docs (possible values: [a, b])" { + t.Errorf("Expected flag usage to be set correctly") + } +} + +func TestStringEnumFlag_Ptr(t *testing.T) { + f := StringEnumFlag("test", []string{"a", "b"}, "docs") + if f.Ptr() != nil { + t.Errorf("Expected Ptr() to be nil initially, got %v", *f.Ptr()) + } + + err := f.Set("a") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + ptr := f.Ptr() + if ptr == nil { + t.Errorf("Expected Ptr() to not be nil after Set") + } else if *ptr != "a" { + t.Errorf("Expected Ptr() to point to 'a', got %v", *ptr) + } + + fWithDefault := StringEnumFlag("test_default", []string{"a", "b"}, "docs", StringEnumDefaultValue("b")) + ptrDefault := fWithDefault.Ptr() + if ptrDefault == nil { + t.Errorf("Expected Ptr() to not be nil with default value") + } else if *ptrDefault != "b" { + t.Errorf("Expected Ptr() to point to 'b' with default value, got %v", *ptrDefault) + } +} diff --git a/internal/pkg/flags/string_enumslice.go b/internal/pkg/flags/string_enumslice.go new file mode 100644 index 000000000..2feb3705f --- /dev/null +++ b/internal/pkg/flags/string_enumslice.go @@ -0,0 +1,126 @@ +package flags + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type stringEnumSliceFlag[T ~string] struct { + ignoreCase bool + options []T + value []T + valueSet bool + docs string + name string +} + +type StringEnumSliceFlagOption[T ~string] func(*stringEnumSliceFlag[T]) + +func IgnoreCase[T ~string]() StringEnumSliceFlagOption[T] { + return func(f *stringEnumSliceFlag[T]) { + f.ignoreCase = true + } +} + +func DefaultValues[T ~string](values ...T) StringEnumSliceFlagOption[T] { + return func(f *stringEnumSliceFlag[T]) { + f.value = append(f.value, values...) + } +} + +func StringEnumSliceFlag[T ~string](name string, possibleValues []T, docs string, opts ...StringEnumSliceFlagOption[T]) *stringEnumSliceFlag[T] { + f := &stringEnumSliceFlag[T]{ + name: name, + docs: docs, + } + for _, v := range possibleValues { + if string(v) != "unknown_default_open_api" { + f.options = append(f.options, v) + } + } + for _, opt := range opts { + opt(f) + } + return f +} + +var _ pflag.Value = &stringEnumSliceFlag[string]{} + +func (s *stringEnumSliceFlag[T]) Register(cmd *cobra.Command) { + cmd.Flags().Var(s, s.name, s.Usage()) +} + +func (s *stringEnumSliceFlag[T]) Usage() string { + return s.docs + fmt.Sprintf(" (possible values: %s)", s.fmtValues(s.options)) +} + +func (s *stringEnumSliceFlag[T]) Get() []T { + return s.value +} + +func (s *stringEnumSliceFlag[T]) Name() string { + return s.name +} + +func (s *stringEnumSliceFlag[T]) String() string { + return s.fmtValues(s.value) +} + +func (s *stringEnumSliceFlag[T]) fmtValues(xs []T) string { + var sb strings.Builder + sb.WriteString("[") + for i, v := range xs { + sb.WriteString(string(v)) + if i != len(xs)-1 { + sb.WriteString(", ") + } + } + sb.WriteString("]") + return sb.String() +} + +func (s *stringEnumSliceFlag[T]) Set(value string) error { + // If the default value is still set, remove it + // (Since we're going to append the incoming values to f.value) + if !s.valueSet { + s.value = []T{} + s.valueSet = true + } + + if value == "" { + return fmt.Errorf("value cannot be empty") + } + values := strings.Split(value, ",") + return s.appendToValue(values) +} + +func (s *stringEnumSliceFlag[T]) Type() string { + return "stringSlice" +} + +func (s *stringEnumSliceFlag[T]) appendToValue(values []string) error { + for _, v := range values { + v = strings.TrimSpace(v) + + foundValid := false + for _, o := range s.options { + if !s.ignoreCase && v == string(o) { + s.value = append(s.value, T(v)) + foundValid = true + break + } else if s.ignoreCase && strings.EqualFold(v, string(o)) { + s.value = append(s.value, T(strings.ToLower(v))) + foundValid = true + break + } + } + + if !foundValid { + return fmt.Errorf("found value %q, expected one of %q", v, s.options) + } + } + return nil +} diff --git a/internal/pkg/flags/string_enumslice_test.go b/internal/pkg/flags/string_enumslice_test.go new file mode 100644 index 000000000..2b292e15a --- /dev/null +++ b/internal/pkg/flags/string_enumslice_test.go @@ -0,0 +1,161 @@ +package flags + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestStringEnumSliceFlag_Set(t *testing.T) { + tests := []struct { + name string + options []string + ignoreCase bool + setValue string + want []string + wantErr bool + }{ + { + name: "valid value", + options: []string{"a", "b", "c"}, + setValue: "a", + want: []string{"a"}, + wantErr: false, + }, + { + name: "multiple valid values", + options: []string{"a", "b", "c"}, + setValue: "a,b", + want: []string{"a", "b"}, + wantErr: false, + }, + { + name: "multiple valid values with spaces", + options: []string{"a", "b", "c"}, + setValue: "a, b ,c", + want: []string{"a", "b", "c"}, + wantErr: false, + }, + { + name: "invalid value", + options: []string{"a", "b", "c"}, + setValue: "d", + wantErr: true, + }, + { + name: "partially invalid value", + options: []string{"a", "b", "c"}, + setValue: "a,d", + wantErr: true, + }, + { + name: "empty value", + options: []string{"a", "b", "c"}, + setValue: "", + wantErr: true, + }, + { + name: "case sensitive mismatch", + options: []string{"A", "B"}, + setValue: "a", + ignoreCase: false, + wantErr: true, + }, + { + name: "case insensitive match", + options: []string{"A", "B"}, + setValue: "a", + ignoreCase: true, + want: []string{"a"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := []StringEnumSliceFlagOption[string]{} + if tt.ignoreCase { + opts = append(opts, IgnoreCase[string]()) + } + f := StringEnumSliceFlag("test", tt.options, "docs", opts...) + + err := f.Set(tt.setValue) + if (err != nil) != tt.wantErr { + t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + got := f.Get() + if len(got) != len(tt.want) { + t.Errorf("Set() got = %v, want %v", got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("Set() got = %v, want %v", got, tt.want) + break + } + } + } + }) + } +} + +func TestStringEnumSliceFlag_DefaultValues(t *testing.T) { + f := StringEnumSliceFlag("test", []string{"a", "b"}, "docs", DefaultValues("a")) + + got := f.Get() + if len(got) != 1 || got[0] != "a" { + t.Errorf("Expected default value [a], got %v", got) + } + + // Setting a value should override the default + err := f.Set("b") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + got = f.Get() + if len(got) != 1 || got[0] != "b" { + t.Errorf("Expected value [b] after Set, got %v", got) + } + + // Setting another value should append + err = f.Set("a") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + got = f.Get() + if len(got) != 2 || got[0] != "b" || got[1] != "a" { + t.Errorf("Expected value [b, a] after second Set, got %v", got) + } +} + +func TestStringEnumSliceFlag_Usage(t *testing.T) { + f := StringEnumSliceFlag("test", []string{"a", "b"}, "docs") + usage := f.Usage() + if usage != "docs (possible values: [a, b])" { + t.Errorf("Expected usage 'docs (possible values: [a, b])', got %q", usage) + } +} + +func TestStringEnumSliceFlag_UnknownDefaultOpenAPI(t *testing.T) { + f := StringEnumSliceFlag("test", []string{"a", "unknown_default_open_api", "b"}, "docs") + usage := f.Usage() + if usage != "docs (possible values: [a, b])" { + t.Errorf("Expected unknown_default_open_api to be filtered out, got %q", usage) + } +} + +func TestStringEnumSliceFlag_Register(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + f := StringEnumSliceFlag("my-flag", []string{"a", "b"}, "docs") + f.Register(cmd) + + flag := cmd.Flags().Lookup("my-flag") + if flag == nil { + t.Errorf("Expected flag 'my-flag' to be registered") + } + if flag.Usage != "docs (possible values: [a, b])" { + t.Errorf("Expected flag usage to be set correctly") + } +} diff --git a/internal/pkg/services/vpn/client/client.go b/internal/pkg/services/vpn/client/client.go new file mode 100644 index 000000000..860c89b3c --- /dev/null +++ b/internal/pkg/services/vpn/client/client.go @@ -0,0 +1,13 @@ +package client + +import ( + "github.com/spf13/viper" + "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" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*vpn.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.VPNCustomEndpointKey), false, vpn.NewAPIClient) +} diff --git a/internal/pkg/utils/strings.go b/internal/pkg/utils/strings.go index 64817f4ee..8772e38af 100644 --- a/internal/pkg/utils/strings.go +++ b/internal/pkg/utils/strings.go @@ -47,11 +47,24 @@ func JoinStringMap(m map[string]string, keyValueSeparator, separator string) str // JoinStringPtr concatenates the strings of a string slice pointer, each separatore by the // [sep] string. -func JoinStringPtr(vals *[]string, sep string) string { +func JoinStringPtr[T ~string](vals *[]T, sep string) string { if vals == nil || len(*vals) == 0 { return "" } - return strings.Join(*vals, sep) + deref := *vals + switch len(deref) { + case 0: + return "" + case 1: + return string(deref[0]) + } + var b strings.Builder + b.WriteString(string(deref[0])) + for _, s := range deref[1:] { + b.WriteString(sep) + b.WriteString(string(s)) + } + return b.String() } // Truncate trims the passed string (if it is not nil). If the input string is From 1b38892d4ccf545be523807d17e1b25d2f576766 Mon Sep 17 00:00:00 2001 From: Carlo Goetz Date: Tue, 9 Jun 2026 15:19:37 +0200 Subject: [PATCH 2/2] fix(vpn): docs etc --- docs/stackit_beta.md | 1 + docs/stackit_beta_vpn.md | 34 +++++++ docs/stackit_beta_vpn_connection.md | 37 ++++++++ docs/stackit_beta_vpn_connection_create.md | 94 +++++++++++++++++++ docs/stackit_beta_vpn_connection_delete.md | 41 ++++++++ docs/stackit_beta_vpn_connection_describe.md | 41 ++++++++ docs/stackit_beta_vpn_connection_list.md | 41 ++++++++ .../cmd/beta/vpn/connection/connection.go | 1 + .../cmd/beta/vpn/connection/create/create.go | 3 +- .../beta/vpn/connection/create/create_test.go | 3 +- .../cmd/beta/vpn/connection/delete/delete.go | 3 +- .../beta/vpn/connection/delete/delete_test.go | 3 +- .../beta/vpn/connection/describe/describe.go | 9 +- .../vpn/connection/describe/describe_test.go | 3 +- internal/cmd/beta/vpn/connection/list/list.go | 14 +-- .../cmd/beta/vpn/connection/list/list_test.go | 5 +- internal/cmd/beta/vpn/vpn.go | 1 + internal/pkg/flags/string_enum_test.go | 2 +- internal/pkg/flags/string_enumslice_test.go | 2 +- internal/pkg/services/vpn/client/client.go | 3 +- 20 files changed, 317 insertions(+), 24 deletions(-) create mode 100644 docs/stackit_beta_vpn.md create mode 100644 docs/stackit_beta_vpn_connection.md create mode 100644 docs/stackit_beta_vpn_connection_create.md create mode 100644 docs/stackit_beta_vpn_connection_delete.md create mode 100644 docs/stackit_beta_vpn_connection_describe.md create mode 100644 docs/stackit_beta_vpn_connection_list.md diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 1f34c1859..d79fd6e9f 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -47,4 +47,5 @@ stackit beta [flags] * [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake * [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (STACKIT File Storage) * [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex +* [stackit beta vpn](./stackit_beta_vpn.md) - Provides functionality for VPN diff --git a/docs/stackit_beta_vpn.md b/docs/stackit_beta_vpn.md new file mode 100644 index 000000000..c15583893 --- /dev/null +++ b/docs/stackit_beta_vpn.md @@ -0,0 +1,34 @@ +## stackit beta vpn + +Provides functionality for VPN + +### Synopsis + +Provides functionality for VPN. + +``` +stackit beta vpn [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta vpn" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta vpn connection](./stackit_beta_vpn_connection.md) - Provides functionality for VPN connections + diff --git a/docs/stackit_beta_vpn_connection.md b/docs/stackit_beta_vpn_connection.md new file mode 100644 index 000000000..735a77b34 --- /dev/null +++ b/docs/stackit_beta_vpn_connection.md @@ -0,0 +1,37 @@ +## stackit beta vpn connection + +Provides functionality for VPN connections + +### Synopsis + +Provides functionality for VPN connections. + +``` +stackit beta vpn connection [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta vpn connection" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn](./stackit_beta_vpn.md) - Provides functionality for VPN +* [stackit beta vpn connection create](./stackit_beta_vpn_connection_create.md) - Creates a VPN connection +* [stackit beta vpn connection delete](./stackit_beta_vpn_connection_delete.md) - Deletes a VPN connection +* [stackit beta vpn connection describe](./stackit_beta_vpn_connection_describe.md) - Shows details of a VPN connection +* [stackit beta vpn connection list](./stackit_beta_vpn_connection_list.md) - Lists all VPN connections of a gateway + diff --git a/docs/stackit_beta_vpn_connection_create.md b/docs/stackit_beta_vpn_connection_create.md new file mode 100644 index 000000000..1525fd345 --- /dev/null +++ b/docs/stackit_beta_vpn_connection_create.md @@ -0,0 +1,94 @@ +## stackit beta vpn connection create + +Creates a VPN connection + +### Synopsis + +Creates a VPN connection. + +``` +stackit beta vpn connection create [flags] +``` + +### Examples + +``` + Create a VPN connection + $ stackit beta vpn connection create --gateway-id xxx --display-name my-connection --tunnel1-remote-address 1.2.3.4 --tunnel2-remote-address 5.6.7.8 +``` + +### Options + +``` + --display-name string Required: A user friendly name for the connection. + --enabled Enable the connection (default true) + --gateway-id string Required: Gateway ID + -h, --help Help for "stackit beta vpn connection create" + --labels stringToString Map of custom labels. Key and values must be a string with max 63 chars, start/end with alphanumeric. The key of a label follows the same rules as the LabelValue except that it cannot be empty. (example: foo=bar) (default []) + --local-subnets strings Defaults to 0.0.0.0/0 for Route-based VPN configurations. Mandatory for Policy-based. + --remote-subnets strings Defaults to 0.0.0.0/0 for Route-based VPN configurations. Mandatory for Policy-based. + --static-routes strings Use this for route-based VPN. + --tunnel1-bgp-remote-asn int Required: Tunnel 1 BGP Remote ASN. + ASN for private use (reserved by IANA), both 16Bit and 32Bit ranges are valid (RFC 6996). + --tunnel1-peering-local-address string Tunnel 1 Peering Local Address. + The peering object defines the point-to-point IP configuration for the Tunnel Interface. These addresses serve as next-hop identifiers and are used for BGP peering sessions and can be used in Static Route-Based connectivity. + --tunnel1-peering-remote-address string Tunnel 1 Peering Remote Address + --tunnel1-phase1-dh-groups strings Tunnel 1 Phase 1 DH Groups. + The Diffie-Hellman Group. Required, except if AEAD algorithms are selected. (possible values: [modp1024, modp2048, ecp256, ecp384, modp2048s256]) (default []) + --tunnel1-phase1-encryption-algorithms strings Required: Tunnel 1 Phase 1 Encryption Algorithms (possible values: [aes256, aes128gcm16, aes256gcm16]) (default []) + --tunnel1-phase1-integrity-algorithms strings Required: Tunnel 1 Phase 1 Integrity Algorithms (possible values: [sha1, sha2_256, sha2_384]) (default []) + --tunnel1-phase1-rekey-time int Tunnel 1 Phase 1 Rekey Time. + Time to schedule a IKE re-keying (in seconds). + --tunnel1-phase2-dh-groups strings Tunnel 1 Phase 2 DH Groups (possible values: [modp1024, modp2048, ecp256, ecp384, modp2048s256]) (default []) + --tunnel1-phase2-dpd-action string Tunnel 1 Phase 2 DPD Action. + Action to perform for this CHILD_SA on DPD timeout. "clear": Closes the CHILD_SA and does not take further action. "restart": immediately tries to re-negotiate the CILD_SA under a fresh IKE_SA. (possible values: [clear, restart]) + --tunnel1-phase2-encryption-algorithms strings Required: Tunnel 1 Phase 2 Encryption Algorithms (possible values: [aes256, aes128gcm16, aes256gcm16]) (default []) + --tunnel1-phase2-integrity-algorithms strings Required: Tunnel 1 Phase 2 Integrity Algorithms (possible values: [sha1, sha2_256, sha2_384]) (default []) + --tunnel1-phase2-rekey-time int Tunnel 1 Phase 2 Rekey Time. + Time to schedule a Child SA re-keying (in seconds). + --tunnel1-phase2-start-action string Tunnel 1 Phase 2 Start Action. + Action to perform after loading the connection configuration. "none": The connection will be loaded but needs to be manually initiated. "start": initiates the connection actively. (possible values: [none, start]) + --tunnel1-pre-shared-key string Required: Tunnel 1 Pre Shared Key. + A Pre-Shared Key for authentication. Required in create-requests, optional in update-requests and omitted in every response. + --tunnel1-remote-address string Tunnel 1 Remote Address + --tunnel2-bgp-remote-asn int Tunnel 2 BGP Remote ASN + --tunnel2-peering-local-address string Tunnel 2 Peering Local Address. + The peering object defines the point-to-point IP configuration for the Tunnel Interface. These addresses serve as next-hop identifiers and are used for BGP peering sessions and can be used in Static Route-Based connectivity. + --tunnel2-peering-remote-address string Tunnel 2 Peering Remote Address + --tunnel2-phase1-dh-groups strings Tunnel 2 Phase 1 DH Groups + The Diffie-Hellman Group. Required, except if AEAD algorithms are selected. (possible values: [modp1024, modp2048, ecp256, ecp384, modp2048s256]) (default []) + --tunnel2-phase1-encryption-algorithms strings Required: Tunnel 2 Phase 1 Encryption Algorithms (possible values: [aes256, aes128gcm16, aes256gcm16]) (default []) + --tunnel2-phase1-integrity-algorithms strings Required: Tunnel 2 Phase 1 Integrity Algorithms (possible values: [sha1, sha2_256, sha2_384]) (default []) + --tunnel2-phase1-rekey-time int Tunnel 2 Phase 1 Rekey Time. + Time to schedule a IKE re-keying (in seconds). + --tunnel2-phase2-dh-groups strings Tunnel 2 Phase 2 DH Groups (possible values: [modp1024, modp2048, ecp256, ecp384, modp2048s256]) (default []) + --tunnel2-phase2-dpd-action string Tunnel 2 Phase 2 DPD Action. + Action to perform for this CHILD_SA on DPD timeout. "clear": Closes the CHILD_SA and does not take further action. "restart": immediately tries to re-negotiate the CILD_SA under a fresh IKE_SA. (possible values: [clear, restart]) + --tunnel2-phase2-encryption-algorithms strings Required: Tunnel 2 Phase 2 Encryption Algorithms (possible values: [aes256, aes128gcm16, aes256gcm16]) (default []) + --tunnel2-phase2-integrity-algorithms strings Required: Tunnel 2 Phase 2 Integrity Algorithms (possible values: [sha1, sha2_256, sha2_384]) (default []) + --tunnel2-phase2-rekey-time int Tunnel 2 Phase 2 Rekey Time. + Time to schedule a Child SA re-keying (in seconds). + --tunnel2-phase2-start-action string Tunnel 2 Phase 2 Start Action. + Default: "start" + Enum: "none" "start" + Action to perform after loading the connection configuration. "none": The connection will be loaded but needs to be manually initiated. "start": initiates the connection actively. (possible values: [none, start]) + --tunnel2-pre-shared-key string Required: Tunnel 2 Pre Shared Key. + A Pre-Shared Key for authentication. Required in create-requests, optional in update-requests and omitted in every response. + --tunnel2-remote-address string Tunnel 2 Remote Address +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn connection](./stackit_beta_vpn_connection.md) - Provides functionality for VPN connections + diff --git a/docs/stackit_beta_vpn_connection_delete.md b/docs/stackit_beta_vpn_connection_delete.md new file mode 100644 index 000000000..724c0f42a --- /dev/null +++ b/docs/stackit_beta_vpn_connection_delete.md @@ -0,0 +1,41 @@ +## stackit beta vpn connection delete + +Deletes a VPN connection + +### Synopsis + +Deletes a VPN connection. + +``` +stackit beta vpn connection delete CONNECTION_ID [flags] +``` + +### Examples + +``` + Delete a VPN connection + $ stackit beta vpn connection delete xxx --gateway-id yyy +``` + +### Options + +``` + --gateway-id string Gateway ID + -h, --help Help for "stackit beta vpn connection delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn connection](./stackit_beta_vpn_connection.md) - Provides functionality for VPN connections + diff --git a/docs/stackit_beta_vpn_connection_describe.md b/docs/stackit_beta_vpn_connection_describe.md new file mode 100644 index 000000000..ddfb63f1c --- /dev/null +++ b/docs/stackit_beta_vpn_connection_describe.md @@ -0,0 +1,41 @@ +## stackit beta vpn connection describe + +Shows details of a VPN connection + +### Synopsis + +Shows details of a VPN connection. + +``` +stackit beta vpn connection describe CONNECTION_ID [flags] +``` + +### Examples + +``` + Show details of a VPN connection + $ stackit beta vpn connection describe xxx --gateway-id yyy +``` + +### Options + +``` + --gateway-id string Gateway ID + -h, --help Help for "stackit beta vpn connection describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn connection](./stackit_beta_vpn_connection.md) - Provides functionality for VPN connections + diff --git a/docs/stackit_beta_vpn_connection_list.md b/docs/stackit_beta_vpn_connection_list.md new file mode 100644 index 000000000..6f5bff9bb --- /dev/null +++ b/docs/stackit_beta_vpn_connection_list.md @@ -0,0 +1,41 @@ +## stackit beta vpn connection list + +Lists all VPN connections of a gateway + +### Synopsis + +Lists all VPN connections of a gateway. + +``` +stackit beta vpn connection list [flags] +``` + +### Examples + +``` + List all VPN connections of a gateway + $ stackit beta vpn connection list --gateway-id xxx +``` + +### Options + +``` + --gateway-id string Gateway ID + -h, --help Help for "stackit beta vpn connection list" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta vpn connection](./stackit_beta_vpn_connection.md) - Provides functionality for VPN connections + diff --git a/internal/cmd/beta/vpn/connection/connection.go b/internal/cmd/beta/vpn/connection/connection.go index ff2cf32d8..2975090fb 100644 --- a/internal/cmd/beta/vpn/connection/connection.go +++ b/internal/cmd/beta/vpn/connection/connection.go @@ -2,6 +2,7 @@ package connection import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection/create" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection/describe" diff --git a/internal/cmd/beta/vpn/connection/create/create.go b/internal/cmd/beta/vpn/connection/create/create.go index 540758262..70760dde9 100644 --- a/internal/cmd/beta/vpn/connection/create/create.go +++ b/internal/cmd/beta/vpn/connection/create/create.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/spf13/cobra" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/vpn/client" "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 ( diff --git a/internal/cmd/beta/vpn/connection/create/create_test.go b/internal/cmd/beta/vpn/connection/create/create_test.go index 485eb5c63..dbb931fec 100644 --- a/internal/cmd/beta/vpn/connection/create/create_test.go +++ b/internal/cmd/beta/vpn/connection/create/create_test.go @@ -11,12 +11,13 @@ import ( vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" "github.com/spf13/cobra" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" ) type testCtxKey struct{} diff --git a/internal/cmd/beta/vpn/connection/delete/delete.go b/internal/cmd/beta/vpn/connection/delete/delete.go index 21707c4c4..1e5de918e 100644 --- a/internal/cmd/beta/vpn/connection/delete/delete.go +++ b/internal/cmd/beta/vpn/connection/delete/delete.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/spf13/cobra" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +16,6 @@ import ( "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/types" - vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" ) const ( diff --git a/internal/cmd/beta/vpn/connection/delete/delete_test.go b/internal/cmd/beta/vpn/connection/delete/delete_test.go index 3633838b9..1d2eee692 100644 --- a/internal/cmd/beta/vpn/connection/delete/delete_test.go +++ b/internal/cmd/beta/vpn/connection/delete/delete_test.go @@ -9,9 +9,10 @@ import ( "github.com/google/uuid" vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" ) type testCtxKey struct{} diff --git a/internal/cmd/beta/vpn/connection/describe/describe.go b/internal/cmd/beta/vpn/connection/describe/describe.go index e59a89412..16f581821 100644 --- a/internal/cmd/beta/vpn/connection/describe/describe.go +++ b/internal/cmd/beta/vpn/connection/describe/describe.go @@ -6,6 +6,8 @@ import ( "strings" "github.com/spf13/cobra" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" ) const ( @@ -124,13 +125,13 @@ func outputResult(p *print.Printer, model *inputModel, resp *vpn.ConnectionRespo ts := []tables.Table{ mainTable, } - ts = append(ts, tunnelTables(resp.Tunnel1, "Tunnel 1")...) - ts = append(ts, tunnelTables(resp.Tunnel2, "Tunnel 2")...) + ts = append(ts, tunnelTables(&resp.Tunnel1, "Tunnel 1")...) + ts = append(ts, tunnelTables(&resp.Tunnel2, "Tunnel 2")...) return tables.DisplayTables(p, ts) }) } -func tunnelTables(tunnel vpn.TunnelConfiguration, title string) []tables.Table { +func tunnelTables(tunnel *vpn.TunnelConfiguration, title string) []tables.Table { table := tables.NewTable() table.SetTitle(title) table.AddRow("IP Address", tunnel.RemoteAddress) diff --git a/internal/cmd/beta/vpn/connection/describe/describe_test.go b/internal/cmd/beta/vpn/connection/describe/describe_test.go index af4a596a9..c9f42ef62 100644 --- a/internal/cmd/beta/vpn/connection/describe/describe_test.go +++ b/internal/cmd/beta/vpn/connection/describe/describe_test.go @@ -9,11 +9,12 @@ import ( "github.com/google/uuid" vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "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" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" ) type testCtxKey struct{} diff --git a/internal/cmd/beta/vpn/connection/list/list.go b/internal/cmd/beta/vpn/connection/list/list.go index 0bd2cfa32..02bb5356d 100644 --- a/internal/cmd/beta/vpn/connection/list/list.go +++ b/internal/cmd/beta/vpn/connection/list/list.go @@ -5,18 +5,18 @@ import ( "fmt" "github.com/spf13/cobra" + vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/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 ( @@ -62,13 +62,7 @@ func NewCmd(p *types.CmdParams) *cobra.Command { return fmt.Errorf("list VPN connections: %w", err) } - projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) - if err != nil { - p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - - return outputResult(p.Printer, model, projectLabel, resp) + return outputResult(p.Printer, model, resp) }, } configureFlags(cmd) @@ -102,7 +96,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *vpn.APIClie return req, nil } -func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *vpn.ConnectionList) error { +func outputResult(p *print.Printer, model *inputModel, resp *vpn.ConnectionList) error { if resp == nil || resp.Connections == nil { return fmt.Errorf("list connections response is empty") } diff --git a/internal/cmd/beta/vpn/connection/list/list_test.go b/internal/cmd/beta/vpn/connection/list/list_test.go index 1dbf641c7..4ddf3db73 100644 --- a/internal/cmd/beta/vpn/connection/list/list_test.go +++ b/internal/cmd/beta/vpn/connection/list/list_test.go @@ -10,12 +10,13 @@ import ( vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" "github.com/spf13/cobra" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" ) type testCtxKey struct{} @@ -197,7 +198,7 @@ func TestOutputResult(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { params := testparams.NewTestParams() - err := outputResult(params.Printer, tt.model, testProjectId, tt.resp) + err := outputResult(params.Printer, tt.model, tt.resp) if (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/internal/cmd/beta/vpn/vpn.go b/internal/cmd/beta/vpn/vpn.go index 46a5821c3..8ee353974 100644 --- a/internal/cmd/beta/vpn/vpn.go +++ b/internal/cmd/beta/vpn/vpn.go @@ -2,6 +2,7 @@ package vpn import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn/connection" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" diff --git a/internal/pkg/flags/string_enum_test.go b/internal/pkg/flags/string_enum_test.go index efe79fed2..a59f9b4ae 100644 --- a/internal/pkg/flags/string_enum_test.go +++ b/internal/pkg/flags/string_enum_test.go @@ -114,7 +114,7 @@ func TestStringEnumFlag_Register(t *testing.T) { flag := cmd.Flags().Lookup("my-flag") if flag == nil { - t.Errorf("Expected flag 'my-flag' to be registered") + t.Fatalf("Expected flag 'my-flag' to be registered") } if flag.Usage != "docs (possible values: [a, b])" { t.Errorf("Expected flag usage to be set correctly") diff --git a/internal/pkg/flags/string_enumslice_test.go b/internal/pkg/flags/string_enumslice_test.go index 2b292e15a..ff0be55c9 100644 --- a/internal/pkg/flags/string_enumslice_test.go +++ b/internal/pkg/flags/string_enumslice_test.go @@ -153,7 +153,7 @@ func TestStringEnumSliceFlag_Register(t *testing.T) { flag := cmd.Flags().Lookup("my-flag") if flag == nil { - t.Errorf("Expected flag 'my-flag' to be registered") + t.Fatalf("Expected flag 'my-flag' to be registered") } if flag.Usage != "docs (possible values: [a, b])" { t.Errorf("Expected flag usage to be set correctly") diff --git a/internal/pkg/services/vpn/client/client.go b/internal/pkg/services/vpn/client/client.go index 860c89b3c..9eee90830 100644 --- a/internal/pkg/services/vpn/client/client.go +++ b/internal/pkg/services/vpn/client/client.go @@ -2,10 +2,11 @@ package client import ( "github.com/spf13/viper" + 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" - vpn "github.com/stackitcloud/stackit-sdk-go/services/vpn/v1api" ) func ConfigureClient(p *print.Printer, cliVersion string) (*vpn.APIClient, error) {