Skip to content

Commit 6c9fe0e

Browse files
committed
add snapshot create subcommand
1 parent 787470f commit 6c9fe0e

File tree

2 files changed

+395
-0
lines changed

2 files changed

+395
-0
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package create
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/projectname"
16+
"github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
17+
"github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
18+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
19+
20+
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
21+
"github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
22+
)
23+
24+
const (
25+
volumeIdFlag = "volume-id"
26+
nameFlag = "name"
27+
labelsFlag = "labels"
28+
)
29+
30+
type inputModel struct {
31+
*globalflags.GlobalFlagModel
32+
VolumeID string
33+
Name *string
34+
Labels map[string]string
35+
}
36+
37+
func NewCmd(params *params.CmdParams) *cobra.Command {
38+
cmd := &cobra.Command{
39+
Use: "create",
40+
Short: "Creates a snapshot from a volume",
41+
Long: "Creates a snapshot from a volume.",
42+
Args: args.NoArgs,
43+
Example: examples.Build(
44+
examples.NewExample(
45+
`Create a snapshot from a volume`,
46+
"$ stackit volume snapshot create --volume-id xxx --project-id xxx"),
47+
examples.NewExample(
48+
`Create a snapshot with a name`,
49+
"$ stackit volume snapshot create --volume-id xxx --name my-snapshot --project-id xxx"),
50+
examples.NewExample(
51+
`Create a snapshot with labels`,
52+
"$ stackit volume snapshot create --volume-id xxx --labels key1=value1,key2=value2 --project-id xxx"),
53+
),
54+
RunE: func(cmd *cobra.Command, _ []string) error {
55+
ctx := context.Background()
56+
model, err := parseInput(params.Printer, cmd)
57+
if err != nil {
58+
return err
59+
}
60+
61+
// Configure API client
62+
apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion)
63+
if err != nil {
64+
return err
65+
}
66+
67+
projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd)
68+
if err != nil {
69+
params.Printer.Debug(print.ErrorLevel, "get project name: %v", err)
70+
projectLabel = model.ProjectId
71+
}
72+
73+
// Get volume name for label
74+
volumeLabel := model.VolumeID
75+
volume, err := apiClient.GetVolume(ctx, model.ProjectId, model.VolumeID).Execute()
76+
if err != nil {
77+
params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err)
78+
} else if volume != nil && volume.Name != nil {
79+
volumeLabel = *volume.Name
80+
}
81+
82+
if !model.AssumeYes {
83+
prompt := fmt.Sprintf("Are you sure you want to create snapshot from volume %q? (This cannot be undone)", volumeLabel)
84+
err = params.Printer.PromptForConfirmation(prompt)
85+
if err != nil {
86+
return err
87+
}
88+
}
89+
90+
// Call API
91+
req := buildRequest(ctx, model, apiClient)
92+
resp, err := req.Execute()
93+
if err != nil {
94+
return fmt.Errorf("create snapshot: %w", err)
95+
}
96+
97+
// Wait for async operation, if async mode not enabled
98+
if !model.Async {
99+
s := spinner.New(params.Printer)
100+
s.Start("Creating snapshot")
101+
resp, err = wait.CreateSnapshotWaitHandler(ctx, apiClient, model.ProjectId, *resp.Id).WaitWithContext(ctx)
102+
if err != nil {
103+
return fmt.Errorf("wait for snapshot creation: %w", err)
104+
}
105+
s.Stop()
106+
}
107+
108+
if model.Async {
109+
params.Printer.Info("Triggered snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, *resp.Id)
110+
} else {
111+
params.Printer.Info("Created snapshot of %q in %q. Snapshot ID: %s\n", volumeLabel, projectLabel, *resp.Id)
112+
}
113+
return nil
114+
},
115+
}
116+
117+
configureFlags(cmd)
118+
return cmd
119+
}
120+
121+
func configureFlags(cmd *cobra.Command) {
122+
cmd.Flags().String(volumeIdFlag, "", "ID of the volume from which a snapshot should be created")
123+
cmd.Flags().String(nameFlag, "", "Name of the snapshot")
124+
cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels")
125+
126+
err := flags.MarkFlagsRequired(cmd, volumeIdFlag)
127+
cobra.CheckErr(err)
128+
}
129+
130+
func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
131+
globalFlags := globalflags.Parse(p, cmd)
132+
if globalFlags.ProjectId == "" {
133+
return nil, &errors.ProjectIdError{}
134+
}
135+
136+
volumeID := flags.FlagToStringValue(p, cmd, volumeIdFlag)
137+
if volumeID == "" {
138+
return nil, fmt.Errorf("volume-id is required")
139+
}
140+
if err := utils.ValidateUUID(volumeID); err != nil {
141+
return nil, fmt.Errorf("volume-id must be a valid UUID")
142+
}
143+
144+
name := flags.FlagToStringPointer(p, cmd, nameFlag)
145+
labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag)
146+
if labels == nil {
147+
labels = &map[string]string{}
148+
}
149+
150+
model := inputModel{
151+
GlobalFlagModel: globalFlags,
152+
VolumeID: volumeID,
153+
Name: name,
154+
Labels: *labels,
155+
}
156+
157+
if p.IsVerbosityDebug() {
158+
modelStr, err := print.BuildDebugStrFromInputModel(model)
159+
if err != nil {
160+
p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
161+
} else {
162+
p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
163+
}
164+
}
165+
166+
return &model, nil
167+
}
168+
169+
func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateSnapshotRequest {
170+
req := apiClient.CreateSnapshot(ctx, model.ProjectId)
171+
payload := iaas.NewCreateSnapshotPayloadWithDefaults()
172+
payload.VolumeId = &model.VolumeID
173+
payload.Name = model.Name
174+
175+
// Convert labels to map[string]interface{}
176+
if len(model.Labels) > 0 {
177+
labelsMap := map[string]interface{}{}
178+
for k, v := range model.Labels {
179+
labelsMap[k] = v
180+
}
181+
payload.Labels = &labelsMap
182+
}
183+
184+
req = req.CreateSnapshotPayload(*payload)
185+
return req
186+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package create
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+
testVolumeId = uuid.NewString()
24+
testName = "test-snapshot"
25+
testLabels = map[string]string{"key1": "value1"}
26+
)
27+
28+
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
29+
flagValues := map[string]string{
30+
globalflags.ProjectIdFlag: testProjectId,
31+
volumeIdFlag: testVolumeId,
32+
nameFlag: testName,
33+
labelsFlag: "key1=value1",
34+
}
35+
for _, mod := range mods {
36+
mod(flagValues)
37+
}
38+
return flagValues
39+
}
40+
41+
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
42+
model := &inputModel{
43+
GlobalFlagModel: &globalflags.GlobalFlagModel{
44+
ProjectId: testProjectId,
45+
Verbosity: globalflags.VerbosityDefault,
46+
},
47+
VolumeID: testVolumeId,
48+
Name: &testName,
49+
Labels: testLabels,
50+
}
51+
for _, mod := range mods {
52+
mod(model)
53+
}
54+
return model
55+
}
56+
57+
func fixtureRequest(mods ...func(request *iaas.ApiCreateSnapshotRequest)) iaas.ApiCreateSnapshotRequest {
58+
request := testClient.CreateSnapshot(testCtx, testProjectId)
59+
payload := iaas.NewCreateSnapshotPayloadWithDefaults()
60+
payload.VolumeId = &testVolumeId
61+
payload.Name = &testName
62+
63+
// Convert test labels to map[string]interface{}
64+
labelsMap := map[string]interface{}{}
65+
for k, v := range testLabels {
66+
labelsMap[k] = v
67+
}
68+
payload.Labels = &labelsMap
69+
70+
request = request.CreateSnapshotPayload(*payload)
71+
for _, mod := range mods {
72+
mod(&request)
73+
}
74+
return request
75+
}
76+
77+
func TestParseInput(t *testing.T) {
78+
tests := []struct {
79+
description string
80+
flagValues map[string]string
81+
isValid bool
82+
expectedModel *inputModel
83+
}{
84+
{
85+
description: "base",
86+
flagValues: fixtureFlagValues(),
87+
isValid: true,
88+
expectedModel: fixtureInputModel(),
89+
},
90+
{
91+
description: "no values",
92+
flagValues: map[string]string{},
93+
isValid: false,
94+
},
95+
{
96+
description: "no volume id",
97+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
98+
delete(flagValues, volumeIdFlag)
99+
}),
100+
isValid: false,
101+
},
102+
{
103+
description: "project id missing",
104+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
105+
delete(flagValues, globalflags.ProjectIdFlag)
106+
}),
107+
isValid: false,
108+
},
109+
{
110+
description: "project id invalid",
111+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
112+
flagValues[globalflags.ProjectIdFlag] = "invalid-uuid"
113+
}),
114+
isValid: false,
115+
},
116+
{
117+
description: "volume id invalid",
118+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
119+
flagValues[volumeIdFlag] = "invalid-uuid"
120+
}),
121+
isValid: false,
122+
},
123+
{
124+
description: "only required flags",
125+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
126+
delete(flagValues, nameFlag)
127+
delete(flagValues, labelsFlag)
128+
}),
129+
isValid: true,
130+
expectedModel: fixtureInputModel(func(model *inputModel) {
131+
model.Name = nil
132+
model.Labels = make(map[string]string)
133+
}),
134+
},
135+
}
136+
137+
for _, tt := range tests {
138+
t.Run(tt.description, func(t *testing.T) {
139+
p := print.NewPrinter()
140+
cmd := NewCmd(&params.CmdParams{Printer: p})
141+
err := globalflags.Configure(cmd.Flags())
142+
if err != nil {
143+
t.Fatalf("configure global flags: %v", err)
144+
}
145+
146+
for flag, value := range tt.flagValues {
147+
err := cmd.Flags().Set(flag, value)
148+
if err != nil {
149+
if !tt.isValid {
150+
return
151+
}
152+
t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
153+
}
154+
}
155+
156+
err = cmd.ValidateRequiredFlags()
157+
if err != nil {
158+
if !tt.isValid {
159+
return
160+
}
161+
t.Fatalf("error validating flags: %v", err)
162+
}
163+
164+
model, err := parseInput(p, cmd)
165+
if err != nil {
166+
if !tt.isValid {
167+
return
168+
}
169+
t.Fatalf("error parsing input: %v", err)
170+
}
171+
172+
if !tt.isValid {
173+
t.Fatalf("did not fail on invalid input")
174+
}
175+
diff := cmp.Diff(model, tt.expectedModel)
176+
if diff != "" {
177+
t.Fatalf("Data does not match: %s", diff)
178+
}
179+
})
180+
}
181+
}
182+
183+
func TestBuildRequest(t *testing.T) {
184+
tests := []struct {
185+
description string
186+
model *inputModel
187+
expectedRequest iaas.ApiCreateSnapshotRequest
188+
}{
189+
{
190+
description: "base",
191+
model: fixtureInputModel(),
192+
expectedRequest: fixtureRequest(),
193+
},
194+
}
195+
196+
for _, tt := range tests {
197+
t.Run(tt.description, func(t *testing.T) {
198+
request := buildRequest(testCtx, tt.model, testClient)
199+
200+
diff := cmp.Diff(request, tt.expectedRequest,
201+
cmp.AllowUnexported(tt.expectedRequest),
202+
cmpopts.EquateComparable(testCtx),
203+
)
204+
if diff != "" {
205+
t.Fatalf("Data does not match: %s", diff)
206+
}
207+
})
208+
}
209+
}

0 commit comments

Comments
 (0)