From 6ddaedcb1b543d0c6b5ca45264349f9a61a83815 Mon Sep 17 00:00:00 2001 From: Alexander Dahmen Date: Fri, 8 May 2026 13:07:03 +0200 Subject: [PATCH] feat(sfs): Onboard snaptshot update command STACKITCLI-393 Signed-off-by: Alexander Dahmen --- docs/stackit_beta_sfs_snapshot.md | 1 + docs/stackit_beta_sfs_snapshot_update.md | 46 ++++ internal/cmd/beta/sfs/snapshot/snapshot.go | 2 + .../cmd/beta/sfs/snapshot/update/update.go | 151 +++++++++++ .../beta/sfs/snapshot/update/update_test.go | 248 ++++++++++++++++++ 5 files changed, 448 insertions(+) create mode 100644 docs/stackit_beta_sfs_snapshot_update.md create mode 100644 internal/cmd/beta/sfs/snapshot/update/update.go create mode 100644 internal/cmd/beta/sfs/snapshot/update/update_test.go diff --git a/docs/stackit_beta_sfs_snapshot.md b/docs/stackit_beta_sfs_snapshot.md index 152fde3b1..4428dd9b8 100644 --- a/docs/stackit_beta_sfs_snapshot.md +++ b/docs/stackit_beta_sfs_snapshot.md @@ -34,4 +34,5 @@ stackit beta sfs snapshot [flags] * [stackit beta sfs snapshot delete](./stackit_beta_sfs_snapshot_delete.md) - Deletes a snapshot * [stackit beta sfs snapshot describe](./stackit_beta_sfs_snapshot_describe.md) - Shows details of a snapshot * [stackit beta sfs snapshot list](./stackit_beta_sfs_snapshot_list.md) - Lists all snapshots of a resource pool +* [stackit beta sfs snapshot update](./stackit_beta_sfs_snapshot_update.md) - Updates a new snapshot of a resource pool diff --git a/docs/stackit_beta_sfs_snapshot_update.md b/docs/stackit_beta_sfs_snapshot_update.md new file mode 100644 index 000000000..3d59f9513 --- /dev/null +++ b/docs/stackit_beta_sfs_snapshot_update.md @@ -0,0 +1,46 @@ +## stackit beta sfs snapshot update + +Updates a new snapshot of a resource pool + +### Synopsis + +Updates a new snapshot of a resource pool. + +``` +stackit beta sfs snapshot update SNAPSHOT_NAME [flags] +``` + +### Examples + +``` + Updates the name of a snapshot with name "snapshot-name" of a resource pool with ID "xxx" + $ stackit beta sfs snapshot update snapshot-name --resource-pool-id xxx --name new-snapshot-name + + Updates the comment of a snapshot with name "snapshot-name" of a resource pool with ID "xxx" + $ stackit beta sfs snapshot update snapshot-name --resource-pool-id xxx --comment "snapshot-comment" +``` + +### Options + +``` + --comment string A comment to add more information to the snapshot + -h, --help Help for "stackit beta sfs snapshot update" + --name string Snapshot name + --resource-pool-id string The resource pool from which the snapshot should be updated +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs snapshot](./stackit_beta_sfs_snapshot.md) - Provides functionality for SFS snapshots + diff --git a/internal/cmd/beta/sfs/snapshot/snapshot.go b/internal/cmd/beta/sfs/snapshot/snapshot.go index aab304c52..443d96df6 100644 --- a/internal/cmd/beta/sfs/snapshot/snapshot.go +++ b/internal/cmd/beta/sfs/snapshot/snapshot.go @@ -5,6 +5,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -29,4 +30,5 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(delete.NewCmd(params)) cmd.AddCommand(describe.NewCmd(params)) cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/beta/sfs/snapshot/update/update.go b/internal/cmd/beta/sfs/snapshot/update/update.go new file mode 100644 index 000000000..1651eace4 --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/update/update.go @@ -0,0 +1,151 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/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/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + snapshotNameArg = "SNAPSHOT_NAME" + + resourcePoolIdFlag = "resource-pool-id" + newSnapshotNameFlag = "name" + commentFlag = "comment" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string + SnapshotName string + NewSnapshotName string + Comment *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", snapshotNameArg), + Short: "Updates a new snapshot of a resource pool", + Long: "Updates a new snapshot of a resource pool.", + Args: args.SingleArg(snapshotNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Updates the name of a snapshot with name "snapshot-name" of a resource pool with ID "xxx"`, + "$ stackit beta sfs snapshot update snapshot-name --resource-pool-id xxx --name new-snapshot-name", + ), + examples.NewExample( + `Updates the comment of a snapshot with name "snapshot-name" of a resource pool with ID "xxx"`, + `$ stackit beta sfs snapshot update snapshot-name --resource-pool-id xxx --comment "snapshot-comment"`, + ), + ), + 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 + } + + resourcePoolLabel, err := sfsUtils.GetResourcePoolName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ResourcePoolId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get resource pool name: %v", err) + resourcePoolLabel = model.ResourcePoolId + } else if resourcePoolLabel == "" { + resourcePoolLabel = model.ResourcePoolId + } + + prompt := fmt.Sprintf("Are you sure you want to update the snapshot %q for resource pool %q?", model.SnapshotName, resourcePoolLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update snapshot: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.SnapshotName, resourcePoolLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(newSnapshotNameFlag, "", "Snapshot name") + cmd.Flags().String(commentFlag, "", "A comment to add more information to the snapshot") + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool from which the snapshot should be updated") + + cmd.MarkFlagsOneRequired(newSnapshotNameFlag, commentFlag) + err := flags.MarkFlagsRequired(cmd, resourcePoolIdFlag) + cobra.CheckErr(err) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiUpdateResourcePoolSnapshotRequest { + req := apiClient.DefaultAPI.UpdateResourcePoolSnapshot(ctx, model.ProjectId, model.Region, model.ResourcePoolId, model.SnapshotName) + + payload := sfs.UpdateResourcePoolSnapshotPayload{ + Comment: *sfs.NewNullableString(model.Comment), + } + + if model.NewSnapshotName != "" { + payload.Name = *sfs.NewNullableString(utils.Ptr(model.NewSnapshotName)) + } + return req.UpdateResourcePoolSnapshotPayload(payload) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + snapshotName := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SnapshotName: snapshotName, + NewSnapshotName: flags.FlagToStringValue(p, cmd, newSnapshotNameFlag), + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + Comment: flags.FlagToStringPointer(p, cmd, commentFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, snapshotLabel, resourcePoolLabel string, resp *sfs.UpdateResourcePoolSnapshotResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil || resp.ResourcePoolSnapshot == nil { + p.Outputln("SFS snapshot response is empty") + return nil + } + + p.Outputf( + "Updated snapshot %q for resource pool %q.\n", + snapshotLabel, + resourcePoolLabel, + ) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/snapshot/update/update_test.go b/internal/cmd/beta/sfs/snapshot/update/update_test.go new file mode 100644 index 000000000..a53ce6417 --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/update/update_test.go @@ -0,0 +1,248 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + sfs "github.com/stackitcloud/stackit-sdk-go/services/sfs/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{DefaultAPI: &sfs.DefaultAPIService{}} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testSnapshotName = "test-snapshot-name" +var testNewName = "test-new-name" +var testComment = "test-comment" +var testResourcePoolId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSnapshotName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + newSnapshotNameFlag: testNewName, + resourcePoolIdFlag: testResourcePoolId, + commentFlag: testComment, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + SnapshotName: testSnapshotName, + NewSnapshotName: testNewName, + ResourcePoolId: testResourcePoolId, + Comment: utils.Ptr(testComment), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiUpdateResourcePoolSnapshotRequest)) sfs.ApiUpdateResourcePoolSnapshotRequest { + request := testClient.DefaultAPI.UpdateResourcePoolSnapshot(testCtx, testProjectId, testRegion, testResourcePoolId, testSnapshotName) + request = request.UpdateResourcePoolSnapshotPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(request *sfs.UpdateResourcePoolSnapshotPayload)) sfs.UpdateResourcePoolSnapshotPayload { + payload := sfs.UpdateResourcePoolSnapshotPayload{ + Name: *sfs.NewNullableString(utils.Ptr(testNewName)), + Comment: *sfs.NewNullableString( + utils.Ptr(testComment), + ), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "either name or comment (only name set)", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commentFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Comment = nil + }), + }, + { + description: "either name or comment (only comment set)", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, newSnapshotNameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NewSnapshotName = "" + }), + }, + { + description: "missing both name and comment (at least one required)", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, newSnapshotNameFlag) + delete(flagValues, commentFlag) + }), + isValid: false, + }, + { + description: "missing required resourcePoolId", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, resourcePoolIdFlag) + }), + isValid: false, + }, + { + description: "invalid resource pool id 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid resource pool id 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "invalid-resource-pool-id" + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiUpdateResourcePoolSnapshotRequest + }{ + { + 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, sfs.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(sfs.NullableString{}, sfs.NullableInt32{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + snapshotLabel string + resourcePoolLabel string + resp *sfs.UpdateResourcePoolSnapshotResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + resp: &sfs.UpdateResourcePoolSnapshotResponse{}, + }, + wantErr: false, + }, + { + name: "set empty snapshot", + args: args{ + resp: &sfs.UpdateResourcePoolSnapshotResponse{ + ResourcePoolSnapshot: &sfs.ResourcePoolSnapshot{}, + }, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.snapshotLabel, tt.args.resourcePoolLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}