diff --git a/docs/stackit_beta_server.md b/docs/stackit_beta_server.md index 2c96d2c32..4734979b3 100644 --- a/docs/stackit_beta_server.md +++ b/docs/stackit_beta_server.md @@ -36,4 +36,5 @@ stackit beta server [flags] * [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 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_volume.md b/docs/stackit_beta_server_volume.md new file mode 100644 index 000000000..f2692f63f --- /dev/null +++ b/docs/stackit_beta_server_volume.md @@ -0,0 +1,37 @@ +## stackit beta server volume + +Provides functionality for Server volumes + +### Synopsis + +Provides functionality for Server volumes. + +``` +stackit beta server volume [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta server volume" +``` + +### 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 Server +* [stackit beta server volume attach](./stackit_beta_server_volume_attach.md) - Attaches a volume to a server +* [stackit beta server volume describe](./stackit_beta_server_volume_describe.md) - Describes a server volume attachment +* [stackit beta server volume detach](./stackit_beta_server_volume_detach.md) - Detaches a volume from a server +* [stackit beta server volume list](./stackit_beta_server_volume_list.md) - Lists all server volumes +* [stackit beta server volume update](./stackit_beta_server_volume_update.md) - Updates an attached volume of a server + diff --git a/docs/stackit_beta_server_volume_attach.md b/docs/stackit_beta_server_volume_attach.md new file mode 100644 index 000000000..43dcb6089 --- /dev/null +++ b/docs/stackit_beta_server_volume_attach.md @@ -0,0 +1,44 @@ +## stackit beta server volume attach + +Attaches a volume to a server + +### Synopsis + +Attaches a volume to a server. + +``` +stackit beta server volume attach [flags] +``` + +### Examples + +``` + Attach a volume with ID "xxx" to a server with ID "yyy" + $ stackit beta server volume attach xxx --server-id yyy + + Attach a volume with ID "xxx" to a server with ID "yyy" and enable deletion on termination + $ stackit beta server volume attach xxx --server-id yyy --delete-on-termination +``` + +### Options + +``` + -b, --delete-on-termination Delete the volume during the termination of the server. (default false) + -h, --help Help for "stackit beta server volume attach" + --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 volume](./stackit_beta_server_volume.md) - Provides functionality for Server volumes + diff --git a/docs/stackit_beta_server_volume_describe.md b/docs/stackit_beta_server_volume_describe.md new file mode 100644 index 000000000..175931b86 --- /dev/null +++ b/docs/stackit_beta_server_volume_describe.md @@ -0,0 +1,46 @@ +## stackit beta server volume describe + +Describes a server volume attachment + +### Synopsis + +Describes a server volume attachment. + +``` +stackit beta server volume describe [flags] +``` + +### Examples + +``` + Get details of the attachment of volume with ID "xxx" to server with ID "yyy" + $ stackit beta server volume describe xxx --server-id yyy + + Get details of the attachment of volume with ID "xxx" to server with ID "yyy" in JSON format + $ stackit beta server volume describe xxx --server-id yyy --output-format json + + Get details of the attachment of volume with ID "xxx" to server with ID "yyy" in yaml format + $ stackit beta server volume describe xxx --server-id yyy --output-format yaml +``` + +### Options + +``` + -h, --help Help for "stackit beta server volume describe" + --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 volume](./stackit_beta_server_volume.md) - Provides functionality for Server volumes + diff --git a/docs/stackit_beta_server_volume_detach.md b/docs/stackit_beta_server_volume_detach.md new file mode 100644 index 000000000..5422aab8a --- /dev/null +++ b/docs/stackit_beta_server_volume_detach.md @@ -0,0 +1,40 @@ +## stackit beta server volume detach + +Detaches a volume from a server + +### Synopsis + +Detaches a volume from a server. + +``` +stackit beta server volume detach [flags] +``` + +### Examples + +``` + Detaches a volume with ID "xxx" from a server with ID "yyy" + $ stackit beta server volume detach xxx --server-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta server volume detach" + --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 volume](./stackit_beta_server_volume.md) - Provides functionality for Server volumes + diff --git a/docs/stackit_beta_server_volume_list.md b/docs/stackit_beta_server_volume_list.md new file mode 100644 index 000000000..68cf83bbc --- /dev/null +++ b/docs/stackit_beta_server_volume_list.md @@ -0,0 +1,43 @@ +## stackit beta server volume list + +Lists all server volumes + +### Synopsis + +Lists all server volumes. + +``` +stackit beta server volume list [flags] +``` + +### Examples + +``` + List all volumes for a server with ID "xxx" + $ stackit beta server volume list --server-id xxx + + List all volumes for a server with ID "xxx" in JSON format + $ stackit beta server volumes list --server-id xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta server volume list" + -s, --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 volume](./stackit_beta_server_volume.md) - Provides functionality for Server volumes + diff --git a/docs/stackit_beta_server_volume_update.md b/docs/stackit_beta_server_volume_update.md new file mode 100644 index 000000000..134cb81a4 --- /dev/null +++ b/docs/stackit_beta_server_volume_update.md @@ -0,0 +1,41 @@ +## stackit beta server volume update + +Updates an attached volume of a server + +### Synopsis + +Updates an attached volume of a server. + +``` +stackit beta server volume update [flags] +``` + +### Examples + +``` + Update a volume with ID "xxx" of a server with ID "yyy" and enables delete on termination + $ stackit beta server volume update xxx --server-id yyy --delete-on-termination +``` + +### Options + +``` + -b, --delete-on-termination Delete the volume during the termination of the server. (default false) + -h, --help Help for "stackit beta server volume update" + --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 volume](./stackit_beta_server_volume.md) - Provides functionality for Server volumes + diff --git a/internal/cmd/beta/server/server.go b/internal/cmd/beta/server/server.go index d3debf967..7f205988f 100644 --- a/internal/cmd/beta/server/server.go +++ b/internal/cmd/beta/server/server.go @@ -8,6 +8,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/list" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/update" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/volume" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -35,4 +36,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(describe.NewCmd(p)) cmd.AddCommand(list.NewCmd(p)) cmd.AddCommand(update.NewCmd(p)) + cmd.AddCommand(volume.NewCmd(p)) } diff --git a/internal/cmd/beta/server/volume/attach/attach.go b/internal/cmd/beta/server/volume/attach/attach.go new file mode 100644 index 000000000..c417265d7 --- /dev/null +++ b/internal/cmd/beta/server/volume/attach/attach.go @@ -0,0 +1,168 @@ +package attach + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + volumeIdArg = "VOLUME_ID" + + serverIdFlag = "server-id" + deleteOnTerminationFlag = "delete-on-termination" + + defaultDeleteOnTermination = false +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId *string + VolumeId string + DeleteOnTermination *bool +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "attach", + Short: "Attaches a volume to a server", + Long: "Attaches a volume to a server.", + Args: args.SingleArg(volumeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Attach a volume with ID "xxx" to a server with ID "yyy"`, + `$ stackit beta server volume attach xxx --server-id yyy`, + ), + examples.NewExample( + `Attach a volume with ID "xxx" to a server with ID "yyy" and enable deletion on termination`, + `$ stackit beta server volume attach xxx --server-id yyy --delete-on-termination`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + if err != nil { + p.Debug(print.ErrorLevel, "get volume name: %v", err) + volumeLabel = model.VolumeId + } + if volumeLabel == "" { + volumeLabel = model.VolumeId + } + + 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 !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to attach volume %q to server %q?", volumeLabel, serverLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("attach server volume: %w", err) + } + + return outputResult(p, model.OutputFormat, volumeLabel, serverLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID") + cmd.Flags().BoolP(deleteOnTerminationFlag, "b", defaultDeleteOnTermination, "Delete the volume during the termination of the server. (default false)") + + err := flags.MarkFlagsRequired(cmd, serverIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + volumeId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + DeleteOnTermination: flags.FlagToBoolPointer(p, cmd, deleteOnTerminationFlag), + VolumeId: volumeId, + } + + 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.ApiAddVolumeToServerRequest { + req := apiClient.AddVolumeToServer(ctx, model.ProjectId, *model.ServerId, model.VolumeId) + payload := iaas.AddVolumeToServerPayload{ + DeleteOnTermination: model.DeleteOnTermination, + } + return req.AddVolumeToServerPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat, volumeLabel, serverLabel string, volume *iaas.VolumeAttachment) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(volume, "", " ") + if err != nil { + return fmt.Errorf("marshal server volume: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal server volume: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Attached volume %q to server %q\n", volumeLabel, serverLabel) + return nil + } +} diff --git a/internal/cmd/beta/server/volume/attach/attach_test.go b/internal/cmd/beta/server/volume/attach/attach_test.go new file mode 100644 index 000000000..9dd33f77f --- /dev/null +++ b/internal/cmd/beta/server/volume/attach/attach_test.go @@ -0,0 +1,251 @@ +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 testVolumeId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testVolumeId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serverIdFlag: testServerId, + deleteOnTerminationFlag: "true", + } + 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), + VolumeId: testVolumeId, + DeleteOnTermination: utils.Ptr(true), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixturePayload(mods ...func(payload *iaas.AddVolumeToServerPayload)) iaas.AddVolumeToServerPayload { + payload := iaas.AddVolumeToServerPayload{ + DeleteOnTermination: utils.Ptr(true), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *iaas.ApiAddVolumeToServerRequest)) iaas.ApiAddVolumeToServerRequest { + request := testClient.AddVolumeToServer(testCtx, testProjectId, testServerId, testVolumeId) + request = request.AddVolumeToServerPayload(fixturePayload()) + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serverIdFlag) + }), + isValid: false, + }, + { + description: "server id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" + }), + isValid: false, + }, + { + description: "server id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "volume id argument missing", + argValues: []string{}, + isValid: false, + }, + { + description: "required only", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, deleteOnTerminationFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DeleteOnTermination = nil + }), + isValid: true, + }, + } + + 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + 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.ApiAddVolumeToServerRequest + }{ + { + 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/volume/describe/describe.go b/internal/cmd/beta/server/volume/describe/describe.go new file mode 100644 index 000000000..94fa6dd82 --- /dev/null +++ b/internal/cmd/beta/server/volume/describe/describe.go @@ -0,0 +1,170 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/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-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + volumeIdArg = "VOLUME_ID" + serverIdFlag = "server-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId *string + VolumeId string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describes a server volume attachment", + Long: "Describes a server volume attachment.", + Args: args.SingleArg(volumeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of the attachment of volume with ID "xxx" to server with ID "yyy"`, + `$ stackit beta server volume describe xxx --server-id yyy`, + ), + examples.NewExample( + `Get details of the attachment of volume with ID "xxx" to server with ID "yyy" in JSON format`, + `$ stackit beta server volume describe xxx --server-id yyy --output-format json`, + ), + examples.NewExample( + `Get details of the attachment of volume with ID "xxx" to server with ID "yyy" in yaml format`, + `$ stackit beta server volume describe xxx --server-id yyy --output-format yaml`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + if err != nil { + p.Debug(print.ErrorLevel, "get volume name: %v", err) + volumeLabel = model.VolumeId + } + + 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 + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe server volume: %w", err) + } + + return outputResult(p, model.OutputFormat, serverLabel, volumeLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID") + + err := flags.MarkFlagsRequired(cmd, serverIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + volumeId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + VolumeId: volumeId, + } + + 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.ApiGetAttachedVolumeRequest { + req := apiClient.GetAttachedVolume(ctx, model.ProjectId, *model.ServerId, model.VolumeId) + return req +} + +func outputResult(p *print.Printer, outputFormat, serverLabel, volumeLabel string, volume *iaas.VolumeAttachment) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(volume, "", " ") + if err != nil { + return fmt.Errorf("marshal server volume: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal server volume: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.AddRow("SERVER ID", *volume.ServerId) + table.AddSeparator() + table.AddRow("SERVER NAME", serverLabel) + table.AddSeparator() + table.AddRow("VOLUME ID", *volume.VolumeId) + table.AddSeparator() + // check if name is set + if volumeLabel != "" { + table.AddRow("VOLUME NAME", volumeLabel) + table.AddSeparator() + } + table.AddRow("DELETE ON TERMINATION", *volume.DeleteOnTermination) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + } +} diff --git a/internal/cmd/beta/server/volume/describe/describe_test.go b/internal/cmd/beta/server/volume/describe/describe_test.go new file mode 100644 index 000000000..1d53423de --- /dev/null +++ b/internal/cmd/beta/server/volume/describe/describe_test.go @@ -0,0 +1,227 @@ +package describe + +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 testVolumeId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testVolumeId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + 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, + }, + ServerId: utils.Ptr(testServerId), + VolumeId: testVolumeId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetAttachedVolumeRequest)) iaas.ApiGetAttachedVolumeRequest { + request := testClient.GetAttachedVolume(testCtx, testProjectId, testServerId, testVolumeId) + 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: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serverIdFlag) + }), + isValid: false, + }, + { + description: "server id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" + }), + isValid: false, + }, + { + description: "server id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "volume id argument missing", + argValues: []string{}, + 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + 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.ApiGetAttachedVolumeRequest + }{ + { + 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/volume/detach/detach.go b/internal/cmd/beta/server/volume/detach/detach.go new file mode 100644 index 000000000..6e80517f6 --- /dev/null +++ b/internal/cmd/beta/server/volume/detach/detach.go @@ -0,0 +1,130 @@ +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" + "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/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + volumeIdArg = "VOLUME_ID" + + serverIdFlag = "server-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId *string + VolumeId string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "detach", + Short: "Detaches a volume from a server", + Long: "Detaches a volume from a server.", + Args: args.SingleArg(volumeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Detaches a volume with ID "xxx" from a server with ID "yyy"`, + `$ stackit beta server volume detach xxx --server-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + if err != nil { + p.Debug(print.ErrorLevel, "get volume name: %v", err) + volumeLabel = model.VolumeId + } + if volumeLabel == "" { + volumeLabel = model.VolumeId + } + + 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 !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to detach volume %q from server %q?", volumeLabel, serverLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err := req.Execute(); err != nil { + return fmt.Errorf("detach server volume: %w", err) + } + + p.Info("Detached volume %q from server %q\n", volumeLabel, serverLabel) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID") + + err := flags.MarkFlagsRequired(cmd, serverIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + volumeId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + VolumeId: volumeId, + } + + 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.ApiRemoveVolumeFromServerRequest { + req := apiClient.RemoveVolumeFromServer(ctx, model.ProjectId, *model.ServerId, model.VolumeId) + return req +} diff --git a/internal/cmd/beta/server/volume/detach/detach_test.go b/internal/cmd/beta/server/volume/detach/detach_test.go new file mode 100644 index 000000000..dd2032277 --- /dev/null +++ b/internal/cmd/beta/server/volume/detach/detach_test.go @@ -0,0 +1,226 @@ +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 testVolumeId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testVolumeId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + 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, + }, + ServerId: utils.Ptr(testServerId), + VolumeId: testVolumeId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiRemoveVolumeFromServerRequest)) iaas.ApiRemoveVolumeFromServerRequest { + request := testClient.RemoveVolumeFromServer(testCtx, testProjectId, testServerId, testVolumeId) + 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", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serverIdFlag) + }), + isValid: false, + }, + { + description: "server id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" + }), + isValid: false, + }, + { + description: "server id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "volume id argument missing", + argValues: []string{}, + 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + 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.ApiRemoveVolumeFromServerRequest + }{ + { + 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/volume/list/list.go b/internal/cmd/beta/server/volume/list/list.go new file mode 100644 index 000000000..f09ddf5dc --- /dev/null +++ b/internal/cmd/beta/server/volume/list/list.go @@ -0,0 +1,161 @@ +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" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId *string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all server volumes", + Long: "Lists all server volumes.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all volumes for a server with ID "xxx"`, + "$ stackit beta server volume list --server-id xxx"), + examples.NewExample( + `List all volumes for a server with ID "xxx" in JSON format`, + "$ stackit beta server volumes list --server-id xxx --output-format json"), + ), + 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 + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list server volumes: %w", err) + } + volumes := *resp.Items + if len(volumes) == 0 { + p.Info("No volumes found for server %s\n", *model.ServerId) + return nil + } + + // get volume names + var volumeNames []string + for i := range volumes { + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, *volumes[i].VolumeId) + if err != nil { + p.Debug(print.ErrorLevel, "get volume name: %v", err) + } + volumeNames = append(volumeNames, volumeLabel) + } + + return outputResult(p, model.OutputFormat, serverLabel, volumeNames, volumes) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID") + + 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{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + } + + 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.ApiListAttachedVolumesRequest { + req := apiClient.ListAttachedVolumes(ctx, model.ProjectId, *model.ServerId) + return req +} + +func outputResult(p *print.Printer, outputFormat, serverLabel string, volumeNames []string, volumes []iaas.VolumeAttachment) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(volumes, "", " ") + if err != nil { + return fmt.Errorf("marshal server volume list: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(volumes, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal server volume list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("SERVER ID", "SERVER NAME", "VOLUME ID", "VOLUME NAME") + for i := range volumes { + s := volumes[i] + table.AddRow(*s.ServerId, serverLabel, *s.VolumeId, volumeNames[i]) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + } +} diff --git a/internal/cmd/beta/server/volume/list/list_test.go b/internal/cmd/beta/server/volume/list/list_test.go new file mode 100644 index 000000000..288c65fd6 --- /dev/null +++ b/internal/cmd/beta/server/volume/list/list_test.go @@ -0,0 +1,193 @@ +package list + +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, + 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, + }, + ServerId: utils.Ptr(testServerId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListAttachedVolumesRequest)) iaas.ApiListAttachedVolumesRequest { + request := testClient.ListAttachedVolumes(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, + }, + } + + 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.ApiListAttachedVolumesRequest + }{ + { + 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/volume/update/update.go b/internal/cmd/beta/server/volume/update/update.go new file mode 100644 index 000000000..5ab7d58de --- /dev/null +++ b/internal/cmd/beta/server/volume/update/update.go @@ -0,0 +1,164 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + volumeIdArg = "VOLUME_ID" + + serverIdFlag = "server-id" + deleteOnTerminationFlag = "delete-on-termination" + + defaultDeleteOnTermination = false +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId *string + VolumeId string + DeleteOnTermination *bool +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates an attached volume of a server", + Long: "Updates an attached volume of a server.", + Args: args.SingleArg(volumeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update a volume with ID "xxx" of a server with ID "yyy" and enables delete on termination`, + `$ stackit beta server volume update xxx --server-id yyy --delete-on-termination`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + if err != nil { + p.Debug(print.ErrorLevel, "get volume name: %v", err) + volumeLabel = model.VolumeId + } + if volumeLabel == "" { + volumeLabel = model.VolumeId + } + + 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 !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update attached volume %q of server %q?", volumeLabel, serverLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update server volume: %w", err) + } + + return outputResult(p, model.OutputFormat, volumeLabel, serverLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID") + cmd.Flags().BoolP(deleteOnTerminationFlag, "b", defaultDeleteOnTermination, "Delete the volume during the termination of the server. (default false)") + + err := flags.MarkFlagsRequired(cmd, serverIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + volumeId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + DeleteOnTermination: flags.FlagToBoolPointer(p, cmd, deleteOnTerminationFlag), + VolumeId: volumeId, + } + + 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.ApiUpdateAttachedVolumeRequest { + req := apiClient.UpdateAttachedVolume(ctx, model.ProjectId, *model.ServerId, model.VolumeId) + payload := iaas.UpdateAttachedVolumePayload{ + DeleteOnTermination: model.DeleteOnTermination, + } + return req.UpdateAttachedVolumePayload(payload) +} + +func outputResult(p *print.Printer, outputFormat, volumeLabel, serverLabel string, volume *iaas.VolumeAttachment) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(volume, "", " ") + if err != nil { + return fmt.Errorf("marshal server volume: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal server volume: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Updated attached volume %q of server %q\n", volumeLabel, serverLabel) + return nil + } +} diff --git a/internal/cmd/beta/server/volume/update/update_test.go b/internal/cmd/beta/server/volume/update/update_test.go new file mode 100644 index 000000000..5d8171fbf --- /dev/null +++ b/internal/cmd/beta/server/volume/update/update_test.go @@ -0,0 +1,250 @@ +package update + +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 testVolumeId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testVolumeId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serverIdFlag: testServerId, + deleteOnTerminationFlag: "true", + } + 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), + VolumeId: testVolumeId, + DeleteOnTermination: utils.Ptr(true), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixturePayload(mods ...func(payload *iaas.UpdateAttachedVolumePayload)) iaas.UpdateAttachedVolumePayload { + payload := iaas.UpdateAttachedVolumePayload{ + DeleteOnTermination: utils.Ptr(true), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *iaas.ApiUpdateAttachedVolumeRequest)) iaas.ApiUpdateAttachedVolumeRequest { + request := testClient.UpdateAttachedVolume(testCtx, testProjectId, testServerId, testVolumeId) + request = request.UpdateAttachedVolumePayload(fixturePayload()) + 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", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serverIdFlag) + }), + isValid: false, + }, + { + description: "server id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" + }), + isValid: false, + }, + { + description: "server id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "required only", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, deleteOnTerminationFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DeleteOnTermination = nil + }), + isValid: true, + }, + { + description: "volume id argument missing", + argValues: []string{}, + 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + 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.ApiUpdateAttachedVolumeRequest + }{ + { + 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/volume/volume.go b/internal/cmd/beta/server/volume/volume.go new file mode 100644 index 000000000..7f8656021 --- /dev/null +++ b/internal/cmd/beta/server/volume/volume.go @@ -0,0 +1,34 @@ +package volume + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/volume/attach" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/volume/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/volume/detach" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/volume/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/server/volume/update" + "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: "volume", + Short: "Provides functionality for Server volumes", + Long: "Provides functionality for Server volumes.", + 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(detach.NewCmd(p)) + cmd.AddCommand(update.NewCmd(p)) + cmd.AddCommand(list.NewCmd(p)) + cmd.AddCommand(describe.NewCmd(p)) +}