Skip to content

Commit afcf18c

Browse files
committed
add volume backup restore and restore tests
1 parent d0f029d commit afcf18c

File tree

2 files changed

+334
-0
lines changed

2 files changed

+334
-0
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package restore
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/globalflags"
13+
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
14+
"github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
15+
"github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
16+
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
17+
18+
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
19+
"github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
20+
)
21+
22+
const (
23+
backupIdArg = "BACKUP_ID"
24+
)
25+
26+
type inputModel struct {
27+
*globalflags.GlobalFlagModel
28+
BackupId string
29+
}
30+
31+
func NewCmd(params *params.CmdParams) *cobra.Command {
32+
cmd := &cobra.Command{
33+
Use: fmt.Sprintf("restore %s", backupIdArg),
34+
Short: "Restores a backup",
35+
Long: "Restores a backup by its ID.",
36+
Args: args.SingleArg(backupIdArg, utils.ValidateUUID),
37+
Example: examples.Build(
38+
examples.NewExample(
39+
`Restore a backup`,
40+
"$ stackit volume backup restore xxx-xxx-xxx"),
41+
examples.NewExample(
42+
`Restore a backup and wait for restore to be completed`,
43+
"$ stackit volume backup restore xxx-xxx-xxx --async=false"),
44+
),
45+
RunE: func(cmd *cobra.Command, args []string) error {
46+
ctx := context.Background()
47+
model, err := parseInput(params.Printer, cmd, args)
48+
if err != nil {
49+
return err
50+
}
51+
52+
// Configure API client
53+
apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion)
54+
if err != nil {
55+
return err
56+
}
57+
58+
// Get backup details for labels
59+
backup, err := apiClient.GetBackup(ctx, model.ProjectId, model.BackupId).Execute()
60+
if err != nil {
61+
params.Printer.Debug(print.ErrorLevel, "get backup details: %v", err)
62+
}
63+
backupLabel := model.BackupId
64+
if backup != nil && backup.Name != nil {
65+
backupLabel = *backup.Name
66+
}
67+
68+
// Get source details for labels
69+
var sourceLabel string
70+
if backup != nil && backup.VolumeId != nil {
71+
volume, err := apiClient.GetVolume(ctx, model.ProjectId, *backup.VolumeId).Execute()
72+
if err != nil {
73+
params.Printer.Debug(print.ErrorLevel, "get volume details: %v", err)
74+
sourceLabel = *backup.VolumeId
75+
} else if volume.Name != nil {
76+
sourceLabel = *volume.Name
77+
} else {
78+
sourceLabel = *backup.VolumeId
79+
}
80+
}
81+
82+
if !model.AssumeYes {
83+
prompt := fmt.Sprintf("Are you sure you want to restore %q with backup %q? (This cannot be undone)", sourceLabel, backupLabel)
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+
err = req.Execute()
93+
if err != nil {
94+
return fmt.Errorf("restore backup: %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("Restoring backup")
101+
_, err = wait.RestoreBackupWaitHandler(ctx, apiClient, model.ProjectId, model.BackupId).WaitWithContext(ctx)
102+
if err != nil {
103+
return fmt.Errorf("wait for backup restore: %w", err)
104+
}
105+
s.Stop()
106+
}
107+
108+
projectLabel := model.ProjectId
109+
110+
if model.Async {
111+
params.Printer.Info("Triggered restore of %q with %q in %q\n", sourceLabel, backupLabel, projectLabel)
112+
} else {
113+
params.Printer.Info("Restored %q with %q in %q\n", sourceLabel, backupLabel, projectLabel)
114+
}
115+
return nil
116+
},
117+
}
118+
return cmd
119+
}
120+
121+
func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
122+
backupId := inputArgs[0]
123+
124+
globalFlags := globalflags.Parse(p, cmd)
125+
if globalFlags.ProjectId == "" {
126+
return nil, &errors.ProjectIdError{}
127+
}
128+
129+
model := inputModel{
130+
GlobalFlagModel: globalFlags,
131+
BackupId: backupId,
132+
}
133+
134+
if p.IsVerbosityDebug() {
135+
modelStr, err := print.BuildDebugStrFromInputModel(model)
136+
if err != nil {
137+
p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
138+
} else {
139+
p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
140+
}
141+
}
142+
143+
return &model, nil
144+
}
145+
146+
func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRestoreBackupRequest {
147+
req := apiClient.RestoreBackup(ctx, model.ProjectId, model.BackupId)
148+
return req
149+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package restore
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+
var projectIdFlag = globalflags.ProjectIdFlag
18+
19+
type testCtxKey struct{}
20+
21+
var (
22+
testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
23+
testClient = &iaas.APIClient{}
24+
testProjectId = uuid.NewString()
25+
testBackupId = uuid.NewString()
26+
)
27+
28+
func fixtureArgValues(mods ...func(argValues []string)) []string {
29+
argValues := []string{
30+
testBackupId,
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+
projectIdFlag: testProjectId,
41+
}
42+
for _, mod := range mods {
43+
mod(flagValues)
44+
}
45+
return flagValues
46+
}
47+
48+
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
49+
model := &inputModel{
50+
GlobalFlagModel: &globalflags.GlobalFlagModel{
51+
ProjectId: testProjectId,
52+
Verbosity: globalflags.VerbosityDefault,
53+
},
54+
BackupId: testBackupId,
55+
}
56+
for _, mod := range mods {
57+
mod(model)
58+
}
59+
return model
60+
}
61+
62+
func fixtureRequest(mods ...func(request *iaas.ApiRestoreBackupRequest)) iaas.ApiRestoreBackupRequest {
63+
request := testClient.RestoreBackup(testCtx, testProjectId, testBackupId)
64+
for _, mod := range mods {
65+
mod(&request)
66+
}
67+
return request
68+
}
69+
70+
func TestParseInput(t *testing.T) {
71+
tests := []struct {
72+
description string
73+
argValues []string
74+
flagValues map[string]string
75+
isValid bool
76+
expectedModel *inputModel
77+
}{
78+
{
79+
description: "base",
80+
argValues: fixtureArgValues(),
81+
flagValues: fixtureFlagValues(),
82+
isValid: true,
83+
expectedModel: fixtureInputModel(),
84+
},
85+
{
86+
description: "no values",
87+
argValues: []string{},
88+
flagValues: map[string]string{},
89+
isValid: false,
90+
},
91+
{
92+
description: "no arg values",
93+
argValues: []string{},
94+
flagValues: fixtureFlagValues(),
95+
isValid: false,
96+
},
97+
{
98+
description: "no flag values",
99+
argValues: fixtureArgValues(),
100+
flagValues: map[string]string{},
101+
isValid: false,
102+
},
103+
{
104+
description: "project id missing",
105+
argValues: fixtureArgValues(),
106+
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
107+
delete(flagValues, projectIdFlag)
108+
}),
109+
isValid: false,
110+
},
111+
}
112+
113+
for _, tt := range tests {
114+
t.Run(tt.description, func(t *testing.T) {
115+
p := print.NewPrinter()
116+
cmd := NewCmd(&params.CmdParams{Printer: p})
117+
err := globalflags.Configure(cmd.Flags())
118+
if err != nil {
119+
t.Fatalf("configure global flags: %v", err)
120+
}
121+
122+
for flag, value := range tt.flagValues {
123+
err := cmd.Flags().Set(flag, value)
124+
if err != nil {
125+
if !tt.isValid {
126+
return
127+
}
128+
t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
129+
}
130+
}
131+
132+
err = cmd.ValidateArgs(tt.argValues)
133+
if err != nil {
134+
if !tt.isValid {
135+
return
136+
}
137+
t.Fatalf("error validating args: %v", err)
138+
}
139+
140+
model, err := parseInput(p, cmd, tt.argValues)
141+
if err != nil {
142+
if !tt.isValid {
143+
return
144+
}
145+
t.Fatalf("error parsing input: %v", err)
146+
}
147+
148+
if !tt.isValid {
149+
t.Fatalf("did not fail on invalid input")
150+
}
151+
diff := cmp.Diff(model, tt.expectedModel)
152+
if diff != "" {
153+
t.Fatalf("Data does not match: %s", diff)
154+
}
155+
})
156+
}
157+
}
158+
159+
func TestBuildRequest(t *testing.T) {
160+
tests := []struct {
161+
description string
162+
model *inputModel
163+
expectedRequest iaas.ApiRestoreBackupRequest
164+
}{
165+
{
166+
description: "base",
167+
model: fixtureInputModel(),
168+
expectedRequest: fixtureRequest(),
169+
},
170+
}
171+
172+
for _, tt := range tests {
173+
t.Run(tt.description, func(t *testing.T) {
174+
request := buildRequest(testCtx, tt.model, testClient)
175+
176+
diff := cmp.Diff(request, tt.expectedRequest,
177+
cmp.AllowUnexported(tt.expectedRequest),
178+
cmpopts.EquateComparable(testCtx),
179+
)
180+
if diff != "" {
181+
t.Fatalf("Data does not match: %s", diff)
182+
}
183+
})
184+
}
185+
}

0 commit comments

Comments
 (0)