Skip to content

Commit 94e43e5

Browse files
committed
add snapshots update subcommand
1 parent 0aaa22f commit 94e43e5

File tree

2 files changed

+397
-0
lines changed

2 files changed

+397
-0
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package update
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
"github.com/stackitcloud/stackit-cli/internal/cmd/params"
9+
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
10+
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
11+
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
12+
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
13+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
14+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
15+
"github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
16+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
17+
18+
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
19+
)
20+
21+
const (
22+
snapshotIdArg = "SNAPSHOT_ID"
23+
nameFlag = "name"
24+
labelsFlag = "labels"
25+
)
26+
27+
type inputModel struct {
28+
*globalflags.GlobalFlagModel
29+
SnapshotId string
30+
Name *string
31+
Labels map[string]string
32+
}
33+
34+
func NewCmd(params *params.CmdParams) *cobra.Command {
35+
cmd := &cobra.Command{
36+
Use: fmt.Sprintf("update %s", snapshotIdArg),
37+
Short: "Updates a snapshot",
38+
Long: "Updates a snapshot by its ID.",
39+
Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID),
40+
Example: examples.Build(
41+
examples.NewExample(
42+
`Update a snapshot name`,
43+
"$ stackit volume snapshot update xxx-xxx-xxx --name my-new-name"),
44+
examples.NewExample(
45+
`Update a snapshot labels`,
46+
"$ stackit volume snapshot update xxx-xxx-xxx --labels key1=value1,key2=value2"),
47+
),
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
ctx := context.Background()
50+
model, err := parseInput(params.Printer, cmd, args)
51+
if err != nil {
52+
return err
53+
}
54+
55+
// Configure API client
56+
apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion)
57+
if err != nil {
58+
return err
59+
}
60+
61+
// Get snapshot name for label
62+
snapshotLabel := model.SnapshotId
63+
snapshot, err := apiClient.GetSnapshot(ctx, model.ProjectId, model.SnapshotId).Execute()
64+
if err != nil {
65+
params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err)
66+
} else if snapshot != nil && snapshot.Name != nil {
67+
snapshotLabel = *snapshot.Name
68+
}
69+
70+
if !model.AssumeYes {
71+
prompt := fmt.Sprintf("Are you sure you want to update snapshot %q?", snapshotLabel)
72+
err = params.Printer.PromptForConfirmation(prompt)
73+
if err != nil {
74+
return err
75+
}
76+
}
77+
78+
// Call API
79+
req := buildRequest(ctx, model, apiClient)
80+
_, err = req.Execute()
81+
if err != nil {
82+
return fmt.Errorf("update snapshot: %w", err)
83+
}
84+
85+
params.Printer.Info("Updated snapshot %q\n", snapshotLabel)
86+
return nil
87+
},
88+
}
89+
90+
configureFlags(cmd)
91+
return cmd
92+
}
93+
94+
func configureFlags(cmd *cobra.Command) {
95+
cmd.Flags().String(nameFlag, "", "Name of the snapshot")
96+
cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels")
97+
}
98+
99+
func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
100+
snapshotId := inputArgs[0]
101+
102+
globalFlags := globalflags.Parse(p, cmd)
103+
if globalFlags.ProjectId == "" {
104+
return nil, &errors.ProjectIdError{}
105+
}
106+
107+
name := flags.FlagToStringPointer(p, cmd, nameFlag)
108+
labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag)
109+
if labels == nil {
110+
labels = &map[string]string{}
111+
}
112+
113+
if name == nil && len(*labels) == 0 {
114+
return nil, fmt.Errorf("either name or labels must be provided")
115+
}
116+
117+
model := inputModel{
118+
GlobalFlagModel: globalFlags,
119+
SnapshotId: snapshotId,
120+
Name: name,
121+
Labels: *labels,
122+
}
123+
124+
if p.IsVerbosityDebug() {
125+
modelStr, err := print.BuildDebugStrFromInputModel(model)
126+
if err != nil {
127+
p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
128+
} else {
129+
p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
130+
}
131+
}
132+
133+
return &model, nil
134+
}
135+
136+
func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateSnapshotRequest {
137+
req := apiClient.UpdateSnapshot(ctx, model.ProjectId, model.SnapshotId)
138+
payload := iaas.NewUpdateSnapshotPayloadWithDefaults()
139+
payload.Name = model.Name
140+
141+
// Convert labels to map[string]interface{}
142+
if len(model.Labels) > 0 {
143+
labelsMap := map[string]interface{}{}
144+
for k, v := range model.Labels {
145+
labelsMap[k] = v
146+
}
147+
payload.Labels = &labelsMap
148+
}
149+
150+
req = req.UpdateSnapshotPayload(*payload)
151+
return req
152+
}
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package update
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stackitcloud/stackit-cli/internal/cmd/params"
8+
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
9+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
10+
11+
"github.com/google/go-cmp/cmp"
12+
"github.com/google/go-cmp/cmp/cmpopts"
13+
"github.com/google/uuid"
14+
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
15+
)
16+
17+
type testCtxKey struct{}
18+
19+
var (
20+
testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
21+
testClient = &iaas.APIClient{}
22+
testProjectId = uuid.NewString()
23+
testSnapshotId = uuid.NewString()
24+
testName = "test-snapshot"
25+
testLabels = map[string]string{"key1": "value1"}
26+
)
27+
28+
func fixtureArgValues(mods ...func(argValues []string)) []string {
29+
argValues := []string{
30+
testSnapshotId,
31+
}
32+
for _, mod := range mods {
33+
mod(argValues)
34+
}
35+
return argValues
36+
}
37+
38+
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
39+
flagValues := map[string]string{
40+
globalflags.ProjectIdFlag: testProjectId,
41+
nameFlag: testName,
42+
labelsFlag: "key1=value1",
43+
}
44+
for _, mod := range mods {
45+
mod(flagValues)
46+
}
47+
return flagValues
48+
}
49+
50+
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
51+
model := &inputModel{
52+
GlobalFlagModel: &globalflags.GlobalFlagModel{
53+
ProjectId: testProjectId,
54+
Verbosity: globalflags.VerbosityDefault,
55+
},
56+
SnapshotId: testSnapshotId,
57+
Name: &testName,
58+
Labels: testLabels,
59+
}
60+
for _, mod := range mods {
61+
mod(model)
62+
}
63+
return model
64+
}
65+
66+
func fixtureRequest(mods ...func(request *iaas.ApiUpdateSnapshotRequest)) iaas.ApiUpdateSnapshotRequest {
67+
request := testClient.UpdateSnapshot(testCtx, testProjectId, testSnapshotId)
68+
payload := iaas.NewUpdateSnapshotPayloadWithDefaults()
69+
payload.Name = &testName
70+
71+
// Convert test labels to map[string]interface{}
72+
labelsMap := map[string]interface{}{}
73+
for k, v := range testLabels {
74+
labelsMap[k] = v
75+
}
76+
payload.Labels = &labelsMap
77+
78+
request = request.UpdateSnapshotPayload(*payload)
79+
for _, mod := range mods {
80+
mod(&request)
81+
}
82+
return request
83+
}
84+
85+
func TestParseInput(t *testing.T) {
86+
tests := []struct {
87+
description string
88+
argValues []string
89+
flagValues map[string]string
90+
isValid bool
91+
expectedModel *inputModel
92+
}{
93+
{
94+
description: "base",
95+
argValues: fixtureArgValues(),
96+
flagValues: fixtureFlagValues(),
97+
isValid: true,
98+
expectedModel: fixtureInputModel(),
99+
},
100+
{
101+
description: "no values",
102+
argValues: []string{},
103+
flagValues: map[string]string{},
104+
isValid: false,
105+
},
106+
{
107+
description: "no arg values",
108+
argValues: []string{},
109+
flagValues: fixtureFlagValues(),
110+
isValid: false,
111+
},
112+
{
113+
description: "no flag values",
114+
argValues: fixtureArgValues(),
115+
flagValues: map[string]string{},
116+
isValid: false,
117+
},
118+
{
119+
description: "project id missing",
120+
argValues: fixtureArgValues(),
121+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
122+
delete(flagValues, globalflags.ProjectIdFlag)
123+
}),
124+
isValid: false,
125+
},
126+
{
127+
description: "project id invalid",
128+
argValues: fixtureArgValues(),
129+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
130+
flagValues[globalflags.ProjectIdFlag] = "invalid-uuid"
131+
}),
132+
isValid: false,
133+
},
134+
{
135+
description: "snapshot id invalid",
136+
argValues: []string{"invalid-uuid"},
137+
flagValues: fixtureFlagValues(),
138+
isValid: false,
139+
},
140+
{
141+
description: "no update flags",
142+
argValues: fixtureArgValues(),
143+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
144+
delete(flagValues, nameFlag)
145+
delete(flagValues, labelsFlag)
146+
}),
147+
isValid: false,
148+
},
149+
{
150+
description: "only name flag",
151+
argValues: fixtureArgValues(),
152+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
153+
delete(flagValues, labelsFlag)
154+
}),
155+
isValid: true,
156+
expectedModel: fixtureInputModel(func(model *inputModel) {
157+
model.Labels = make(map[string]string)
158+
}),
159+
},
160+
{
161+
description: "only labels flag",
162+
argValues: fixtureArgValues(),
163+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
164+
delete(flagValues, nameFlag)
165+
}),
166+
isValid: true,
167+
expectedModel: fixtureInputModel(func(model *inputModel) {
168+
model.Name = nil
169+
}),
170+
},
171+
}
172+
173+
for _, tt := range tests {
174+
t.Run(tt.description, func(t *testing.T) {
175+
p := print.NewPrinter()
176+
cmd := NewCmd(&params.CmdParams{Printer: p})
177+
err := globalflags.Configure(cmd.Flags())
178+
if err != nil {
179+
t.Fatalf("configure global flags: %v", err)
180+
}
181+
182+
for flag, value := range tt.flagValues {
183+
err := cmd.Flags().Set(flag, value)
184+
if err != nil {
185+
if !tt.isValid {
186+
return
187+
}
188+
t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
189+
}
190+
}
191+
192+
err = cmd.ValidateArgs(tt.argValues)
193+
if err != nil {
194+
if !tt.isValid {
195+
return
196+
}
197+
t.Fatalf("error validating args: %v", err)
198+
}
199+
200+
model, err := parseInput(p, cmd, tt.argValues)
201+
if err != nil {
202+
if !tt.isValid {
203+
return
204+
}
205+
t.Fatalf("error parsing input: %v", err)
206+
}
207+
208+
if !tt.isValid {
209+
t.Fatalf("did not fail on invalid input")
210+
}
211+
diff := cmp.Diff(model, tt.expectedModel)
212+
if diff != "" {
213+
t.Fatalf("Data does not match: %s", diff)
214+
}
215+
})
216+
}
217+
}
218+
219+
func TestBuildRequest(t *testing.T) {
220+
tests := []struct {
221+
description string
222+
model *inputModel
223+
expectedRequest iaas.ApiUpdateSnapshotRequest
224+
}{
225+
{
226+
description: "base",
227+
model: fixtureInputModel(),
228+
expectedRequest: fixtureRequest(),
229+
},
230+
}
231+
232+
for _, tt := range tests {
233+
t.Run(tt.description, func(t *testing.T) {
234+
request := buildRequest(testCtx, tt.model, testClient)
235+
236+
diff := cmp.Diff(request, tt.expectedRequest,
237+
cmp.AllowUnexported(tt.expectedRequest),
238+
cmpopts.EquateComparable(testCtx),
239+
)
240+
if diff != "" {
241+
t.Fatalf("Data does not match: %s", diff)
242+
}
243+
})
244+
}
245+
}

0 commit comments

Comments
 (0)