From d08d7ceadb120ce6a89887a086cb5c0c69e8a9e5 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Mon, 2 Jun 2025 20:14:53 +0200 Subject: [PATCH 01/27] add snapshot basic structure --- internal/cmd/volume/snapshot/snapshot.go | 33 ++++++++++++++++++++++++ internal/cmd/volume/volume.go | 4 +-- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/volume/snapshot/snapshot.go diff --git a/internal/cmd/volume/snapshot/snapshot.go b/internal/cmd/volume/snapshot/snapshot.go new file mode 100644 index 000000000..9656b1465 --- /dev/null +++ b/internal/cmd/volume/snapshot/snapshot.go @@ -0,0 +1,33 @@ +package snapshot + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "snapshot", + Short: "Provides functionality for snapshots", + Long: "Provides functionality for snapshots.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/volume/volume.go b/internal/cmd/volume/volume.go index d5cad1614..9fbe0ad33 100644 --- a/internal/cmd/volume/volume.go +++ b/internal/cmd/volume/volume.go @@ -2,13 +2,13 @@ package volume import ( "github.com/stackitcloud/stackit-cli/internal/cmd/params" - "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/create" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/list" performanceclass "github.com/stackitcloud/stackit-cli/internal/cmd/volume/performance-class" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/resize" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -36,5 +36,5 @@ func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(update.NewCmd(params)) cmd.AddCommand(resize.NewCmd(params)) cmd.AddCommand(performanceclass.NewCmd(params)) - cmd.AddCommand(backup.NewCmd(params)) + cmd.AddCommand(snapshot.NewCmd(params)) } From 6deeb6195e4ae8dfe21848bd92c557fa340096a7 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Mon, 2 Jun 2025 20:15:16 +0200 Subject: [PATCH 02/27] add snapshot create subcommand --- internal/cmd/volume/snapshot/create/create.go | 186 ++++++++++++++++ .../cmd/volume/snapshot/create/create_test.go | 209 ++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 internal/cmd/volume/snapshot/create/create.go create mode 100644 internal/cmd/volume/snapshot/create/create_test.go diff --git a/internal/cmd/volume/snapshot/create/create.go b/internal/cmd/volume/snapshot/create/create.go new file mode 100644 index 000000000..b9f3b035a --- /dev/null +++ b/internal/cmd/volume/snapshot/create/create.go @@ -0,0 +1,186 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" +) + +const ( + volumeIdFlag = "volume-id" + nameFlag = "name" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + VolumeID string + Name *string + Labels map[string]string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a snapshot from a volume", + Long: "Creates a snapshot from a volume.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a snapshot from a volume`, + "$ stackit volume snapshot create --volume-id xxx --project-id xxx"), + examples.NewExample( + `Create a snapshot with a name`, + "$ stackit volume snapshot create --volume-id xxx --name my-snapshot --project-id xxx"), + examples.NewExample( + `Create a snapshot with labels`, + "$ stackit volume snapshot create --volume-id xxx --labels key1=value1,key2=value2 --project-id xxx"), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Get volume name for label + volumeLabel := model.VolumeID + volume, err := apiClient.GetVolume(ctx, model.ProjectId, model.VolumeID).Execute() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) + } else if volume != nil && volume.Name != nil { + volumeLabel = *volume.Name + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create snapshot from volume %q? (This cannot be undone)", volumeLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create snapshot: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Creating snapshot") + resp, err = wait.CreateSnapshotWaitHandler(ctx, apiClient, model.ProjectId, *resp.Id).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for snapshot creation: %w", err) + } + s.Stop() + } + + if model.Async { + params.Printer.Info("Triggered snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, *resp.Id) + } else { + params.Printer.Info("Created snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, *resp.Id) + } + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(volumeIdFlag, "", "ID of the volume from which a snapshot should be created") + cmd.Flags().String(nameFlag, "", "Name of the snapshot") + cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") + + err := flags.MarkFlagsRequired(cmd, volumeIdFlag) + 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{} + } + + volumeID := flags.FlagToStringValue(p, cmd, volumeIdFlag) + if volumeID == "" { + return nil, fmt.Errorf("volume-id is required") + } + if err := utils.ValidateUUID(volumeID); err != nil { + return nil, fmt.Errorf("volume-id must be a valid UUID") + } + + name := flags.FlagToStringPointer(p, cmd, nameFlag) + labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) + if labels == nil { + labels = &map[string]string{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + VolumeID: volumeID, + Name: name, + Labels: *labels, + } + + 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.ApiCreateSnapshotRequest { + req := apiClient.CreateSnapshot(ctx, model.ProjectId) + payload := iaas.NewCreateSnapshotPayloadWithDefaults() + payload.VolumeId = &model.VolumeID + payload.Name = model.Name + + // Convert labels to map[string]interface{} + if len(model.Labels) > 0 { + labelsMap := map[string]interface{}{} + for k, v := range model.Labels { + labelsMap[k] = v + } + payload.Labels = &labelsMap + } + + req = req.CreateSnapshotPayload(*payload) + return req +} diff --git a/internal/cmd/volume/snapshot/create/create_test.go b/internal/cmd/volume/snapshot/create/create_test.go new file mode 100644 index 000000000..1c151437d --- /dev/null +++ b/internal/cmd/volume/snapshot/create/create_test.go @@ -0,0 +1,209 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "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" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testVolumeId = uuid.NewString() + testName = "test-snapshot" + testLabels = map[string]string{"key1": "value1"} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + volumeIdFlag: testVolumeId, + nameFlag: testName, + labelsFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + VolumeID: testVolumeId, + Name: &testName, + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiCreateSnapshotRequest)) iaas.ApiCreateSnapshotRequest { + request := testClient.CreateSnapshot(testCtx, testProjectId) + payload := iaas.NewCreateSnapshotPayloadWithDefaults() + payload.VolumeId = &testVolumeId + payload.Name = &testName + + // Convert test labels to map[string]interface{} + labelsMap := map[string]interface{}{} + for k, v := range testLabels { + labelsMap[k] = v + } + payload.Labels = &labelsMap + + request = request.CreateSnapshotPayload(*payload) + 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: "no volume id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, volumeIdFlag) + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "volume id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[volumeIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "only required flags", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + delete(flagValues, labelsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + model.Labels = make(map[string]string) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: 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.ApiCreateSnapshotRequest + }{ + { + 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) + } + }) + } +} From 618e9a43465c5196e08b4769c0c621d1d616d68c Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Mon, 2 Jun 2025 20:30:36 +0200 Subject: [PATCH 03/27] add snapshot list subcommand --- internal/cmd/volume/snapshot/list/list.go | 188 ++++++++++++++ .../cmd/volume/snapshot/list/list_test.go | 240 ++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 internal/cmd/volume/snapshot/list/list.go create mode 100644 internal/cmd/volume/snapshot/list/list_test.go diff --git a/internal/cmd/volume/snapshot/list/list.go b/internal/cmd/volume/snapshot/list/list.go new file mode 100644 index 000000000..dba12f5b3 --- /dev/null +++ b/internal/cmd/volume/snapshot/list/list.go @@ -0,0 +1,188 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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" + "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 ( + limitFlag = "limit" + labelSelectorFlag = "label-selector" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 + LabelSelector *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all snapshots", + Long: "Lists all snapshots in a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all snapshots`, + "$ stackit volume snapshot list"), + examples.NewExample( + `List snapshots with a limit of 10`, + "$ stackit volume snapshot list --limit 10"), + examples.NewExample( + `List snapshots filtered by label`, + "$ stackit volume snapshot list --label-selector key1=value1"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list snapshots: %w", err) + } + + // Filter results by label selector + snapshots := *resp.Items + if model.LabelSelector != nil { + filtered := []iaas.Snapshot{} + for _, s := range snapshots { + if s.Labels != nil { + for k, v := range *s.Labels { + if fmt.Sprintf("%s=%s", k, v) == *model.LabelSelector { + filtered = append(filtered, s) + break + } + } + } + } + snapshots = filtered + } + + // Apply limit if specified + if model.Limit != nil && int(*model.Limit) < len(snapshots) { + snapshots = snapshots[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, snapshots) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().String(labelSelectorFlag, "", "Filter snapshots by labels") +} + +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, fmt.Errorf("limit must be greater than 0") + } + + labelSelector := flags.FlagToStringPointer(p, cmd, labelSelectorFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + LabelSelector: labelSelector, + } + + 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.ApiListSnapshotsRequest { + return apiClient.ListSnapshots(ctx, model.ProjectId) +} + +func outputResult(p *print.Printer, outputFormat string, snapshots []iaas.Snapshot) error { + if snapshots == nil { + return fmt.Errorf("list snapshots response is empty") + } + + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(snapshots, "", " ") + if err != nil { + return fmt.Errorf("marshal snapshots: %w", err) + } + p.Outputln(string(details)) + return nil + + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(snapshots, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal snapshots: %w", err) + } + p.Outputln(string(details)) + return nil + + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "SIZE", "STATUS", "VOLUME ID", "LABELS", "CREATED AT", "UPDATED AT") + + for i := range snapshots { + snapshot := snapshots[i] + table.AddRow( + utils.PtrString(snapshot.Id), + utils.PtrString(snapshot.Name), + utils.PtrByteSizeDefault((*int64)(snapshot.Size), ""), + utils.PtrString(snapshot.Status), + utils.PtrString(snapshot.VolumeId), + utils.PtrStringDefault(snapshot.Labels, ""), + utils.ConvertTimePToDateTimeString(snapshot.CreatedAt), + utils.ConvertTimePToDateTimeString(snapshot.UpdatedAt), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/volume/snapshot/list/list_test.go b/internal/cmd/volume/snapshot/list/list_test.go new file mode 100644 index 000000000..75ff2013f --- /dev/null +++ b/internal/cmd/volume/snapshot/list/list_test.go @@ -0,0 +1,240 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", + labelSelectorFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + LabelSelector: utils.Ptr("key1=value1"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListSnapshotsRequest)) iaas.ApiListSnapshotsRequest { + request := testClient.ListSnapshots(testCtx, testProjectId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "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, + }, + { + description: "only required flags", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + delete(flagValues, labelSelectorFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + model.LabelSelector = nil + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: 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) + } + } + + 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.ApiListSnapshotsRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + snapshots []iaas.Snapshot + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty snapshot in slice", + args: args{ + snapshots: []iaas.Snapshot{{}}, + }, + wantErr: false, + }, + { + name: "snapshots as argument", + args: args{ + snapshots: []iaas.Snapshot{ + { + Id: utils.Ptr("snapshot-1"), + }, + { + Id: utils.Ptr("snapshot-2"), + }, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.snapshots); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From 2cc64d22659816ef184a786a5062fbf249130e25 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Mon, 2 Jun 2025 20:34:30 +0200 Subject: [PATCH 04/27] add snapshots update subcommand --- internal/cmd/volume/snapshot/update/update.go | 152 +++++++++++ .../cmd/volume/snapshot/update/update_test.go | 245 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 internal/cmd/volume/snapshot/update/update.go create mode 100644 internal/cmd/volume/snapshot/update/update_test.go diff --git a/internal/cmd/volume/snapshot/update/update.go b/internal/cmd/volume/snapshot/update/update.go new file mode 100644 index 000000000..df1e69037 --- /dev/null +++ b/internal/cmd/volume/snapshot/update/update.go @@ -0,0 +1,152 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + snapshotIdArg = "SNAPSHOT_ID" + nameFlag = "name" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SnapshotId string + Name *string + Labels map[string]string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", snapshotIdArg), + Short: "Updates a snapshot", + Long: "Updates a snapshot by its ID.", + Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update a snapshot name`, + "$ stackit volume snapshot update xxx-xxx-xxx --name my-new-name"), + examples.NewExample( + `Update a snapshot labels`, + "$ stackit volume snapshot update xxx-xxx-xxx --labels key1=value1,key2=value2"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get snapshot name for label + snapshotLabel := model.SnapshotId + snapshot, err := apiClient.GetSnapshot(ctx, model.ProjectId, model.SnapshotId).Execute() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err) + } else if snapshot != nil && snapshot.Name != nil { + snapshotLabel = *snapshot.Name + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update snapshot %q?", snapshotLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update snapshot: %w", err) + } + + params.Printer.Info("Updated snapshot %q\n", snapshotLabel) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Name of the snapshot") + cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + snapshotId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + name := flags.FlagToStringPointer(p, cmd, nameFlag) + labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) + if labels == nil { + labels = &map[string]string{} + } + + if name == nil && len(*labels) == 0 { + return nil, fmt.Errorf("either name or labels must be provided") + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SnapshotId: snapshotId, + Name: name, + Labels: *labels, + } + + 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.ApiUpdateSnapshotRequest { + req := apiClient.UpdateSnapshot(ctx, model.ProjectId, model.SnapshotId) + payload := iaas.NewUpdateSnapshotPayloadWithDefaults() + payload.Name = model.Name + + // Convert labels to map[string]interface{} + if len(model.Labels) > 0 { + labelsMap := map[string]interface{}{} + for k, v := range model.Labels { + labelsMap[k] = v + } + payload.Labels = &labelsMap + } + + req = req.UpdateSnapshotPayload(*payload) + return req +} diff --git a/internal/cmd/volume/snapshot/update/update_test.go b/internal/cmd/volume/snapshot/update/update_test.go new file mode 100644 index 000000000..7cad0b23b --- /dev/null +++ b/internal/cmd/volume/snapshot/update/update_test.go @@ -0,0 +1,245 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "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" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testSnapshotId = uuid.NewString() + testName = "test-snapshot" + testLabels = map[string]string{"key1": "value1"} +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSnapshotId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + nameFlag: testName, + labelsFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + SnapshotId: testSnapshotId, + Name: &testName, + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiUpdateSnapshotRequest)) iaas.ApiUpdateSnapshotRequest { + request := testClient.UpdateSnapshot(testCtx, testProjectId, testSnapshotId) + payload := iaas.NewUpdateSnapshotPayloadWithDefaults() + payload.Name = &testName + + // Convert test labels to map[string]interface{} + labelsMap := map[string]interface{}{} + for k, v := range testLabels { + labelsMap[k] = v + } + payload.Labels = &labelsMap + + request = request.UpdateSnapshotPayload(*payload) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "snapshot id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no update flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + delete(flagValues, labelsFlag) + }), + isValid: false, + }, + { + description: "only name flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = make(map[string]string) + }), + }, + { + description: "only labels flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: 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 validating args: %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.ApiUpdateSnapshotRequest + }{ + { + 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) + } + }) + } +} From 035a4a2b037b3af63e5e0dc9111f329c7705364a Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Mon, 2 Jun 2025 20:36:45 +0200 Subject: [PATCH 05/27] add snapshots delete subcommand --- internal/cmd/volume/snapshot/delete/delete.go | 131 ++++++++++++ .../cmd/volume/snapshot/delete/delete_test.go | 197 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 internal/cmd/volume/snapshot/delete/delete.go create mode 100644 internal/cmd/volume/snapshot/delete/delete_test.go diff --git a/internal/cmd/volume/snapshot/delete/delete.go b/internal/cmd/volume/snapshot/delete/delete.go new file mode 100644 index 000000000..129e7c9d4 --- /dev/null +++ b/internal/cmd/volume/snapshot/delete/delete.go @@ -0,0 +1,131 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" +) + +const ( + snapshotIdArg = "SNAPSHOT_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SnapshotId string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", snapshotIdArg), + Short: "Deletes a snapshot", + Long: "Deletes a snapshot by its ID.", + Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a snapshot`, + "$ stackit volume snapshot delete xxx-xxx-xxx"), + examples.NewExample( + `Delete a snapshot and wait for deletion to be completed`, + "$ stackit volume snapshot delete xxx-xxx-xxx --async=false"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get snapshot name for label + snapshotLabel := model.SnapshotId + snapshot, err := apiClient.GetSnapshot(ctx, model.ProjectId, model.SnapshotId).Execute() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err) + } else if snapshot != nil && snapshot.Name != nil { + snapshotLabel = *snapshot.Name + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete snapshot %q? (This cannot be undone)", snapshotLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete snapshot: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Deleting snapshot") + _, err = wait.DeleteSnapshotWaitHandler(ctx, apiClient, model.ProjectId, model.SnapshotId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for snapshot deletion: %w", err) + } + s.Stop() + } + + if model.Async { + params.Printer.Info("Triggered deletion of snapshot %q\n", snapshotLabel) + } else { + params.Printer.Info("Deleted snapshot %q\n", snapshotLabel) + } + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + snapshotId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SnapshotId: snapshotId, + } + + 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.ApiDeleteSnapshotRequest { + return apiClient.DeleteSnapshot(ctx, model.ProjectId, model.SnapshotId) +} diff --git a/internal/cmd/volume/snapshot/delete/delete_test.go b/internal/cmd/volume/snapshot/delete/delete_test.go new file mode 100644 index 000000000..bf1f87d15 --- /dev/null +++ b/internal/cmd/volume/snapshot/delete/delete_test.go @@ -0,0 +1,197 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "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" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testSnapshotId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSnapshotId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + SnapshotId: testSnapshotId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiDeleteSnapshotRequest)) iaas.ApiDeleteSnapshotRequest { + request := testClient.DeleteSnapshot(testCtx, testProjectId, testSnapshotId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "snapshot id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: 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 validating args: %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.ApiDeleteSnapshotRequest + }{ + { + 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) + } + }) + } +} From c0ecd870bf938dced0d64895785fca2e7862088e Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Mon, 2 Jun 2025 20:39:12 +0200 Subject: [PATCH 06/27] add snapshots describe subcommand --- .../cmd/volume/snapshot/describe/describe.go | 145 +++++++++++ .../volume/snapshot/describe/describe_test.go | 242 ++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 internal/cmd/volume/snapshot/describe/describe.go create mode 100644 internal/cmd/volume/snapshot/describe/describe_test.go diff --git a/internal/cmd/volume/snapshot/describe/describe.go b/internal/cmd/volume/snapshot/describe/describe.go new file mode 100644 index 000000000..de02221c6 --- /dev/null +++ b/internal/cmd/volume/snapshot/describe/describe.go @@ -0,0 +1,145 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "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 ( + snapshotIdArg = "SNAPSHOT_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SnapshotId string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", snapshotIdArg), + Short: "Describes a snapshot", + Long: "Describes a snapshot by its ID.", + Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a snapshot`, + "$ stackit volume snapshot describe xxx-xxx-xxx"), + examples.NewExample( + `Get details of a snapshot in JSON format`, + "$ stackit volume snapshot describe xxx-xxx-xxx --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get snapshot details: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + snapshotId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SnapshotId: snapshotId, + } + + 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.ApiGetSnapshotRequest { + return apiClient.GetSnapshot(ctx, model.ProjectId, model.SnapshotId) +} + +func outputResult(p *print.Printer, outputFormat string, snapshot *iaas.Snapshot) error { + if snapshot == nil { + return fmt.Errorf("get snapshot response is empty") + } + + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return fmt.Errorf("marshal snapshot: %w", err) + } + p.Outputln(string(details)) + return nil + + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(snapshot, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal snapshot: %w", err) + } + p.Outputln(string(details)) + return nil + + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "SIZE", "STATUS", "VOLUME ID", "LABELS", "CREATED AT", "UPDATED AT") + + table.AddRow( + utils.PtrString(snapshot.Id), + utils.PtrString(snapshot.Name), + utils.PtrByteSizeDefault((*int64)(snapshot.Size), ""), + utils.PtrString(snapshot.Status), + utils.PtrString(snapshot.VolumeId), + utils.PtrStringDefault(snapshot.Labels, ""), + utils.ConvertTimePToDateTimeString(snapshot.CreatedAt), + utils.ConvertTimePToDateTimeString(snapshot.UpdatedAt), + ) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/volume/snapshot/describe/describe_test.go b/internal/cmd/volume/snapshot/describe/describe_test.go new file mode 100644 index 000000000..5d501758f --- /dev/null +++ b/internal/cmd/volume/snapshot/describe/describe_test.go @@ -0,0 +1,242 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testSnapshotId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSnapshotId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + SnapshotId: testSnapshotId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetSnapshotRequest)) iaas.ApiGetSnapshotRequest { + request := testClient.GetSnapshot(testCtx, testProjectId, testSnapshotId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "snapshot id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: 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 validating args: %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.ApiGetSnapshotRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + snapshot *iaas.Snapshot + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty snapshot", + args: args{ + snapshot: &iaas.Snapshot{}, + }, + wantErr: false, + }, + { + name: "snapshot with values", + args: args{ + snapshot: &iaas.Snapshot{ + Id: utils.Ptr("snapshot-1"), + Name: utils.Ptr("test-snapshot"), + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.snapshot); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From f5276ec1f7264be8eb79e6fc3c9ab20f0283d7a6 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Mon, 2 Jun 2025 20:49:47 +0200 Subject: [PATCH 07/27] fix linting error --- internal/cmd/volume/snapshot/list/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/volume/snapshot/list/list.go b/internal/cmd/volume/snapshot/list/list.go index dba12f5b3..316c9d987 100644 --- a/internal/cmd/volume/snapshot/list/list.go +++ b/internal/cmd/volume/snapshot/list/list.go @@ -49,7 +49,7 @@ func NewCmd(params *params.CmdParams) *cobra.Command { `List snapshots filtered by label`, "$ stackit volume snapshot list --label-selector key1=value1"), ), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() model, err := parseInput(params.Printer, cmd) if err != nil { From c5eda488098d79eedd546f3fb8257bbdbf8b8583 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Mon, 2 Jun 2025 21:01:32 +0200 Subject: [PATCH 08/27] add generate docs --- docs/stackit_volume.md | 1 + docs/stackit_volume_snapshot.md | 38 ++++++++++++++++++ docs/stackit_volume_snapshot_create.md | 49 ++++++++++++++++++++++++ docs/stackit_volume_snapshot_delete.md | 43 +++++++++++++++++++++ docs/stackit_volume_snapshot_describe.md | 43 +++++++++++++++++++++ docs/stackit_volume_snapshot_list.md | 48 +++++++++++++++++++++++ docs/stackit_volume_snapshot_update.md | 45 ++++++++++++++++++++++ 7 files changed, 267 insertions(+) create mode 100644 docs/stackit_volume_snapshot.md create mode 100644 docs/stackit_volume_snapshot_create.md create mode 100644 docs/stackit_volume_snapshot_delete.md create mode 100644 docs/stackit_volume_snapshot_describe.md create mode 100644 docs/stackit_volume_snapshot_list.md create mode 100644 docs/stackit_volume_snapshot_update.md diff --git a/docs/stackit_volume.md b/docs/stackit_volume.md index 4955b6299..3412504c2 100644 --- a/docs/stackit_volume.md +++ b/docs/stackit_volume.md @@ -37,5 +37,6 @@ stackit volume [flags] * [stackit volume list](./stackit_volume_list.md) - Lists all volumes of a project * [stackit volume performance-class](./stackit_volume_performance-class.md) - Provides functionality for volume performance classes available inside a project * [stackit volume resize](./stackit_volume_resize.md) - Resizes a volume +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots * [stackit volume update](./stackit_volume_update.md) - Updates a volume diff --git a/docs/stackit_volume_snapshot.md b/docs/stackit_volume_snapshot.md new file mode 100644 index 000000000..61f6f428e --- /dev/null +++ b/docs/stackit_volume_snapshot.md @@ -0,0 +1,38 @@ +## stackit volume snapshot + +Provides functionality for snapshots + +### Synopsis + +Provides functionality for snapshots. + +``` +stackit volume snapshot [flags] +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume](./stackit_volume.md) - Provides functionality for volumes +* [stackit volume snapshot create](./stackit_volume_snapshot_create.md) - Creates a snapshot from a volume +* [stackit volume snapshot delete](./stackit_volume_snapshot_delete.md) - Deletes a snapshot +* [stackit volume snapshot describe](./stackit_volume_snapshot_describe.md) - Describes a snapshot +* [stackit volume snapshot list](./stackit_volume_snapshot_list.md) - Lists all snapshots +* [stackit volume snapshot update](./stackit_volume_snapshot_update.md) - Updates a snapshot + diff --git a/docs/stackit_volume_snapshot_create.md b/docs/stackit_volume_snapshot_create.md new file mode 100644 index 000000000..549cffa16 --- /dev/null +++ b/docs/stackit_volume_snapshot_create.md @@ -0,0 +1,49 @@ +## stackit volume snapshot create + +Creates a snapshot from a volume + +### Synopsis + +Creates a snapshot from a volume. + +``` +stackit volume snapshot create [flags] +``` + +### Examples + +``` + Create a snapshot from a volume + $ stackit volume snapshot create --volume-id xxx --project-id xxx + + Create a snapshot with a name + $ stackit volume snapshot create --volume-id xxx --name my-snapshot --project-id xxx + + Create a snapshot with labels + $ stackit volume snapshot create --volume-id xxx --labels key1=value1,key2=value2 --project-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot create" + --labels stringToString Key-value string pairs as labels (default []) + --name string Name of the snapshot + --volume-id string ID of the volume from which a snapshot should be created +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + diff --git a/docs/stackit_volume_snapshot_delete.md b/docs/stackit_volume_snapshot_delete.md new file mode 100644 index 000000000..bb23bb9c9 --- /dev/null +++ b/docs/stackit_volume_snapshot_delete.md @@ -0,0 +1,43 @@ +## stackit volume snapshot delete + +Deletes a snapshot + +### Synopsis + +Deletes a snapshot by its ID. + +``` +stackit volume snapshot delete SNAPSHOT_ID [flags] +``` + +### Examples + +``` + Delete a snapshot + $ stackit volume snapshot delete xxx-xxx-xxx + + Delete a snapshot and wait for deletion to be completed + $ stackit volume snapshot delete xxx-xxx-xxx --async=false +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + diff --git a/docs/stackit_volume_snapshot_describe.md b/docs/stackit_volume_snapshot_describe.md new file mode 100644 index 000000000..21baafbaa --- /dev/null +++ b/docs/stackit_volume_snapshot_describe.md @@ -0,0 +1,43 @@ +## stackit volume snapshot describe + +Describes a snapshot + +### Synopsis + +Describes a snapshot by its ID. + +``` +stackit volume snapshot describe SNAPSHOT_ID [flags] +``` + +### Examples + +``` + Get details of a snapshot + $ stackit volume snapshot describe xxx-xxx-xxx + + Get details of a snapshot in JSON format + $ stackit volume snapshot describe xxx-xxx-xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + diff --git a/docs/stackit_volume_snapshot_list.md b/docs/stackit_volume_snapshot_list.md new file mode 100644 index 000000000..f4fe9dd3a --- /dev/null +++ b/docs/stackit_volume_snapshot_list.md @@ -0,0 +1,48 @@ +## stackit volume snapshot list + +Lists all snapshots + +### Synopsis + +Lists all snapshots in a project. + +``` +stackit volume snapshot list [flags] +``` + +### Examples + +``` + List all snapshots + $ stackit volume snapshot list + + List snapshots with a limit of 10 + $ stackit volume snapshot list --limit 10 + + List snapshots filtered by label + $ stackit volume snapshot list --label-selector key1=value1 +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot list" + --label-selector string Filter snapshots by labels + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + diff --git a/docs/stackit_volume_snapshot_update.md b/docs/stackit_volume_snapshot_update.md new file mode 100644 index 000000000..0ad02098b --- /dev/null +++ b/docs/stackit_volume_snapshot_update.md @@ -0,0 +1,45 @@ +## stackit volume snapshot update + +Updates a snapshot + +### Synopsis + +Updates a snapshot by its ID. + +``` +stackit volume snapshot update SNAPSHOT_ID [flags] +``` + +### Examples + +``` + Update a snapshot name + $ stackit volume snapshot update xxx-xxx-xxx --name my-new-name + + Update a snapshot labels + $ stackit volume snapshot update xxx-xxx-xxx --labels key1=value1,key2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot update" + --labels stringToString Key-value string pairs as labels (default []) + --name string Name of the snapshot +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + From 6f341360efcc1384c4ad64487add539fc6e3bb96 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Thu, 5 Jun 2025 21:31:48 +0200 Subject: [PATCH 09/27] fix visual representation of list and describe --- .../cmd/volume/snapshot/describe/describe.go | 36 ++++++++++++------- internal/cmd/volume/snapshot/list/list.go | 6 ++-- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/internal/cmd/volume/snapshot/describe/describe.go b/internal/cmd/volume/snapshot/describe/describe.go index de02221c6..d560d1bd7 100644 --- a/internal/cmd/volume/snapshot/describe/describe.go +++ b/internal/cmd/volume/snapshot/describe/describe.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/goccy/go-yaml" "github.com/spf13/cobra" @@ -122,18 +123,29 @@ func outputResult(p *print.Printer, outputFormat string, snapshot *iaas.Snapshot default: table := tables.NewTable() - table.SetHeader("ID", "NAME", "SIZE", "STATUS", "VOLUME ID", "LABELS", "CREATED AT", "UPDATED AT") - - table.AddRow( - utils.PtrString(snapshot.Id), - utils.PtrString(snapshot.Name), - utils.PtrByteSizeDefault((*int64)(snapshot.Size), ""), - utils.PtrString(snapshot.Status), - utils.PtrString(snapshot.VolumeId), - utils.PtrStringDefault(snapshot.Labels, ""), - utils.ConvertTimePToDateTimeString(snapshot.CreatedAt), - utils.ConvertTimePToDateTimeString(snapshot.UpdatedAt), - ) + table.AddRow("ID", utils.PtrString(snapshot.Id)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(snapshot.Name)) + table.AddSeparator() + table.AddRow("SIZE", utils.PtrByteSizeDefault((*int64)(snapshot.Size), "")) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(snapshot.Status)) + table.AddSeparator() + table.AddRow("VOLUME ID", utils.PtrString(snapshot.VolumeId)) + table.AddSeparator() + + if snapshot.Labels != nil && len(*snapshot.Labels) > 0 { + labels := []string{} + for key, value := range *snapshot.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + table.AddSeparator() + } + + table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(snapshot.CreatedAt)) + table.AddSeparator() + table.AddRow("UPDATED AT", utils.ConvertTimePToDateTimeString(snapshot.UpdatedAt)) err := table.Display(p) if err != nil { diff --git a/internal/cmd/volume/snapshot/list/list.go b/internal/cmd/volume/snapshot/list/list.go index 316c9d987..362e271b1 100644 --- a/internal/cmd/volume/snapshot/list/list.go +++ b/internal/cmd/volume/snapshot/list/list.go @@ -177,12 +177,10 @@ func outputResult(p *print.Printer, outputFormat string, snapshots []iaas.Snapsh utils.ConvertTimePToDateTimeString(snapshot.CreatedAt), utils.ConvertTimePToDateTimeString(snapshot.UpdatedAt), ) - } - err := table.Display(p) - if err != nil { - return fmt.Errorf("render table: %w", err) + table.AddSeparator() } + p.Outputln(table.Render()) return nil } } From 18d514f04adf3f55d4f975098fde5bb13fc44719 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Thu, 5 Jun 2025 21:46:16 +0200 Subject: [PATCH 10/27] fix empty result for listing snapshots --- internal/cmd/volume/snapshot/list/list.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/cmd/volume/snapshot/list/list.go b/internal/cmd/volume/snapshot/list/list.go index 362e271b1..71b2228e5 100644 --- a/internal/cmd/volume/snapshot/list/list.go +++ b/internal/cmd/volume/snapshot/list/list.go @@ -18,6 +18,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) @@ -69,6 +70,17 @@ func NewCmd(params *params.CmdParams) *cobra.Command { return fmt.Errorf("list snapshots: %w", err) } + // Check if response is empty + if resp.Items == nil || len(*resp.Items) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + params.Printer.Info("No snapshots found for project %q\n", projectLabel) + return nil + } + // Filter results by label selector snapshots := *resp.Items if model.LabelSelector != nil { From cdd064b266a4083e4083a794ef3a23b91248b7ea Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Thu, 5 Jun 2025 21:52:02 +0200 Subject: [PATCH 11/27] add newest version of iaas service --- go.sum | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/go.sum b/go.sum index 6bd663516..808a1251f 100644 --- a/go.sum +++ b/go.sum @@ -574,12 +574,12 @@ github.com/stackitcloud/stackit-sdk-go/services/git v0.5.0 h1:s0A2EPBrnBxfKStKA/ github.com/stackitcloud/stackit-sdk-go/services/git v0.5.0/go.mod h1:XhXHJpOVC9Rpwyf1G+EpMbprBafH9aZb8vWBdR+z0WM= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.24.0 h1:aLlZmcsDHqqc7KPsevvs+W6EPZFT51u/dx5TcVQsE6g= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.24.0/go.mod h1:TaMx7kukGpRm0BkNCmS7u2x12q1pgfbD55DAnLIjOIQ= -github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.4.0 h1:Ef4SyTBjIkfwaws4mssa6AoK+OokHFtr7ZIflUpoXVE= -github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.4.0/go.mod h1:FiVhDlw9+yuTiUmnyGLn2qpsLW26w9OC4TS1y78czvg= -github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.1 h1:hfnILDJGBwwqUIs4xt/7Jj4LBe+JsSdHy+Md2ynUg4Y= -github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.1/go.mod h1:XjDMHhAQogFXsVR+o138CPYG1FOe0/Nl2Vm+fAgzx2A= -github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.1 h1:7nN7ZCuWSbJMy5KqoOqSbp5JKIOvyuDqVRtxVvT1iyE= -github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.1/go.mod h1:Pb8IEV5/jP8k75dVcN5cn3kP7PHTy/4KXXKpG76oj4U= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.2.0 h1:gaTgjmEIvq7Nmji5YHh1haFvA/8dWyOgCg3lw6drjL4= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.2.0/go.mod h1:h3oM6cS23Yfynp8Df1hNr0FxtY5Alii/2g8Wqi5SIVE= +github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.0 h1:5J2TH9ig5cp+5pCHugrsDJuFsRnIOQHQUqsxlweRXL0= +github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.0/go.mod h1:+3jizYma6Dq3XVn6EMMdSBF9eIm0w6hCJvrStB3AIL0= +github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.0 h1:t/Ten9AuoWFmrDq5gAI3kVZShF3i8zEAaeBsYYqiaao= +github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.0/go.mod h1:qgvi3qiAzB1wKpMJ5CPnEaUToeiwgnQxGvlkjdisaLU= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.0 h1:U/IhjLOz0vG6zuxTqGhBd8f609s6JB+X9PaL6x/VM58= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.0/go.mod h1:+JSnz5/AvGN5ek/iH008frRc/NgjSr1EVOTIbyLwAuQ= github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.2.0 h1:+dKIPVz9ydKbX3x6+1NvYk++OA378w74p+N6SjDmzBQ= From afe4e9d1471baba9571fbd01e7a443da2e3d4b05 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 10 Jun 2025 22:28:16 +0200 Subject: [PATCH 12/27] update examples, format labels, typing fix --- internal/cmd/volume/snapshot/create/create.go | 16 ++++++++-------- internal/cmd/volume/snapshot/delete/delete.go | 7 ++----- .../cmd/volume/snapshot/describe/describe.go | 10 +++++----- internal/cmd/volume/snapshot/list/list.go | 16 ++++++++++++---- internal/cmd/volume/snapshot/update/update.go | 8 ++++---- 5 files changed, 31 insertions(+), 26 deletions(-) diff --git a/internal/cmd/volume/snapshot/create/create.go b/internal/cmd/volume/snapshot/create/create.go index b9f3b035a..e8a33315f 100644 --- a/internal/cmd/volume/snapshot/create/create.go +++ b/internal/cmd/volume/snapshot/create/create.go @@ -42,14 +42,14 @@ func NewCmd(params *params.CmdParams) *cobra.Command { Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `Create a snapshot from a volume`, - "$ stackit volume snapshot create --volume-id xxx --project-id xxx"), + `Create a snapshot from a volume with ID "xxx"`, + "$ stackit volume snapshot create --volume-id xxx"), examples.NewExample( - `Create a snapshot with a name`, - "$ stackit volume snapshot create --volume-id xxx --name my-snapshot --project-id xxx"), + `Create a snapshot from a volume with ID "xxx" and name "my-snapshot"`, + "$ stackit volume snapshot create --volume-id xxx --name my-snapshot"), examples.NewExample( - `Create a snapshot with labels`, - "$ stackit volume snapshot create --volume-id xxx --labels key1=value1,key2=value2 --project-id xxx"), + `Create a snapshot from a volume with ID "xxx" and labels`, + "$ stackit volume snapshot create --volume-id xxx --labels key1=value1,key2=value2"), ), RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() @@ -106,9 +106,9 @@ func NewCmd(params *params.CmdParams) *cobra.Command { } if model.Async { - params.Printer.Info("Triggered snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, *resp.Id) + params.Printer.Info("Triggered snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, utils.PtrString(resp.Id)) } else { - params.Printer.Info("Created snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, *resp.Id) + params.Printer.Info("Created snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, utils.PtrString(resp.Id)) } return nil }, diff --git a/internal/cmd/volume/snapshot/delete/delete.go b/internal/cmd/volume/snapshot/delete/delete.go index 129e7c9d4..6b300114d 100644 --- a/internal/cmd/volume/snapshot/delete/delete.go +++ b/internal/cmd/volume/snapshot/delete/delete.go @@ -36,11 +36,8 @@ func NewCmd(params *params.CmdParams) *cobra.Command { Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( - `Delete a snapshot`, - "$ stackit volume snapshot delete xxx-xxx-xxx"), - examples.NewExample( - `Delete a snapshot and wait for deletion to be completed`, - "$ stackit volume snapshot delete xxx-xxx-xxx --async=false"), + `Delete a snapshot with ID "xxx"`, + "$ stackit volume snapshot delete xxx"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/internal/cmd/volume/snapshot/describe/describe.go b/internal/cmd/volume/snapshot/describe/describe.go index d560d1bd7..fbbe7ba28 100644 --- a/internal/cmd/volume/snapshot/describe/describe.go +++ b/internal/cmd/volume/snapshot/describe/describe.go @@ -38,11 +38,11 @@ func NewCmd(params *params.CmdParams) *cobra.Command { Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( - `Get details of a snapshot`, - "$ stackit volume snapshot describe xxx-xxx-xxx"), + `Get details of a snapshot with ID "xxx"`, + "$ stackit volume snapshot describe xxx"), examples.NewExample( - `Get details of a snapshot in JSON format`, - "$ stackit volume snapshot describe xxx-xxx-xxx --output-format json"), + `Get details of a snapshot with ID "xxx" in JSON format`, + "$ stackit volume snapshot describe xxx --output-format json"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -127,7 +127,7 @@ func outputResult(p *print.Printer, outputFormat string, snapshot *iaas.Snapshot table.AddSeparator() table.AddRow("NAME", utils.PtrString(snapshot.Name)) table.AddSeparator() - table.AddRow("SIZE", utils.PtrByteSizeDefault((*int64)(snapshot.Size), "")) + table.AddRow("SIZE", utils.PtrByteSizeDefault(snapshot.Size, "")) table.AddSeparator() table.AddRow("STATUS", utils.PtrString(snapshot.Status)) table.AddSeparator() diff --git a/internal/cmd/volume/snapshot/list/list.go b/internal/cmd/volume/snapshot/list/list.go index 71b2228e5..094a71b0a 100644 --- a/internal/cmd/volume/snapshot/list/list.go +++ b/internal/cmd/volume/snapshot/list/list.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/goccy/go-yaml" "github.com/spf13/cobra" @@ -177,15 +178,22 @@ func outputResult(p *print.Printer, outputFormat string, snapshots []iaas.Snapsh table := tables.NewTable() table.SetHeader("ID", "NAME", "SIZE", "STATUS", "VOLUME ID", "LABELS", "CREATED AT", "UPDATED AT") - for i := range snapshots { - snapshot := snapshots[i] + for _, snapshot := range snapshots { + var labelsString string + if snapshot.Labels != nil { + var labels []string + for key, value := range *snapshot.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + labelsString = strings.Join(labels, "\n") + } table.AddRow( utils.PtrString(snapshot.Id), utils.PtrString(snapshot.Name), - utils.PtrByteSizeDefault((*int64)(snapshot.Size), ""), + utils.PtrByteSizeDefault(snapshot.Size, ""), utils.PtrString(snapshot.Status), utils.PtrString(snapshot.VolumeId), - utils.PtrStringDefault(snapshot.Labels, ""), + labelsString, utils.ConvertTimePToDateTimeString(snapshot.CreatedAt), utils.ConvertTimePToDateTimeString(snapshot.UpdatedAt), ) diff --git a/internal/cmd/volume/snapshot/update/update.go b/internal/cmd/volume/snapshot/update/update.go index df1e69037..9626180a6 100644 --- a/internal/cmd/volume/snapshot/update/update.go +++ b/internal/cmd/volume/snapshot/update/update.go @@ -39,11 +39,11 @@ func NewCmd(params *params.CmdParams) *cobra.Command { Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( - `Update a snapshot name`, - "$ stackit volume snapshot update xxx-xxx-xxx --name my-new-name"), + `Update a snapshot name with ID "xxx"`, + "$ stackit volume snapshot update xxx --name my-new-name"), examples.NewExample( - `Update a snapshot labels`, - "$ stackit volume snapshot update xxx-xxx-xxx --labels key1=value1,key2=value2"), + `Update a snapshot labels with ID "xxx"`, + "$ stackit volume snapshot update xxx --labels key1=value1,key2=value2"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() From 71c0e59e9c8b136af8a9b2fb665a1ad1a9f34b1a Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 10 Jun 2025 22:34:37 +0200 Subject: [PATCH 13/27] update docs --- docs/stackit_volume_snapshot_create.md | 12 ++++++------ docs/stackit_volume_snapshot_delete.md | 7 ++----- docs/stackit_volume_snapshot_describe.md | 8 ++++---- docs/stackit_volume_snapshot_update.md | 8 ++++---- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/docs/stackit_volume_snapshot_create.md b/docs/stackit_volume_snapshot_create.md index 549cffa16..4ed86ad39 100644 --- a/docs/stackit_volume_snapshot_create.md +++ b/docs/stackit_volume_snapshot_create.md @@ -13,14 +13,14 @@ stackit volume snapshot create [flags] ### Examples ``` - Create a snapshot from a volume - $ stackit volume snapshot create --volume-id xxx --project-id xxx + Create a snapshot from a volume with ID "xxx" + $ stackit volume snapshot create --volume-id xxx - Create a snapshot with a name - $ stackit volume snapshot create --volume-id xxx --name my-snapshot --project-id xxx + Create a snapshot from a volume with ID "xxx" and name "my-snapshot" + $ stackit volume snapshot create --volume-id xxx --name my-snapshot - Create a snapshot with labels - $ stackit volume snapshot create --volume-id xxx --labels key1=value1,key2=value2 --project-id xxx + Create a snapshot from a volume with ID "xxx" and labels + $ stackit volume snapshot create --volume-id xxx --labels key1=value1,key2=value2 ``` ### Options diff --git a/docs/stackit_volume_snapshot_delete.md b/docs/stackit_volume_snapshot_delete.md index bb23bb9c9..df9a37828 100644 --- a/docs/stackit_volume_snapshot_delete.md +++ b/docs/stackit_volume_snapshot_delete.md @@ -13,11 +13,8 @@ stackit volume snapshot delete SNAPSHOT_ID [flags] ### Examples ``` - Delete a snapshot - $ stackit volume snapshot delete xxx-xxx-xxx - - Delete a snapshot and wait for deletion to be completed - $ stackit volume snapshot delete xxx-xxx-xxx --async=false + Delete a snapshot with ID "xxx" + $ stackit volume snapshot delete xxx ``` ### Options diff --git a/docs/stackit_volume_snapshot_describe.md b/docs/stackit_volume_snapshot_describe.md index 21baafbaa..5f7f256b7 100644 --- a/docs/stackit_volume_snapshot_describe.md +++ b/docs/stackit_volume_snapshot_describe.md @@ -13,11 +13,11 @@ stackit volume snapshot describe SNAPSHOT_ID [flags] ### Examples ``` - Get details of a snapshot - $ stackit volume snapshot describe xxx-xxx-xxx + Get details of a snapshot with ID "xxx" + $ stackit volume snapshot describe xxx - Get details of a snapshot in JSON format - $ stackit volume snapshot describe xxx-xxx-xxx --output-format json + Get details of a snapshot with ID "xxx" in JSON format + $ stackit volume snapshot describe xxx --output-format json ``` ### Options diff --git a/docs/stackit_volume_snapshot_update.md b/docs/stackit_volume_snapshot_update.md index 0ad02098b..2b74b5ae8 100644 --- a/docs/stackit_volume_snapshot_update.md +++ b/docs/stackit_volume_snapshot_update.md @@ -13,11 +13,11 @@ stackit volume snapshot update SNAPSHOT_ID [flags] ### Examples ``` - Update a snapshot name - $ stackit volume snapshot update xxx-xxx-xxx --name my-new-name + Update a snapshot name with ID "xxx" + $ stackit volume snapshot update xxx --name my-new-name - Update a snapshot labels - $ stackit volume snapshot update xxx-xxx-xxx --labels key1=value1,key2=value2 + Update a snapshot labels with ID "xxx" + $ stackit volume snapshot update xxx --labels key1=value1,key2=value2 ``` ### Options From baa5aaccbb752abd77e806cd177403eace169f05 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Wed, 11 Jun 2025 20:04:03 +0200 Subject: [PATCH 14/27] change var to const in create backup test --- .../cmd/volume/snapshot/create/create_test.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/cmd/volume/snapshot/create/create_test.go b/internal/cmd/volume/snapshot/create/create_test.go index 1c151437d..82f066e77 100644 --- a/internal/cmd/volume/snapshot/create/create_test.go +++ b/internal/cmd/volume/snapshot/create/create_test.go @@ -4,24 +4,27 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/cmd/params" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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/stackitcloud/stackit-sdk-go/services/iaas" ) type testCtxKey struct{} +const ( + testName = "test-snapshot" +) + var ( testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") testClient = &iaas.APIClient{} testProjectId = uuid.NewString() testVolumeId = uuid.NewString() - testName = "test-snapshot" testLabels = map[string]string{"key1": "value1"} ) @@ -45,7 +48,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { Verbosity: globalflags.VerbosityDefault, }, VolumeID: testVolumeId, - Name: &testName, + Name: utils.Ptr(testName), Labels: testLabels, } for _, mod := range mods { @@ -58,7 +61,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateSnapshotRequest)) iaas.A request := testClient.CreateSnapshot(testCtx, testProjectId) payload := iaas.NewCreateSnapshotPayloadWithDefaults() payload.VolumeId = &testVolumeId - payload.Name = &testName + payload.Name = utils.Ptr(testName) // Convert test labels to map[string]interface{} labelsMap := map[string]interface{}{} From 6d3de884c76bda885b8bc20a835ca97e8fff01e8 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 17 Jun 2025 11:15:45 +0200 Subject: [PATCH 15/27] refactor duplicate code with util func ConvertStringMapToInterfaceMap --- go.sum | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/go.sum b/go.sum index 808a1251f..6bd663516 100644 --- a/go.sum +++ b/go.sum @@ -574,12 +574,12 @@ github.com/stackitcloud/stackit-sdk-go/services/git v0.5.0 h1:s0A2EPBrnBxfKStKA/ github.com/stackitcloud/stackit-sdk-go/services/git v0.5.0/go.mod h1:XhXHJpOVC9Rpwyf1G+EpMbprBafH9aZb8vWBdR+z0WM= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.24.0 h1:aLlZmcsDHqqc7KPsevvs+W6EPZFT51u/dx5TcVQsE6g= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.24.0/go.mod h1:TaMx7kukGpRm0BkNCmS7u2x12q1pgfbD55DAnLIjOIQ= -github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.2.0 h1:gaTgjmEIvq7Nmji5YHh1haFvA/8dWyOgCg3lw6drjL4= -github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.2.0/go.mod h1:h3oM6cS23Yfynp8Df1hNr0FxtY5Alii/2g8Wqi5SIVE= -github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.0 h1:5J2TH9ig5cp+5pCHugrsDJuFsRnIOQHQUqsxlweRXL0= -github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.0/go.mod h1:+3jizYma6Dq3XVn6EMMdSBF9eIm0w6hCJvrStB3AIL0= -github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.0 h1:t/Ten9AuoWFmrDq5gAI3kVZShF3i8zEAaeBsYYqiaao= -github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.0/go.mod h1:qgvi3qiAzB1wKpMJ5CPnEaUToeiwgnQxGvlkjdisaLU= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.4.0 h1:Ef4SyTBjIkfwaws4mssa6AoK+OokHFtr7ZIflUpoXVE= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.4.0/go.mod h1:FiVhDlw9+yuTiUmnyGLn2qpsLW26w9OC4TS1y78czvg= +github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.1 h1:hfnILDJGBwwqUIs4xt/7Jj4LBe+JsSdHy+Md2ynUg4Y= +github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.1/go.mod h1:XjDMHhAQogFXsVR+o138CPYG1FOe0/Nl2Vm+fAgzx2A= +github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.1 h1:7nN7ZCuWSbJMy5KqoOqSbp5JKIOvyuDqVRtxVvT1iyE= +github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.1/go.mod h1:Pb8IEV5/jP8k75dVcN5cn3kP7PHTy/4KXXKpG76oj4U= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.0 h1:U/IhjLOz0vG6zuxTqGhBd8f609s6JB+X9PaL6x/VM58= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.0/go.mod h1:+JSnz5/AvGN5ek/iH008frRc/NgjSr1EVOTIbyLwAuQ= github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.2.0 h1:+dKIPVz9ydKbX3x6+1NvYk++OA378w74p+N6SjDmzBQ= From 24be571b1107331420df10c8d860344fae3afa7b Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 17 Jun 2025 11:51:15 +0200 Subject: [PATCH 16/27] fix printing behavior --- internal/cmd/volume/snapshot/create/create.go | 4 ++-- internal/cmd/volume/snapshot/delete/delete.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/volume/snapshot/create/create.go b/internal/cmd/volume/snapshot/create/create.go index e8a33315f..a37194d34 100644 --- a/internal/cmd/volume/snapshot/create/create.go +++ b/internal/cmd/volume/snapshot/create/create.go @@ -106,9 +106,9 @@ func NewCmd(params *params.CmdParams) *cobra.Command { } if model.Async { - params.Printer.Info("Triggered snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, utils.PtrString(resp.Id)) + params.Printer.Outputf("Triggered snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, utils.PtrString(resp.Id)) } else { - params.Printer.Info("Created snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, utils.PtrString(resp.Id)) + params.Printer.Outputf("Created snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, utils.PtrString(resp.Id)) } return nil }, diff --git a/internal/cmd/volume/snapshot/delete/delete.go b/internal/cmd/volume/snapshot/delete/delete.go index 6b300114d..8ea29a928 100644 --- a/internal/cmd/volume/snapshot/delete/delete.go +++ b/internal/cmd/volume/snapshot/delete/delete.go @@ -88,9 +88,9 @@ func NewCmd(params *params.CmdParams) *cobra.Command { } if model.Async { - params.Printer.Info("Triggered deletion of snapshot %q\n", snapshotLabel) + params.Printer.Outputf("Triggered deletion of snapshot %q\n", snapshotLabel) } else { - params.Printer.Info("Deleted snapshot %q\n", snapshotLabel) + params.Printer.Outputf("Deleted snapshot %q\n", snapshotLabel) } return nil }, From 1b7bdca1a2c220cfbc35daf30c04872fd94099d5 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 17 Jun 2025 13:22:23 +0200 Subject: [PATCH 17/27] fix add backup subcommand to volume command --- internal/cmd/volume/volume.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/cmd/volume/volume.go b/internal/cmd/volume/volume.go index 9fbe0ad33..8da9cbd13 100644 --- a/internal/cmd/volume/volume.go +++ b/internal/cmd/volume/volume.go @@ -2,6 +2,7 @@ package volume import ( "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/create" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/describe" @@ -37,4 +38,5 @@ func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(resize.NewCmd(params)) cmd.AddCommand(performanceclass.NewCmd(params)) cmd.AddCommand(snapshot.NewCmd(params)) + cmd.AddCommand(backup.NewCmd(params)) } From a6c8a701279b586f84a8ba10698129d514bf724e Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 17 Jun 2025 17:49:12 +0200 Subject: [PATCH 18/27] fix refactoring with convertStringMapToInterfaceMap util func --- internal/cmd/volume/backup/update/update_test.go | 8 ++------ internal/cmd/volume/snapshot/create/create.go | 10 +--------- internal/cmd/volume/snapshot/create/create_test.go | 7 +------ internal/cmd/volume/snapshot/update/update.go | 10 +--------- internal/cmd/volume/snapshot/update/update_test.go | 9 ++------- 5 files changed, 7 insertions(+), 37 deletions(-) diff --git a/internal/cmd/volume/backup/update/update_test.go b/internal/cmd/volume/backup/update/update_test.go index b0826ae65..21aa21c1e 100644 --- a/internal/cmd/volume/backup/update/update_test.go +++ b/internal/cmd/volume/backup/update/update_test.go @@ -7,6 +7,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/params" "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" @@ -68,12 +69,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiUpdateBackupRequest)) iaas.Api payload := iaas.NewUpdateBackupPayloadWithDefaults() payload.Name = &testName - // Convert test labels to map[string]interface{} - labelsMap := map[string]interface{}{} - for k, v := range testLabels { - labelsMap[k] = v - } - payload.Labels = &labelsMap + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(testLabels)) request = request.UpdateBackupPayload(*payload) for _, mod := range mods { diff --git a/internal/cmd/volume/snapshot/create/create.go b/internal/cmd/volume/snapshot/create/create.go index a37194d34..d642b2154 100644 --- a/internal/cmd/volume/snapshot/create/create.go +++ b/internal/cmd/volume/snapshot/create/create.go @@ -171,15 +171,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli payload := iaas.NewCreateSnapshotPayloadWithDefaults() payload.VolumeId = &model.VolumeID payload.Name = model.Name - - // Convert labels to map[string]interface{} - if len(model.Labels) > 0 { - labelsMap := map[string]interface{}{} - for k, v := range model.Labels { - labelsMap[k] = v - } - payload.Labels = &labelsMap - } + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(model.Labels)) req = req.CreateSnapshotPayload(*payload) return req diff --git a/internal/cmd/volume/snapshot/create/create_test.go b/internal/cmd/volume/snapshot/create/create_test.go index 82f066e77..08c5c0baa 100644 --- a/internal/cmd/volume/snapshot/create/create_test.go +++ b/internal/cmd/volume/snapshot/create/create_test.go @@ -63,12 +63,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateSnapshotRequest)) iaas.A payload.VolumeId = &testVolumeId payload.Name = utils.Ptr(testName) - // Convert test labels to map[string]interface{} - labelsMap := map[string]interface{}{} - for k, v := range testLabels { - labelsMap[k] = v - } - payload.Labels = &labelsMap + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(testLabels)) request = request.CreateSnapshotPayload(*payload) for _, mod := range mods { diff --git a/internal/cmd/volume/snapshot/update/update.go b/internal/cmd/volume/snapshot/update/update.go index 9626180a6..71351a323 100644 --- a/internal/cmd/volume/snapshot/update/update.go +++ b/internal/cmd/volume/snapshot/update/update.go @@ -137,15 +137,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli req := apiClient.UpdateSnapshot(ctx, model.ProjectId, model.SnapshotId) payload := iaas.NewUpdateSnapshotPayloadWithDefaults() payload.Name = model.Name - - // Convert labels to map[string]interface{} - if len(model.Labels) > 0 { - labelsMap := map[string]interface{}{} - for k, v := range model.Labels { - labelsMap[k] = v - } - payload.Labels = &labelsMap - } + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(model.Labels)) req = req.UpdateSnapshotPayload(*payload) return req diff --git a/internal/cmd/volume/snapshot/update/update_test.go b/internal/cmd/volume/snapshot/update/update_test.go index 7cad0b23b..cb4af61fc 100644 --- a/internal/cmd/volume/snapshot/update/update_test.go +++ b/internal/cmd/volume/snapshot/update/update_test.go @@ -7,6 +7,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/params" "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" @@ -67,13 +68,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiUpdateSnapshotRequest)) iaas.A request := testClient.UpdateSnapshot(testCtx, testProjectId, testSnapshotId) payload := iaas.NewUpdateSnapshotPayloadWithDefaults() payload.Name = &testName - - // Convert test labels to map[string]interface{} - labelsMap := map[string]interface{}{} - for k, v := range testLabels { - labelsMap[k] = v - } - payload.Labels = &labelsMap + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(testLabels)) request = request.UpdateSnapshotPayload(*payload) for _, mod := range mods { From 8d98c6930cf71413f06e137b834db9fd2e88633a Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 17 Jun 2025 17:57:58 +0200 Subject: [PATCH 19/27] fix printing --- internal/cmd/volume/snapshot/update/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/volume/snapshot/update/update.go b/internal/cmd/volume/snapshot/update/update.go index 71351a323..05bcd57da 100644 --- a/internal/cmd/volume/snapshot/update/update.go +++ b/internal/cmd/volume/snapshot/update/update.go @@ -82,7 +82,7 @@ func NewCmd(params *params.CmdParams) *cobra.Command { return fmt.Errorf("update snapshot: %w", err) } - params.Printer.Info("Updated snapshot %q\n", snapshotLabel) + params.Printer.Outputf("Updated snapshot %q\n", snapshotLabel) return nil }, } From d93f1bb5d786b79867cd4ba18dde166516717ab5 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 24 Jun 2025 08:56:21 +0200 Subject: [PATCH 20/27] fix volumeid validation --- internal/cmd/volume/snapshot/create/create.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/cmd/volume/snapshot/create/create.go b/internal/cmd/volume/snapshot/create/create.go index d642b2154..f8b13afdf 100644 --- a/internal/cmd/volume/snapshot/create/create.go +++ b/internal/cmd/volume/snapshot/create/create.go @@ -119,7 +119,7 @@ func NewCmd(params *params.CmdParams) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().String(volumeIdFlag, "", "ID of the volume from which a snapshot should be created") + cmd.Flags().Var(flags.UUIDFlag(), volumeIdFlag, "Volume ID") cmd.Flags().String(nameFlag, "", "Name of the snapshot") cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") @@ -137,9 +137,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { if volumeID == "" { return nil, fmt.Errorf("volume-id is required") } - if err := utils.ValidateUUID(volumeID); err != nil { - return nil, fmt.Errorf("volume-id must be a valid UUID") - } name := flags.FlagToStringPointer(p, cmd, nameFlag) labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) From 2bef33482f8d0db67312830ae7512be2a0aaef17 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 24 Jun 2025 08:59:02 +0200 Subject: [PATCH 21/27] fix volumeid validation --- internal/cmd/volume/snapshot/create/create.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/cmd/volume/snapshot/create/create.go b/internal/cmd/volume/snapshot/create/create.go index f8b13afdf..a6ca715e1 100644 --- a/internal/cmd/volume/snapshot/create/create.go +++ b/internal/cmd/volume/snapshot/create/create.go @@ -134,9 +134,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { } volumeID := flags.FlagToStringValue(p, cmd, volumeIdFlag) - if volumeID == "" { - return nil, fmt.Errorf("volume-id is required") - } name := flags.FlagToStringPointer(p, cmd, nameFlag) labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) From 9c806bdaf3c597065e2005cc95bf36a076bb194c Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 24 Jun 2025 09:13:22 +0200 Subject: [PATCH 22/27] refactoring --- internal/cmd/volume/snapshot/create/create.go | 13 ++++++------- internal/cmd/volume/snapshot/delete/delete.go | 13 ++++++------- internal/pkg/services/iaas/utils/utils.go | 4 ++++ 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/internal/cmd/volume/snapshot/create/create.go b/internal/cmd/volume/snapshot/create/create.go index a6ca715e1..a9569c351 100644 --- a/internal/cmd/volume/snapshot/create/create.go +++ b/internal/cmd/volume/snapshot/create/create.go @@ -14,6 +14,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" "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/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -71,12 +72,10 @@ func NewCmd(params *params.CmdParams) *cobra.Command { } // Get volume name for label - volumeLabel := model.VolumeID - volume, err := apiClient.GetVolume(ctx, model.ProjectId, model.VolumeID).Execute() + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeID) if err != nil { params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) - } else if volume != nil && volume.Name != nil { - volumeLabel = *volume.Name + volumeLabel = model.VolumeID } if !model.AssumeYes { @@ -105,11 +104,11 @@ func NewCmd(params *params.CmdParams) *cobra.Command { s.Stop() } + operationState := "Created" if model.Async { - params.Printer.Outputf("Triggered snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, utils.PtrString(resp.Id)) - } else { - params.Printer.Outputf("Created snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, utils.PtrString(resp.Id)) + operationState = "Triggered creation of" } + params.Printer.Outputf("%s snapshot of %q in %q. Snapshot ID: %s\n", operationState, volumeLabel, projectLabel, utils.PtrString(resp.Id)) return nil }, } diff --git a/internal/cmd/volume/snapshot/delete/delete.go b/internal/cmd/volume/snapshot/delete/delete.go index 8ea29a928..0a4c17faa 100644 --- a/internal/cmd/volume/snapshot/delete/delete.go +++ b/internal/cmd/volume/snapshot/delete/delete.go @@ -12,6 +12,7 @@ import ( "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/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -53,12 +54,10 @@ func NewCmd(params *params.CmdParams) *cobra.Command { } // Get snapshot name for label - snapshotLabel := model.SnapshotId - snapshot, err := apiClient.GetSnapshot(ctx, model.ProjectId, model.SnapshotId).Execute() + snapshotLabel, err := iaasUtils.GetSnapshotName(ctx, apiClient, model.ProjectId, model.SnapshotId) if err != nil { params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err) - } else if snapshot != nil && snapshot.Name != nil { - snapshotLabel = *snapshot.Name + snapshotLabel = model.SnapshotId } if !model.AssumeYes { @@ -87,11 +86,11 @@ func NewCmd(params *params.CmdParams) *cobra.Command { s.Stop() } + operationState := "Deleted" if model.Async { - params.Printer.Outputf("Triggered deletion of snapshot %q\n", snapshotLabel) - } else { - params.Printer.Outputf("Deleted snapshot %q\n", snapshotLabel) + operationState = "Triggered deletion of" } + params.Printer.Outputf("%s snapshot %q\n", operationState, snapshotLabel) return nil }, } diff --git a/internal/pkg/services/iaas/utils/utils.go b/internal/pkg/services/iaas/utils/utils.go index f3d3571d7..2cf460334 100644 --- a/internal/pkg/services/iaas/utils/utils.go +++ b/internal/pkg/services/iaas/utils/utils.go @@ -177,6 +177,10 @@ func GetSnapshotName(ctx context.Context, apiClient IaaSClient, projectId, snaps resp, err := apiClient.GetSnapshotExecute(ctx, projectId, snapshotId) if err != nil { return "", fmt.Errorf("get snapshot: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.Name == nil { + return "", ErrNameNil } return *resp.Name, nil } From 4f3d0992aa6c2ea3b65ad6007812245c260a3731 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 24 Jun 2025 09:23:58 +0200 Subject: [PATCH 23/27] refactoring --- internal/cmd/volume/snapshot/list/list.go | 26 +++++++------------ internal/cmd/volume/snapshot/update/update.go | 7 +++-- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/internal/cmd/volume/snapshot/list/list.go b/internal/cmd/volume/snapshot/list/list.go index 094a71b0a..6c5e3a8b2 100644 --- a/internal/cmd/volume/snapshot/list/list.go +++ b/internal/cmd/volume/snapshot/list/list.go @@ -82,22 +82,7 @@ func NewCmd(params *params.CmdParams) *cobra.Command { return nil } - // Filter results by label selector snapshots := *resp.Items - if model.LabelSelector != nil { - filtered := []iaas.Snapshot{} - for _, s := range snapshots { - if s.Labels != nil { - for k, v := range *s.Labels { - if fmt.Sprintf("%s=%s", k, v) == *model.LabelSelector { - filtered = append(filtered, s) - break - } - } - } - } - snapshots = filtered - } // Apply limit if specified if model.Limit != nil && int(*model.Limit) < len(snapshots) { @@ -125,7 +110,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) if limit != nil && *limit < 1 { - return nil, fmt.Errorf("limit must be greater than 0") + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } } labelSelector := flags.FlagToStringPointer(p, cmd, labelSelectorFlag) @@ -149,7 +137,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListSnapshotsRequest { - return apiClient.ListSnapshots(ctx, model.ProjectId) + req := apiClient.ListSnapshots(ctx, model.ProjectId) + if model.LabelSelector != nil { + req.LabelSelector(*model.LabelSelector) + } + return req } func outputResult(p *print.Printer, outputFormat string, snapshots []iaas.Snapshot) error { diff --git a/internal/cmd/volume/snapshot/update/update.go b/internal/cmd/volume/snapshot/update/update.go index 05bcd57da..543c484d1 100644 --- a/internal/cmd/volume/snapshot/update/update.go +++ b/internal/cmd/volume/snapshot/update/update.go @@ -13,6 +13,7 @@ import ( "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" @@ -59,12 +60,10 @@ func NewCmd(params *params.CmdParams) *cobra.Command { } // Get snapshot name for label - snapshotLabel := model.SnapshotId - snapshot, err := apiClient.GetSnapshot(ctx, model.ProjectId, model.SnapshotId).Execute() + snapshotLabel, err := iaasUtils.GetSnapshotName(ctx, apiClient, model.ProjectId, model.SnapshotId) if err != nil { params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err) - } else if snapshot != nil && snapshot.Name != nil { - snapshotLabel = *snapshot.Name + snapshotLabel = model.SnapshotId } if !model.AssumeYes { From 2051944fcc7442ee03b8ff6b66f7532aeb7b68cd Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Tue, 24 Jun 2025 09:26:47 +0200 Subject: [PATCH 24/27] update docs --- docs/stackit_volume_snapshot_create.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/stackit_volume_snapshot_create.md b/docs/stackit_volume_snapshot_create.md index 4ed86ad39..932b0a0a1 100644 --- a/docs/stackit_volume_snapshot_create.md +++ b/docs/stackit_volume_snapshot_create.md @@ -29,7 +29,7 @@ stackit volume snapshot create [flags] -h, --help Help for "stackit volume snapshot create" --labels stringToString Key-value string pairs as labels (default []) --name string Name of the snapshot - --volume-id string ID of the volume from which a snapshot should be created + --volume-id string Volume ID ``` ### Options inherited from parent commands From 246b6f4c14ed7c0ce48ae360afcb51d18079b292 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Wed, 25 Jun 2025 00:10:56 +0200 Subject: [PATCH 25/27] fix labelselector, config volumeID --- internal/cmd/volume/snapshot/create/create.go | 2 +- internal/cmd/volume/snapshot/list/list.go | 2 +- internal/cmd/volume/snapshot/list/list_test.go | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/cmd/volume/snapshot/create/create.go b/internal/cmd/volume/snapshot/create/create.go index a9569c351..856b0a929 100644 --- a/internal/cmd/volume/snapshot/create/create.go +++ b/internal/cmd/volume/snapshot/create/create.go @@ -118,7 +118,7 @@ func NewCmd(params *params.CmdParams) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().Var(flags.UUIDFlag(), volumeIdFlag, "Volume ID") + cmd.Flags().Var(flags.UUIDFlag(), volumeIdFlag, "ID of the volume from which a snapshot should be created") cmd.Flags().String(nameFlag, "", "Name of the snapshot") cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") diff --git a/internal/cmd/volume/snapshot/list/list.go b/internal/cmd/volume/snapshot/list/list.go index 6c5e3a8b2..ec47d9a32 100644 --- a/internal/cmd/volume/snapshot/list/list.go +++ b/internal/cmd/volume/snapshot/list/list.go @@ -139,7 +139,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListSnapshotsRequest { req := apiClient.ListSnapshots(ctx, model.ProjectId) if model.LabelSelector != nil { - req.LabelSelector(*model.LabelSelector) + req = req.LabelSelector(*model.LabelSelector) } return req } diff --git a/internal/cmd/volume/snapshot/list/list_test.go b/internal/cmd/volume/snapshot/list/list_test.go index 75ff2013f..e1f68f1b1 100644 --- a/internal/cmd/volume/snapshot/list/list_test.go +++ b/internal/cmd/volume/snapshot/list/list_test.go @@ -52,6 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { func fixtureRequest(mods ...func(request *iaas.ApiListSnapshotsRequest)) iaas.ApiListSnapshotsRequest { request := testClient.ListSnapshots(testCtx, testProjectId) + request = request.LabelSelector("key1=value1") for _, mod := range mods { mod(&request) } @@ -174,6 +175,15 @@ func TestBuildRequest(t *testing.T) { model: fixtureInputModel(), expectedRequest: fixtureRequest(), }, + { + description: "without label selector", + model: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiListSnapshotsRequest) { + *request = testClient.ListSnapshots(testCtx, testProjectId) + }), + }, } for _, tt := range tests { From 67b8a50629e6360a945d2dfad71a3857f1ddf596 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Wed, 25 Jun 2025 00:12:32 +0200 Subject: [PATCH 26/27] update docs --- docs/stackit_volume_snapshot_create.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/stackit_volume_snapshot_create.md b/docs/stackit_volume_snapshot_create.md index 932b0a0a1..4ed86ad39 100644 --- a/docs/stackit_volume_snapshot_create.md +++ b/docs/stackit_volume_snapshot_create.md @@ -29,7 +29,7 @@ stackit volume snapshot create [flags] -h, --help Help for "stackit volume snapshot create" --labels stringToString Key-value string pairs as labels (default []) --name string Name of the snapshot - --volume-id string Volume ID + --volume-id string ID of the volume from which a snapshot should be created ``` ### Options inherited from parent commands From b3b4c69619a059850c65ea677909000eb1501cc6 Mon Sep 17 00:00:00 2001 From: Benjosh95 Date: Wed, 25 Jun 2025 00:20:53 +0200 Subject: [PATCH 27/27] refactoring using new util func PtrGigaByteSizeDefault --- internal/cmd/volume/backup/describe/describe.go | 2 +- internal/cmd/volume/backup/list/list.go | 2 +- internal/cmd/volume/snapshot/describe/describe.go | 2 +- internal/cmd/volume/snapshot/list/list.go | 2 +- internal/pkg/utils/utils.go | 9 +++++++++ 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/cmd/volume/backup/describe/describe.go b/internal/cmd/volume/backup/describe/describe.go index f181195d9..d3322f6d0 100644 --- a/internal/cmd/volume/backup/describe/describe.go +++ b/internal/cmd/volume/backup/describe/describe.go @@ -128,7 +128,7 @@ func outputResult(p *print.Printer, outputFormat string, backup *iaas.Backup) er table.AddSeparator() table.AddRow("NAME", utils.PtrString(backup.Name)) table.AddSeparator() - table.AddRow("SIZE", utils.PtrByteSizeDefault(backup.Size, "")) + table.AddRow("SIZE", utils.PtrGigaByteSizeDefault(backup.Size, "n/a")) table.AddSeparator() table.AddRow("STATUS", utils.PtrString(backup.Status)) table.AddSeparator() diff --git a/internal/cmd/volume/backup/list/list.go b/internal/cmd/volume/backup/list/list.go index 540c00480..f3d7062c7 100644 --- a/internal/cmd/volume/backup/list/list.go +++ b/internal/cmd/volume/backup/list/list.go @@ -184,7 +184,7 @@ func outputResult(p *print.Printer, outputFormat string, backups []iaas.Backup) table.AddRow( utils.PtrString(backup.Id), utils.PtrString(backup.Name), - utils.PtrByteSizeDefault(backup.Size, ""), + utils.PtrGigaByteSizeDefault(backup.Size, "n/a"), utils.PtrString(backup.Status), utils.PtrString(backup.SnapshotId), utils.PtrString(backup.VolumeId), diff --git a/internal/cmd/volume/snapshot/describe/describe.go b/internal/cmd/volume/snapshot/describe/describe.go index fbbe7ba28..7ae36212e 100644 --- a/internal/cmd/volume/snapshot/describe/describe.go +++ b/internal/cmd/volume/snapshot/describe/describe.go @@ -127,7 +127,7 @@ func outputResult(p *print.Printer, outputFormat string, snapshot *iaas.Snapshot table.AddSeparator() table.AddRow("NAME", utils.PtrString(snapshot.Name)) table.AddSeparator() - table.AddRow("SIZE", utils.PtrByteSizeDefault(snapshot.Size, "")) + table.AddRow("SIZE", utils.PtrGigaByteSizeDefault(snapshot.Size, "n/a")) table.AddSeparator() table.AddRow("STATUS", utils.PtrString(snapshot.Status)) table.AddSeparator() diff --git a/internal/cmd/volume/snapshot/list/list.go b/internal/cmd/volume/snapshot/list/list.go index ec47d9a32..83b59987c 100644 --- a/internal/cmd/volume/snapshot/list/list.go +++ b/internal/cmd/volume/snapshot/list/list.go @@ -182,7 +182,7 @@ func outputResult(p *print.Printer, outputFormat string, snapshots []iaas.Snapsh table.AddRow( utils.PtrString(snapshot.Id), utils.PtrString(snapshot.Name), - utils.PtrByteSizeDefault(snapshot.Size, ""), + utils.PtrGigaByteSizeDefault(snapshot.Size, "n/a"), utils.PtrString(snapshot.Status), utils.PtrString(snapshot.VolumeId), labelsString, diff --git a/internal/pkg/utils/utils.go b/internal/pkg/utils/utils.go index a26d7b4b2..2db0936b8 100644 --- a/internal/pkg/utils/utils.go +++ b/internal/pkg/utils/utils.go @@ -119,6 +119,15 @@ func PtrByteSizeDefault(size *int64, defaultValue string) string { return bytesize.New(float64(*size)).String() } +// PtrGigaByteSizeDefault return the value of an int64 pointer to a string representation of gigabytes. If the pointer is nil, +// it returns the [defaultValue]. +func PtrGigaByteSizeDefault(size *int64, defaultValue string) string { + if size == nil { + return defaultValue + } + return (bytesize.New(float64(*size)) * bytesize.GB).String() +} + // Base64Encode encodes a []byte to a base64 representation as string func Base64Encode(message []byte) string { b := make([]byte, base64.StdEncoding.EncodedLen(len(message)))