From c3c4765443a0207d6a3877ec7a507ede11f167e6 Mon Sep 17 00:00:00 2001 From: Alexander Dahmen Date: Mon, 9 Dec 2024 12:48:19 +0100 Subject: [PATCH] Onboard IaaS server network interface commands (#519) Supported commands: attach, detach, list Signed-off-by: Alexander Dahmen --- docs/stackit_beta_server.md | 1 + docs/stackit_beta_server_network-interface.md | 35 ++ ...it_beta_server_network-interface_attach.md | 46 +++ ...it_beta_server_network-interface_detach.md | 46 +++ ...ckit_beta_server_network-interface_list.md | 47 +++ .../server/network-interface/attach/attach.go | 171 ++++++++++ .../network-interface/attach/attach_test.go | 311 ++++++++++++++++++ .../server/network-interface/detach/detach.go | 171 ++++++++++ .../network-interface/detach/detach_test.go | 311 ++++++++++++++++++ .../server/network-interface/list/list.go | 172 ++++++++++ .../network-interface/list/list_test.go | 211 ++++++++++++ .../network-interface/network-interface.go | 30 ++ internal/cmd/beta/server/server.go | 2 + internal/pkg/errors/errors.go | 20 ++ 14 files changed, 1574 insertions(+) create mode 100644 docs/stackit_beta_server_network-interface.md create mode 100644 docs/stackit_beta_server_network-interface_attach.md create mode 100644 docs/stackit_beta_server_network-interface_detach.md create mode 100644 docs/stackit_beta_server_network-interface_list.md create mode 100644 internal/cmd/beta/server/network-interface/attach/attach.go create mode 100644 internal/cmd/beta/server/network-interface/attach/attach_test.go create mode 100644 internal/cmd/beta/server/network-interface/detach/detach.go create mode 100644 internal/cmd/beta/server/network-interface/detach/detach_test.go create mode 100644 internal/cmd/beta/server/network-interface/list/list.go create mode 100644 internal/cmd/beta/server/network-interface/list/list_test.go create mode 100644 internal/cmd/beta/server/network-interface/network-interface.go diff --git a/docs/stackit_beta_server.md b/docs/stackit_beta_server.md index 5d244398..59943e5b 100644 --- a/docs/stackit_beta_server.md +++ b/docs/stackit_beta_server.md @@ -35,6 +35,7 @@ stackit beta server [flags] * [stackit beta server delete](./stackit_beta_server_delete.md) - Deletes a server * [stackit beta server describe](./stackit_beta_server_describe.md) - Shows details of a server * [stackit beta server list](./stackit_beta_server_list.md) - Lists all servers of a project +* [stackit beta server network-interface](./stackit_beta_server_network-interface.md) - Allows attaching/detaching network interfaces to servers * [stackit beta server public-ip](./stackit_beta_server_public-ip.md) - Allows attaching/detaching public IPs to servers * [stackit beta server update](./stackit_beta_server_update.md) - Updates a server * [stackit beta server volume](./stackit_beta_server_volume.md) - Provides functionality for server volumes diff --git a/docs/stackit_beta_server_network-interface.md b/docs/stackit_beta_server_network-interface.md new file mode 100644 index 00000000..53547f83 --- /dev/null +++ b/docs/stackit_beta_server_network-interface.md @@ -0,0 +1,35 @@ +## stackit beta server network-interface + +Allows attaching/detaching network interfaces to servers + +### Synopsis + +Allows attaching/detaching network interfaces to servers. + +``` +stackit beta server network-interface [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta server network-interface" +``` + +### 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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta server](./stackit_beta_server.md) - Provides functionality for servers +* [stackit beta server network-interface attach](./stackit_beta_server_network-interface_attach.md) - Attaches a network interface to a server +* [stackit beta server network-interface detach](./stackit_beta_server_network-interface_detach.md) - Detaches a network interface from a server +* [stackit beta server network-interface list](./stackit_beta_server_network-interface_list.md) - Lists all attached network interfaces of a server + diff --git a/docs/stackit_beta_server_network-interface_attach.md b/docs/stackit_beta_server_network-interface_attach.md new file mode 100644 index 00000000..e376d5c9 --- /dev/null +++ b/docs/stackit_beta_server_network-interface_attach.md @@ -0,0 +1,46 @@ +## stackit beta server network-interface attach + +Attaches a network interface to a server + +### Synopsis + +Attaches a network interface to a server. + +``` +stackit beta server network-interface attach [flags] +``` + +### Examples + +``` + Attach a network interface with ID "xxx" to a server with ID "yyy" + $ stackit beta server network-interface attach --network-interface-id xxx --server-id yyy + + Create a network interface for network with ID "xxx" and attach it to a server with ID "yyy" + $ stackit beta server network-interface attach --network-id xxx --server-id yyy --create +``` + +### Options + +``` + -b, --create If this is set a network interface will be created. (default false) + -h, --help Help for "stackit beta server network-interface attach" + --network-id string Network ID + --network-interface-id string Network Interface ID + --server-id string Server ID +``` + +### 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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta server network-interface](./stackit_beta_server_network-interface.md) - Allows attaching/detaching network interfaces to servers + diff --git a/docs/stackit_beta_server_network-interface_detach.md b/docs/stackit_beta_server_network-interface_detach.md new file mode 100644 index 00000000..e90a3165 --- /dev/null +++ b/docs/stackit_beta_server_network-interface_detach.md @@ -0,0 +1,46 @@ +## stackit beta server network-interface detach + +Detaches a network interface from a server + +### Synopsis + +Detaches a network interface from a server. + +``` +stackit beta server network-interface detach [flags] +``` + +### Examples + +``` + Detach a network interface with ID "xxx" from a server with ID "yyy" + $ stackit beta server network-interface detach --network-interface-id xxx --server-id yyy + + Detach and delete all network interfaces for network with ID "xxx" and detach them from a server with ID "yyy" + $ stackit beta server network-interface detach --network-id xxx --server-id yyy --delete +``` + +### Options + +``` + -b, --delete If this is set all network interfaces will be deleted. (default false) + -h, --help Help for "stackit beta server network-interface detach" + --network-id string Network ID + --network-interface-id string Network Interface ID + --server-id string Server ID +``` + +### 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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta server network-interface](./stackit_beta_server_network-interface.md) - Allows attaching/detaching network interfaces to servers + diff --git a/docs/stackit_beta_server_network-interface_list.md b/docs/stackit_beta_server_network-interface_list.md new file mode 100644 index 00000000..43337476 --- /dev/null +++ b/docs/stackit_beta_server_network-interface_list.md @@ -0,0 +1,47 @@ +## stackit beta server network-interface list + +Lists all attached network interfaces of a server + +### Synopsis + +Lists all attached network interfaces of a server. + +``` +stackit beta server network-interface list [flags] +``` + +### Examples + +``` + Lists all attached network interfaces of server with ID "xxx" + $ stackit beta server network-interface list --server-id xxx + + Lists all attached network interfaces of server with ID "xxx" in JSON format + $ stackit beta server network-interface list --server-id xxx --output-format json + + Lists up to 10 attached network interfaces of server with ID "xxx" + $ stackit beta server network-interface list --server-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta server network-interface list" + --limit int Maximum number of entries to list + --server-id string Server ID +``` + +### 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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta server network-interface](./stackit_beta_server_network-interface.md) - Allows attaching/detaching network interfaces to servers + diff --git a/internal/cmd/beta/server/network-interface/attach/attach.go b/internal/cmd/beta/server/network-interface/attach/attach.go new file mode 100644 index 00000000..1a2b45d4 --- /dev/null +++ b/internal/cmd/beta/server/network-interface/attach/attach.go @@ -0,0 +1,171 @@ +package attach + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + 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/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + serverIdFlag = "server-id" + networkInterfaceIdFlag = "network-interface-id" + createFlag = "create" + networkIdFlag = "network-id" + + defaultCreateFlag = false +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId *string + NicId *string + NetworkId *string + Create *bool +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "attach", + Short: "Attaches a network interface to a server", + Long: "Attaches a network interface to a server.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Attach a network interface with ID "xxx" to a server with ID "yyy"`, + `$ stackit beta server network-interface attach --network-interface-id xxx --server-id yyy`, + ), + examples.NewExample( + `Create a network interface for network with ID "xxx" and attach it to a server with ID "yyy"`, + `$ stackit beta server network-interface attach --network-id xxx --server-id yyy --create`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + if err != nil { + p.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = *model.ServerId + } + + // if the create flag is provided a network interface will be created and attached + if model.Create != nil && *model.Create { + networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, *model.NetworkId) + if err != nil { + p.Debug(print.ErrorLevel, "get network name: %v", err) + networkLabel = *model.NetworkId + } + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a network interface for network %q and attach it to server %q?", networkLabel, serverLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + // Call API + req := buildRequestCreateAndAttach(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("create and attach network interface: %w", err) + } + p.Info("Created a network interface for network %q and attached it to server %q\n", networkLabel, serverLabel) + return nil + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to attach network interface %q to server %q?", *model.NicId, serverLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + // Call API + req := buildRequestAttach(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("attach network interface: %w", err) + } + p.Info("Attached network interface %q to server %q\n", *model.NicId, serverLabel) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID") + cmd.Flags().Var(flags.UUIDFlag(), networkInterfaceIdFlag, "Network Interface ID") + cmd.Flags().Var(flags.UUIDFlag(), networkIdFlag, "Network ID") + cmd.Flags().BoolP(createFlag, "b", defaultCreateFlag, "If this is set a network interface will be created. (default false)") + + cmd.MarkFlagsRequiredTogether(createFlag, networkIdFlag) + cmd.MarkFlagsMutuallyExclusive(createFlag, networkInterfaceIdFlag) + cmd.MarkFlagsMutuallyExclusive(networkIdFlag, networkInterfaceIdFlag) + + err := flags.MarkFlagsRequired(cmd, serverIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + // if create is not provided then network-interface-id is needed + networkInterfaceId := flags.FlagToStringPointer(p, cmd, networkInterfaceIdFlag) + create := flags.FlagToBoolPointer(p, cmd, createFlag) + if create == nil && networkInterfaceId == nil { + return nil, &cliErr.ServerNicAttachMissingNicIdError{Cmd: cmd} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag), + NicId: networkInterfaceId, + Create: create, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequestAttach(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddNicToServerRequest { + return apiClient.AddNicToServer(ctx, model.ProjectId, *model.ServerId, *model.NicId) +} + +func buildRequestCreateAndAttach(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddNetworkToServerRequest { + return apiClient.AddNetworkToServer(ctx, model.ProjectId, *model.ServerId, *model.NetworkId) +} diff --git a/internal/cmd/beta/server/network-interface/attach/attach_test.go b/internal/cmd/beta/server/network-interface/attach/attach_test.go new file mode 100644 index 00000000..2adeff44 --- /dev/null +++ b/internal/cmd/beta/server/network-interface/attach/attach_test.go @@ -0,0 +1,311 @@ +package attach + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} +var testProjectId = uuid.NewString() +var testServerId = uuid.NewString() +var testNicId = uuid.NewString() +var testNetworkId = uuid.NewString() + +// contains nic id +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serverIdFlag: testServerId, + networkInterfaceIdFlag: testNicId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + ServerId: utils.Ptr(testServerId), + NicId: utils.Ptr(testNicId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequestAttach(mods ...func(request *iaas.ApiAddNicToServerRequest)) iaas.ApiAddNicToServerRequest { + request := testClient.AddNicToServer(testCtx, testProjectId, testServerId, testNicId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureRequestCreateAndAttach(mods ...func(request *iaas.ApiAddNetworkToServerRequest)) iaas.ApiAddNetworkToServerRequest { + request := testClient.AddNetworkToServer(testCtx, testProjectId, testServerId, testNetworkId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serverIdFlag) + }), + isValid: false, + }, + { + description: "server id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" + }), + isValid: false, + }, + { + description: "server id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + // only create + { + description: "provided flags invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[createFlag] = "true" + delete(flagValues, networkInterfaceIdFlag) + }), + isValid: false, + }, + // only network id + { + description: "provided flags invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkInterfaceIdFlag) + flagValues[networkIdFlag] = testNetworkId + }), + isValid: false, + }, + // create and nic id + { + description: "provided flags invalid 3", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[createFlag] = "true" + }), + isValid: false, + }, + // create and network id (valid) + { + description: "provided flags valid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[createFlag] = "true" + delete(flagValues, networkInterfaceIdFlag) + flagValues[networkIdFlag] = testNetworkId + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Create = utils.Ptr(true) + model.NicId = nil + model.NetworkId = utils.Ptr(testNetworkId) + }), + }, + // create, nic id and network id + { + description: "provided flags invalid 4", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[createFlag] = "true" + flagValues[networkIdFlag] = testNetworkId + }), + isValid: false, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Create = utils.Ptr(true) + model.NetworkId = utils.Ptr(testNetworkId) + }), + }, + // network id and nic id + { + description: "provided flags invalid 5", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkIdFlag] = testNetworkId + }), + isValid: false, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NetworkId = utils.Ptr(testNetworkId) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flag groups: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequestAttach(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiAddNicToServerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequestAttach(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequestAttach(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequestCreateAndAttach(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiAddNetworkToServerRequest + }{ + { + description: "base", + model: fixtureInputModel(func(model *inputModel) { + model.NicId = nil + model.NetworkId = utils.Ptr(testNetworkId) + model.Create = utils.Ptr(true) + }), + expectedRequest: fixtureRequestCreateAndAttach(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequestCreateAndAttach(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/server/network-interface/detach/detach.go b/internal/cmd/beta/server/network-interface/detach/detach.go new file mode 100644 index 00000000..fb11d12d --- /dev/null +++ b/internal/cmd/beta/server/network-interface/detach/detach.go @@ -0,0 +1,171 @@ +package detach + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + 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/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + serverIdFlag = "server-id" + networkInterfaceIdFlag = "network-interface-id" + networkIdFlag = "network-id" + deleteFlag = "delete" + + defaultDeleteFlag = false +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId *string + NicId *string + NetworkId *string + Delete *bool +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "detach", + Short: "Detaches a network interface from a server", + Long: "Detaches a network interface from a server.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Detach a network interface with ID "xxx" from a server with ID "yyy"`, + `$ stackit beta server network-interface detach --network-interface-id xxx --server-id yyy`, + ), + examples.NewExample( + `Detach and delete all network interfaces for network with ID "xxx" and detach them from a server with ID "yyy"`, + `$ stackit beta server network-interface detach --network-id xxx --server-id yyy --delete`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + if err != nil { + p.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = *model.ServerId + } + + // if the delete flag is provided a network interface is detached and deleted + if model.Delete != nil && *model.Delete { + networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, *model.NetworkId) + if err != nil { + p.Debug(print.ErrorLevel, "get network name: %v", err) + networkLabel = *model.NetworkId + } + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to detach and delete all network interfaces of network %q from server %q? (This cannot be undone)", networkLabel, serverLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + // Call API + req := buildRequestDetachAndDelete(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("detach and delete network interfaces: %w", err) + } + p.Info("Detached and deleted all network interfaces of network %q from server %q\n", networkLabel, serverLabel) + return nil + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to detach network interface %q from server %q?", *model.NicId, serverLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + // Call API + req := buildRequestDetach(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("detach network interface: %w", err) + } + p.Info("Detached network interface %q from server %q\n", *model.NicId, serverLabel) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID") + cmd.Flags().Var(flags.UUIDFlag(), networkInterfaceIdFlag, "Network Interface ID") + cmd.Flags().Var(flags.UUIDFlag(), networkIdFlag, "Network ID") + cmd.Flags().BoolP(deleteFlag, "b", defaultDeleteFlag, "If this is set all network interfaces will be deleted. (default false)") + + cmd.MarkFlagsRequiredTogether(deleteFlag, networkIdFlag) + cmd.MarkFlagsMutuallyExclusive(deleteFlag, networkInterfaceIdFlag) + cmd.MarkFlagsMutuallyExclusive(networkIdFlag, networkInterfaceIdFlag) + + err := flags.MarkFlagsRequired(cmd, serverIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + // if delete is not provided then network-interface-id is needed + networkInterfaceId := flags.FlagToStringPointer(p, cmd, networkInterfaceIdFlag) + deleteValue := flags.FlagToBoolPointer(p, cmd, deleteFlag) + if deleteValue == nil && networkInterfaceId == nil { + return nil, &cliErr.ServerNicDetachMissingNicIdError{Cmd: cmd} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag), + NicId: networkInterfaceId, + Delete: deleteValue, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequestDetach(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRemoveNicFromServerRequest { + return apiClient.RemoveNicFromServer(ctx, model.ProjectId, *model.ServerId, *model.NicId) +} + +func buildRequestDetachAndDelete(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRemoveNetworkFromServerRequest { + return apiClient.RemoveNetworkFromServer(ctx, model.ProjectId, *model.ServerId, *model.NetworkId) +} diff --git a/internal/cmd/beta/server/network-interface/detach/detach_test.go b/internal/cmd/beta/server/network-interface/detach/detach_test.go new file mode 100644 index 00000000..df408bbb --- /dev/null +++ b/internal/cmd/beta/server/network-interface/detach/detach_test.go @@ -0,0 +1,311 @@ +package detach + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} +var testProjectId = uuid.NewString() +var testServerId = uuid.NewString() +var testNicId = uuid.NewString() +var testNetworkId = uuid.NewString() + +// contains nic id +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serverIdFlag: testServerId, + networkInterfaceIdFlag: testNicId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + ServerId: utils.Ptr(testServerId), + NicId: utils.Ptr(testNicId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequestDetach(mods ...func(request *iaas.ApiRemoveNicFromServerRequest)) iaas.ApiRemoveNicFromServerRequest { + request := testClient.RemoveNicFromServer(testCtx, testProjectId, testServerId, testNicId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureRequestDetachAndDelete(mods ...func(request *iaas.ApiRemoveNetworkFromServerRequest)) iaas.ApiRemoveNetworkFromServerRequest { + request := testClient.RemoveNetworkFromServer(testCtx, testProjectId, testServerId, testNetworkId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serverIdFlag) + }), + isValid: false, + }, + { + description: "server id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" + }), + isValid: false, + }, + { + description: "server id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + // only delete + { + description: "provided flags invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[deleteFlag] = "true" + delete(flagValues, networkInterfaceIdFlag) + }), + isValid: false, + }, + // only network id + { + description: "provided flags invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkInterfaceIdFlag) + flagValues[networkIdFlag] = testNetworkId + }), + isValid: false, + }, + // delete and nic id + { + description: "provided flags invalid 3", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[deleteFlag] = "true" + }), + isValid: false, + }, + // delete and network id (valid) + { + description: "provided flags valid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[deleteFlag] = "true" + delete(flagValues, networkInterfaceIdFlag) + flagValues[networkIdFlag] = testNetworkId + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Delete = utils.Ptr(true) + model.NicId = nil + model.NetworkId = utils.Ptr(testNetworkId) + }), + }, + // delete, nic id and network id + { + description: "provided flags invalid 4", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[deleteFlag] = "true" + flagValues[networkIdFlag] = testNetworkId + }), + isValid: false, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Delete = utils.Ptr(true) + model.NetworkId = utils.Ptr(testNetworkId) + }), + }, + // network id and nic id + { + description: "provided flags invalid 5", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkIdFlag] = testNetworkId + }), + isValid: false, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NetworkId = utils.Ptr(testNetworkId) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flag groups: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequestDetach(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiRemoveNicFromServerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequestDetach(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequestDetach(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequestDetachAndDelete(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiRemoveNetworkFromServerRequest + }{ + { + description: "base", + model: fixtureInputModel(func(model *inputModel) { + model.NicId = nil + model.NetworkId = utils.Ptr(testNetworkId) + model.Delete = utils.Ptr(true) + }), + expectedRequest: fixtureRequestDetachAndDelete(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequestDetachAndDelete(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/server/network-interface/list/list.go b/internal/cmd/beta/server/network-interface/list/list.go new file mode 100644 index 00000000..307afdbc --- /dev/null +++ b/internal/cmd/beta/server/network-interface/list/list.go @@ -0,0 +1,172 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/spf13/cobra" +) + +const ( + serverIdFlag = "server-id" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId *string + Limit *int64 +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all attached network interfaces of a server", + Long: "Lists all attached network interfaces of a server.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all attached network interfaces of server with ID "xxx"`, + "$ stackit beta server network-interface list --server-id xxx", + ), + examples.NewExample( + `Lists all attached network interfaces of server with ID "xxx" in JSON format`, + "$ stackit beta server network-interface list --server-id xxx --output-format json", + ), + examples.NewExample( + `Lists up to 10 attached network interfaces of server with ID "xxx"`, + "$ stackit beta server network-interface list --server-id xxx --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list attached network interfaces: %w", err) + } + + if resp.Items == nil || len(*resp.Items) == 0 { + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + if err != nil { + p.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = *model.ServerId + } + p.Info("No attached network interfaces found for server %q\n", serverLabel) + return nil + } + + // Truncate output + items := *resp.Items + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(p, model.OutputFormat, *model.ServerId, items) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, serverIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + Limit: limit, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListServerNicsRequest { + return apiClient.ListServerNics(ctx, model.ProjectId, *model.ServerId) +} + +func outputResult(p *print.Printer, outputFormat, serverId string, serverNics []iaas.NIC) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(serverNics, "", " ") + if err != nil { + return fmt.Errorf("marshal server network interfaces: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(serverNics, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal server network interfaces: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("NIC ID", "SERVER ID") + + for i := range serverNics { + nic := serverNics[i] + table.AddRow(*nic.Id, serverId) + } + table.EnableAutoMergeOnColumns(2) + + p.Outputln(table.Render()) + return nil + } +} diff --git a/internal/cmd/beta/server/network-interface/list/list_test.go b/internal/cmd/beta/server/network-interface/list/list_test.go new file mode 100644 index 00000000..9f729b86 --- /dev/null +++ b/internal/cmd/beta/server/network-interface/list/list_test.go @@ -0,0 +1,211 @@ +package list + +// TODO: hier sind wir + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} +var testProjectId = uuid.NewString() +var testServerId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + serverIdFlag: testServerId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + ServerId: utils.Ptr(testServerId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListServerNicsRequest)) iaas.ApiListServerNicsRequest { + request := testClient.ListServerNics(testCtx, testProjectId, testServerId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serverIdFlag) + }), + isValid: false, + }, + { + description: "server id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" + }), + isValid: false, + }, + { + description: "server id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListServerNicsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/server/network-interface/network-interface.go b/internal/cmd/beta/server/network-interface/network-interface.go new file mode 100644 index 00000000..88fc744d --- /dev/null +++ b/internal/cmd/beta/server/network-interface/network-interface.go @@ -0,0 +1,30 @@ +package networkinterface + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/network-interface/attach" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/network-interface/detach" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/network-interface/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "network-interface", + Short: "Allows attaching/detaching network interfaces to servers", + Long: "Allows attaching/detaching network interfaces to servers.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(attach.NewCmd(p)) + cmd.AddCommand(list.NewCmd(p)) + cmd.AddCommand(detach.NewCmd(p)) +} diff --git a/internal/cmd/beta/server/server.go b/internal/cmd/beta/server/server.go index 4b8ba0fd..90aae142 100644 --- a/internal/cmd/beta/server/server.go +++ b/internal/cmd/beta/server/server.go @@ -7,6 +7,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/list" + networkinterface "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/network-interface" publicip "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/public-ip" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/update" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/volume" @@ -39,4 +40,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(publicip.NewCmd(p)) cmd.AddCommand(update.NewCmd(p)) cmd.AddCommand(volume.NewCmd(p)) + cmd.AddCommand(networkinterface.NewCmd(p)) } diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 38aaa083..324abbad 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -146,8 +146,28 @@ To enable it, run: IAAS_SERVER_MISSING_VOLUME_TYPE = `The "boot-volume-source-type" flag must be provided together with "boot-volume-source-id" flag.` IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS = `Either "image-id" or "boot-volume-source-type" and "boot-volume-source-id" flags must be provided.` + + IAAS_SERVER_NIC_ATTACH_MISSING_NIC_ID = `The "network-interface-id" flag must be provided if the "create" flag is not provided.` + + IAAS_SERVER_NIC_DETACH_MISSING_NIC_ID = `The "network-interface-id" flag must be provided if the "delete" flag is not provided.` ) +type ServerNicAttachMissingNicIdError struct { + Cmd *cobra.Command +} + +func (e *ServerNicAttachMissingNicIdError) Error() string { + return IAAS_SERVER_NIC_ATTACH_MISSING_NIC_ID +} + +type ServerNicDetachMissingNicIdError struct { + Cmd *cobra.Command +} + +func (e *ServerNicDetachMissingNicIdError) Error() string { + return IAAS_SERVER_NIC_DETACH_MISSING_NIC_ID +} + type ServerCreateMissingVolumeIdError struct { Cmd *cobra.Command }