diff --git a/docs/stackit_mongodbflex_backup.md b/docs/stackit_mongodbflex_backup.md index 765096f4e..19e304c76 100644 --- a/docs/stackit_mongodbflex_backup.md +++ b/docs/stackit_mongodbflex_backup.md @@ -31,5 +31,8 @@ stackit mongodbflex backup [flags] * [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex * [stackit mongodbflex backup describe](./stackit_mongodbflex_backup_describe.md) - Shows details of a backup for a MongoDB Flex instance * [stackit mongodbflex backup list](./stackit_mongodbflex_backup_list.md) - Lists all backups which are available for a MongoDB Flex instance +* [stackit mongodbflex backup restore](./stackit_mongodbflex_backup_restore.md) - Restores a MongoDB Flex instance from a backup * [stackit mongodbflex backup restore-jobs](./stackit_mongodbflex_backup_restore-jobs.md) - Lists all restore jobs which have been run for a MongoDB Flex instance +* [stackit mongodbflex backup schedule](./stackit_mongodbflex_backup_schedule.md) - Shows details of the backup schedule and retention policy of a MongoDB Flex instance +* [stackit mongodbflex backup update-schedule](./stackit_mongodbflex_backup_update-schedule.md) - Updates the backup schedule and retention policy for a MongoDB Flex instance diff --git a/docs/stackit_mongodbflex_backup_restore-jobs.md b/docs/stackit_mongodbflex_backup_restore-jobs.md index de1d6686f..af5626306 100644 --- a/docs/stackit_mongodbflex_backup_restore-jobs.md +++ b/docs/stackit_mongodbflex_backup_restore-jobs.md @@ -14,13 +14,13 @@ stackit mongodbflex backup restore-jobs [flags] ``` List all restore jobs of instance with ID "xxx" - $ stackit mongodbflex backup list --instance-id xxx + $ stackit mongodbflex backup restore-jobs --instance-id xxx List all restore jobs of instance with ID "xxx" in JSON format - $ stackit mongodbflex backup list --instance-id xxx --output-format json + $ stackit mongodbflex backup restore-jobs --instance-id xxx --output-format json List up to 10 restore jobs of instance with ID "xxx" - $ stackit mongodbflex backup list --instance-id xxx --limit 10 + $ stackit mongodbflex backup restore-jobs --instance-id xxx --limit 10 ``` ### Options diff --git a/docs/stackit_mongodbflex_backup_restore.md b/docs/stackit_mongodbflex_backup_restore.md new file mode 100644 index 000000000..f0f8e00e8 --- /dev/null +++ b/docs/stackit_mongodbflex_backup_restore.md @@ -0,0 +1,51 @@ +## stackit mongodbflex backup restore + +Restores a MongoDB Flex instance from a backup + +### Synopsis + +Restores a MongoDB Flex instance from a backup of an instance or clones a MongoDB Flex instance from a point-in-time snapshot. +The backup is specified by a backup ID and the point-in-time snapshot is specified by a timestamp. +You can specify the instance to which the backup will be applied. If not specified, the backup will be applied to the same instance from which it was taken. + +``` +stackit mongodbflex backup restore [flags] +``` + +### Examples + +``` + Restore a MongoDB Flex instance with ID "yyy" using backup with ID "zzz" + $ stackit mongodbflex backup restore --instance-id yyy --backup-id zzz + + Clone a MongoDB Flex instance with ID "yyy" via point-in-time restore to timestamp "2024-05-14T14:31:48Z" + $ stackit mongodbflex backup restore --instance-id yyy --timestamp 2024-05-14T14:31:48Z + + Restore a MongoDB Flex instance with ID "yyy", using backup from instance with ID "zzz" with backup ID "xxx" + $ stackit mongodbflex backup restore --instance-id zzz --backup-instance-id yyy --backup-id xxx +``` + +### Options + +``` + --backup-id string Backup ID + --backup-instance-id string Instance ID of the target instance to restore the backup to + -h, --help Help for "stackit mongodbflex backup restore" + --instance-id string Instance ID + --timestamp string Timestamp of the snapshot to use as a source for cloning the instance in a date-time with the RFC3339 layout format, e.g. 2024-01-01T00:00:00Z +``` + +### 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"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit mongodbflex backup](./stackit_mongodbflex_backup.md) - Provides functionality for MongoDB Flex instance backups + diff --git a/docs/stackit_mongodbflex_backup_schedule.md b/docs/stackit_mongodbflex_backup_schedule.md new file mode 100644 index 000000000..8993c63c4 --- /dev/null +++ b/docs/stackit_mongodbflex_backup_schedule.md @@ -0,0 +1,43 @@ +## stackit mongodbflex backup schedule + +Shows details of the backup schedule and retention policy of a MongoDB Flex instance + +### Synopsis + +Shows details of the backup schedule and retention policy of a MongoDB Flex instance. + +``` +stackit mongodbflex backup schedule [flags] +``` + +### Examples + +``` + Get details of the backup schedule of a MongoDB Flex instance with ID "xxx" + $ stackit mongodbflex backup schedule --instance-id xxx + + Get details of the backup schedule of a MongoDB Flex instance with ID "xxx" in JSON format + $ stackit mongodbflex backup schedule --instance-id xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex backup schedule" + --instance-id string Instance ID +``` + +### 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"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit mongodbflex backup](./stackit_mongodbflex_backup.md) - Provides functionality for MongoDB Flex instance backups + diff --git a/docs/stackit_mongodbflex_backup_update-schedule.md b/docs/stackit_mongodbflex_backup_update-schedule.md new file mode 100644 index 000000000..38e1e69dc --- /dev/null +++ b/docs/stackit_mongodbflex_backup_update-schedule.md @@ -0,0 +1,51 @@ +## stackit mongodbflex backup update-schedule + +Updates the backup schedule and retention policy for a MongoDB Flex instance + +### Synopsis + +Updates the backup schedule and retention policy for a MongoDB Flex instance. +The current backup schedule and retention policy can be seen in the output of the "stackit mongodbflex backup schedule" command. +The backup schedule is defined in the cron scheduling system format e.g. '0 0 * * *'. +See below for more detail on the retention policy options. + +``` +stackit mongodbflex backup update-schedule [flags] +``` + +### Examples + +``` + Update the backup schedule of a MongoDB Flex instance with ID "xxx" + $ stackit mongodbflex backup update-schedule --instance-id xxx --schedule '6 6 * * *' + + Update the retention days for snapshots of a MongoDB Flex instance with ID "xxx" to 5 days + $ stackit mongodbflex backup update-schedule --instance-id xxx --save-snapshot-days 5 +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex backup update-schedule" + --instance-id string Instance ID + --save-daily-snapshot-days int Number of days to retain daily snapshots. Should be less than or equal to the number of days of the selected weekly or monthly value. + --save-monthly-snapshot-months int Number of months to retain monthly snapshots + --save-snapshot-days int Number of days to retain snapshots. Should be less than or equal to the value of the daily backup. + --save-weekly-snapshot-weeks int Number of weeks to retain weekly snapshots. Should be less than or equal to the number of weeks of the selected monthly value. + --schedule string Backup schedule, in the cron scheduling system format e.g. '0 0 * * *' +``` + +### 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"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit mongodbflex backup](./stackit_mongodbflex_backup.md) - Provides functionality for MongoDB Flex instance backups + diff --git a/go.mod b/go.mod index dddd46e3b..1c3c74579 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.12.0 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.2.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.9.1 - github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.12.0 + github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.13.0 github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.14.0 github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.13.0 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.8.0 diff --git a/go.sum b/go.sum index 3ebc4b5ad..2e009d334 100644 --- a/go.sum +++ b/go.sum @@ -91,8 +91,8 @@ github.com/stackitcloud/stackit-sdk-go/services/logme v0.14.0 h1:vvQFCN5sKZA9tdz github.com/stackitcloud/stackit-sdk-go/services/logme v0.14.0/go.mod h1:bj9cn1treNSxKTRCEmESwqfENN8vCYn60HUnEA0P83c= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.14.0 h1:tK6imWrbZ5TgQJbukWCUz7yDgcvvFMX8wamxkPTLuDo= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.14.0/go.mod h1:kPetkX9hNm9HkRyiKQL/tlgdi8frZdMP8afg0mEvQ9s= -github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.12.0 h1:/m6N/CdsFxomexsowU7PwT1S4UTmI39PnEvvWGsDh1s= -github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.12.0/go.mod h1:iFerEzGmkg6R13ldFUyHUWHm0ac9cS4ftTDLhP0k/dU= +github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.13.0 h1:Dhanx9aV5VRfpHg22Li07661FbRT5FR9/M6FowN08a8= +github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.13.0/go.mod h1:iFerEzGmkg6R13ldFUyHUWHm0ac9cS4ftTDLhP0k/dU= github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.9.0 h1:rWgy4/eCIgyA2dUuc4a30pldmS6taQDwiLqoeZmyeP8= github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.9.0/go.mod h1:dkVMJI88eJ3Xs0ZV15r4tUpgitUGJXcvrX3RL4Zq2bQ= github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.14.0 h1:zkhm0r0OZ5NbHJFrm+7B+h11QL0bNLC53nzXhqCaLWo= diff --git a/internal/cmd/mongodbflex/backup/backup.go b/internal/cmd/mongodbflex/backup/backup.go index da7696760..738363d78 100644 --- a/internal/cmd/mongodbflex/backup/backup.go +++ b/internal/cmd/mongodbflex/backup/backup.go @@ -3,7 +3,10 @@ package backup import ( "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/backup/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/backup/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/backup/restore" restorejobs "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/backup/restore-jobs" + "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/backup/schedule" + updateschedule "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/backup/update-schedule" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -24,6 +27,9 @@ func NewCmd(p *print.Printer) *cobra.Command { } func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(updateschedule.NewCmd(p)) + cmd.AddCommand(schedule.NewCmd(p)) + cmd.AddCommand(restore.NewCmd(p)) cmd.AddCommand(list.NewCmd(p)) cmd.AddCommand(describe.NewCmd(p)) cmd.AddCommand(restorejobs.NewCmd(p)) diff --git a/internal/cmd/mongodbflex/backup/restore/restore.go b/internal/cmd/mongodbflex/backup/restore/restore.go new file mode 100644 index 000000000..c043e4292 --- /dev/null +++ b/internal/cmd/mongodbflex/backup/restore/restore.go @@ -0,0 +1,205 @@ +package restore + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/mongodbflex/client" + mongodbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex/wait" +) + +const ( + instanceIdFlag = "instance-id" + backupInstanceIdFlag = "backup-instance-id" + backupIdFlag = "backup-id" + timestampFlag = "timestamp" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + BackupInstanceId string + BackupId string + Timestamp string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "restore", + Short: "Restores a MongoDB Flex instance from a backup", + Long: fmt.Sprintf("%s\n%s\n%s", + "Restores a MongoDB Flex instance from a backup of an instance or clones a MongoDB Flex instance from a point-in-time snapshot.", + "The backup is specified by a backup ID and the point-in-time snapshot is specified by a timestamp.", + "You can specify the instance to which the backup will be applied. If not specified, the backup will be applied to the same instance from which it was taken.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Restore a MongoDB Flex instance with ID "yyy" using backup with ID "zzz"`, + `$ stackit mongodbflex backup restore --instance-id yyy --backup-id zzz`), + examples.NewExample( + `Clone a MongoDB Flex instance with ID "yyy" via point-in-time restore to timestamp "2024-05-14T14:31:48Z"`, + `$ stackit mongodbflex backup restore --instance-id yyy --timestamp 2024-05-14T14:31:48Z`), + examples.NewExample( + `Restore a MongoDB Flex instance with ID "yyy", using backup from instance with ID "zzz" with backup ID "xxx"`, + `$ stackit mongodbflex backup restore --instance-id zzz --backup-instance-id yyy --backup-id xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + instanceLabel, err := mongodbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + p.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to restore MongoDB Flex instance %q?", instanceLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // If backupInstanceId is not provided, the target is the same instance as the backup + if model.BackupInstanceId == "" { + model.BackupInstanceId = model.InstanceId + } + + isRestoreOperation := getIsRestoreOperation(model.BackupId, model.Timestamp) + + // If backupId is provided, restore the instance from the backup with the backupId + if isRestoreOperation { + req := buildRestoreRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("restore MongoDB Flex instance: %w", err) + } + + if !model.Async { + s := spinner.New(p) + s.Start("Restoring instance") + _, err = wait.RestoreInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.BackupId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for MongoDB Flex instance restoration: %w", err) + } + s.Stop() + } + + p.Outputf("Restored instance %q with backup %q\n", model.InstanceId, model.BackupId) + return nil + } + + // Else, if timestamp is provided, clone the instance from a point-in-time snapshot + req := buildCloneRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("clone MongoDB Flex instance: %w", err) + } + + if !model.Async { + s := spinner.New(p) + s.Start("Cloning instance") + _, err = wait.CloneInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for MongoDB Flex instance cloning: %w", err) + } + s.Stop() + } + + p.Outputf("Cloned instance %q from backup with timestamp %q\n", model.InstanceId, model.Timestamp) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().Var(flags.UUIDFlag(), backupInstanceIdFlag, "Instance ID of the target instance to restore the backup to") + cmd.Flags().String(backupIdFlag, "", "Backup ID") + cmd.Flags().String(timestampFlag, "", "Timestamp of the snapshot to use as a source for cloning the instance in a date-time with the RFC3339 layout format, e.g. 2024-01-01T00:00:00Z") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + backupId := flags.FlagToStringValue(p, cmd, backupIdFlag) + timestamp := flags.FlagToStringValue(p, cmd, timestampFlag) + + if backupId != "" && timestamp != "" || backupId == "" && timestamp == "" { + return nil, &cliErr.RequiredMutuallyExclusiveFlagsError{ + Flags: []string{backupIdFlag, timestampFlag}, + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + BackupInstanceId: flags.FlagToStringValue(p, cmd, backupInstanceIdFlag), + BackupId: flags.FlagToStringValue(p, cmd, backupIdFlag), + Timestamp: flags.FlagToStringValue(p, cmd, timestampFlag), + } + + 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 buildRestoreRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiRestoreInstanceRequest { + req := apiClient.RestoreInstance(ctx, model.ProjectId, model.InstanceId) + req = req.RestoreInstancePayload(mongodbflex.RestoreInstancePayload{ + BackupId: &model.BackupId, + InstanceId: &model.BackupInstanceId, + }) + return req +} + +func buildCloneRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiCloneInstanceRequest { + req := apiClient.CloneInstance(ctx, model.ProjectId, model.InstanceId) + req = req.CloneInstancePayload(mongodbflex.CloneInstancePayload{ + Timestamp: &model.Timestamp, + InstanceId: &model.BackupInstanceId, + }) + return req +} + +func getIsRestoreOperation(backupId, timestamp string) bool { + return backupId != "" && timestamp == "" +} diff --git a/internal/cmd/mongodbflex/backup/restore/restore_test.go b/internal/cmd/mongodbflex/backup/restore/restore_test.go new file mode 100644 index 000000000..63e06d9af --- /dev/null +++ b/internal/cmd/mongodbflex/backup/restore/restore_test.go @@ -0,0 +1,310 @@ +package restore + +import ( + "context" + "testing" + + "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/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +const ( + testBackupId = "backupID" + testTimestamp = "2021-01-01T00:00:00Z" +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} + +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testBackupInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + backupIdFlag: testBackupId, + backupInstanceIdFlag: testBackupInstanceId, + instanceIdFlag: testInstanceId, + } + 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, + }, + InstanceId: testInstanceId, + BackupId: testBackupId, + BackupInstanceId: testBackupInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRestoreRequest(mods ...func(request mongodbflex.ApiRestoreInstanceRequest)) mongodbflex.ApiRestoreInstanceRequest { + request := testClient.RestoreInstance(testCtx, testProjectId, testInstanceId) + request = request.RestoreInstancePayload(mongodbflex.RestoreInstancePayload{ + BackupId: utils.Ptr(testBackupId), + InstanceId: utils.Ptr(testBackupInstanceId), + }) + for _, mod := range mods { + mod(request) + } + return request +} + +func fixtureCloneRequest(mods ...func(request mongodbflex.ApiCloneInstanceRequest)) mongodbflex.ApiCloneInstanceRequest { + request := testClient.CloneInstance(testCtx, testProjectId, testInstanceId) + request = request.CloneInstancePayload(mongodbflex.CloneInstancePayload{ + Timestamp: utils.Ptr(testTimestamp), + InstanceId: utils.Ptr(testBackupInstanceId), + }) + for _, mod := range mods { + mod(request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + aclValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "backup instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[backupInstanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "backup instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[backupInstanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "timestamp and backup id both provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[timestampFlag] = testTimestamp + }), + isValid: false, + }, + { + description: "timestamp and backup id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, backupIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(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) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flag groups: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %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 TestBuildRestoreRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiRestoreInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRestoreRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRestoreRequest(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 TestBuildCloneRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiCloneInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(func(model *inputModel) { + model.BackupId = "" + model.Timestamp = testTimestamp + }), + expectedRequest: fixtureCloneRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildCloneRequest(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 TestGetIsRestoreOperation(t *testing.T) { + tests := []struct { + description string + model *inputModel + expected bool + }{ + { + description: "true", + model: fixtureInputModel(), + expected: true, + }, + { + description: "false", + model: fixtureInputModel(func(model *inputModel) { + model.BackupId = "" + model.Timestamp = testTimestamp + }), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + result := getIsRestoreOperation(tt.model.BackupId, tt.model.Timestamp) + if result != tt.expected { + t.Fatalf("Data does not match: %t", result) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/backup/schedule/schedule.go b/internal/cmd/mongodbflex/backup/schedule/schedule.go new file mode 100644 index 000000000..3be9badf2 --- /dev/null +++ b/internal/cmd/mongodbflex/backup/schedule/schedule.go @@ -0,0 +1,151 @@ +package schedule + +import ( + "context" + "encoding/json" + "fmt" + + "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/mongodbflex/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "schedule", + Short: "Shows details of the backup schedule and retention policy of a MongoDB Flex instance", + Long: "Shows details of the backup schedule and retention policy of a MongoDB Flex instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Get details of the backup schedule of a MongoDB Flex instance with ID "xxx"`, + "$ stackit mongodbflex backup schedule --instance-id xxx"), + examples.NewExample( + `Get details of the backup schedule of a MongoDB Flex instance with ID "xxx" in JSON format`, + "$ stackit mongodbflex backup schedule --instance-id xxx --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read MongoDB Flex instance: %w", err) + } + + return outputResult(p, model.OutputFormat, resp.Item) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + 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{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: *flags.FlagToStringPointer(p, cmd, instanceIdFlag), + } + + 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 *mongodbflex.APIClient) mongodbflex.ApiGetInstanceRequest { + req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, instance *mongodbflex.Instance) error { + switch outputFormat { + case print.JSONOutputFormat: + output := struct { + BackupSchedule string `json:"backup_schedule"` + DailySnaphotRetentionDays string `json:"daily_snapshot_retention_days"` + MonthlySnapshotRetentionMonths string `json:"monthly_snapshot_retention_months"` + PointInTimeWindowHours string `json:"point_in_time_window_hours"` + SnapshotRetentionDays string `json:"snapshot_retention_days"` + WeeklySnapshotRetentionWeeks string `json:"weekly_snapshot_retention_weeks"` + }{ + BackupSchedule: *instance.BackupSchedule, + DailySnaphotRetentionDays: (*instance.Options)["dailySnapshotRetentionDays"], + MonthlySnapshotRetentionMonths: (*instance.Options)["monthlySnapshotRetentionDays"], + PointInTimeWindowHours: (*instance.Options)["pointInTimeWindowHours"], + SnapshotRetentionDays: (*instance.Options)["snapshotRetentionDays"], + WeeklySnapshotRetentionWeeks: (*instance.Options)["weeklySnapshotRetentionWeeks"], + } + details, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("marshal MongoDB Flex backup schedule: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.AddRow("BACKUP SCHEDULE", *instance.BackupSchedule) + table.AddSeparator() + table.AddRow("DAILY SNAPSHOT RETENTION (DAYS)", (*instance.Options)["dailySnapshotRetentionDays"]) + table.AddSeparator() + table.AddRow("MONTHLY SNAPSHOT RETENTION (MONTHS)", (*instance.Options)["monthlySnapshotRetentionMonths"]) + table.AddSeparator() + table.AddRow("POINT IN TIME WINDOW (HOURS)", (*instance.Options)["pointInTimeWindowHours"]) + table.AddSeparator() + table.AddRow("SNAPSHOT RETENTION (DAYS)", (*instance.Options)["snapshotRetentionDays"]) + table.AddSeparator() + table.AddRow("WEEKLY SNAPSHOT RETENTION (WEEKS)", (*instance.Options)["weeklySnapshotRetentionWeeks"]) + table.AddSeparator() + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/mongodbflex/backup/schedule/schedule_test.go b/internal/cmd/mongodbflex/backup/schedule/schedule_test.go new file mode 100644 index 000000000..e25957058 --- /dev/null +++ b/internal/cmd/mongodbflex/backup/schedule/schedule_test.go @@ -0,0 +1,195 @@ +package schedule + +import ( + "context" + "testing" + + "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/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + 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, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiGetInstanceRequest)) mongodbflex.ApiGetInstanceRequest { + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId) + 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, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + 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) + } + + p := print.NewPrinter() + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %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 mongodbflex.ApiGetInstanceRequest + }{ + { + 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) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/backup/update-schedule/update_schedule.go b/internal/cmd/mongodbflex/backup/update-schedule/update_schedule.go new file mode 100644 index 000000000..cf296ea92 --- /dev/null +++ b/internal/cmd/mongodbflex/backup/update-schedule/update_schedule.go @@ -0,0 +1,233 @@ +package updateschedule + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/mongodbflex/client" + mongoDBflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + instanceIdFlag = "instance-id" + scheduleFlag = "schedule" + snapshotRetentionDaysFlag = "save-snapshot-days" + dailySnapshotRetentionDaysFlag = "save-daily-snapshot-days" + weeklySnapshotRetentionWeeksFlag = "save-weekly-snapshot-weeks" + monthlySnapshotRetentionMonthsFlag = "save-monthly-snapshot-months" + + // Default values for the backup schedule options + defaultBackupSchedule = "0 0/6 * * *" + defaultSnapshotRetentionDays int64 = 3 + defaultDailySnapshotRetentionDays int64 = 0 + defaultWeeklySnapshotRetentionWeeks int64 = 3 + defaultMonthlySnapshotRetentionMonths int64 = 1 + defaultPointInTimeWindowHours int64 = 30 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId *string + BackupSchedule *string + SnapshotRetentionDays *int64 + DailySnaphotRetentionDays *int64 + WeeklySnapshotRetentionWeeks *int64 + MonthlySnapshotRetentionMonths *int64 +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "update-schedule", + Short: "Updates the backup schedule and retention policy for a MongoDB Flex instance", + Long: fmt.Sprintf("%s\n%s\n%s\n%s", + "Updates the backup schedule and retention policy for a MongoDB Flex instance.", + `The current backup schedule and retention policy can be seen in the output of the "stackit mongodbflex backup schedule" command.`, + "The backup schedule is defined in the cron scheduling system format e.g. '0 0 * * *'.", + "See below for more detail on the retention policy options.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Update the backup schedule of a MongoDB Flex instance with ID "xxx"`, + "$ stackit mongodbflex backup update-schedule --instance-id xxx --schedule '6 6 * * *'"), + examples.NewExample( + `Update the retention days for snapshots of a MongoDB Flex instance with ID "xxx" to 5 days`, + "$ stackit mongodbflex backup update-schedule --instance-id xxx --save-snapshot-days 5"), + ), + + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + instanceLabel, err := mongoDBflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId) + if err != nil { + p.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = *model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update backup schedule of instance %q?", instanceLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Get current instance + getReq := buildGetInstanceRequest(ctx, model, apiClient) + getResp, err := getReq.Execute() + if err != nil { + return fmt.Errorf("get MongoDB Flex instance %q: %w", instanceLabel, err) + } + + instance := getResp.Item + + // Call API + req := buildUpdateBackupScheduleRequest(ctx, model, instance, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update backup schedule of MongoDB Flex instance: %w", err) + } + + cmd.Printf("Updated backup schedule of instance %q\n", instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().String(scheduleFlag, "", "Backup schedule, in the cron scheduling system format e.g. '0 0 * * *'") + cmd.Flags().Int64(snapshotRetentionDaysFlag, 0, "Number of days to retain snapshots. Should be less than or equal to the value of the daily backup.") + cmd.Flags().Int64(dailySnapshotRetentionDaysFlag, 0, "Number of days to retain daily snapshots. Should be less than or equal to the number of days of the selected weekly or monthly value.") + cmd.Flags().Int64(weeklySnapshotRetentionWeeksFlag, 0, "Number of weeks to retain weekly snapshots. Should be less than or equal to the number of weeks of the selected monthly value.") + cmd.Flags().Int64(monthlySnapshotRetentionMonthsFlag, 0, "Number of months to retain monthly snapshots") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + schedule := flags.FlagToStringPointer(p, cmd, scheduleFlag) + snapshotRetentionDays := flags.FlagToInt64Pointer(p, cmd, snapshotRetentionDaysFlag) + dailySnapshotRetentionDays := flags.FlagToInt64Pointer(p, cmd, dailySnapshotRetentionDaysFlag) + weeklySnapshotRetentionWeeks := flags.FlagToInt64Pointer(p, cmd, weeklySnapshotRetentionWeeksFlag) + monthlySnapshotRetentionMonths := flags.FlagToInt64Pointer(p, cmd, monthlySnapshotRetentionMonthsFlag) + + if schedule == nil && snapshotRetentionDays == nil && dailySnapshotRetentionDays == nil && weeklySnapshotRetentionWeeks == nil && monthlySnapshotRetentionMonths == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringPointer(p, cmd, instanceIdFlag), + BackupSchedule: schedule, + DailySnaphotRetentionDays: dailySnapshotRetentionDays, + MonthlySnapshotRetentionMonths: monthlySnapshotRetentionMonths, + SnapshotRetentionDays: snapshotRetentionDays, + WeeklySnapshotRetentionWeeks: weeklySnapshotRetentionWeeks, + }, nil +} + +func buildUpdateBackupScheduleRequest(ctx context.Context, model *inputModel, instance *mongodbflex.Instance, apiClient *mongodbflex.APIClient) mongodbflex.ApiUpdateBackupScheduleRequest { + req := apiClient.UpdateBackupSchedule(ctx, model.ProjectId, *model.InstanceId) + + payload := getUpdateBackupSchedulePayload(instance) + + if model.BackupSchedule != nil { + payload.BackupSchedule = model.BackupSchedule + } + if model.DailySnaphotRetentionDays != nil { + payload.DailySnapshotRetentionDays = model.DailySnaphotRetentionDays + } + if model.MonthlySnapshotRetentionMonths != nil { + payload.MonthlySnapshotRetentionMonths = model.MonthlySnapshotRetentionMonths + } + if model.SnapshotRetentionDays != nil { + payload.SnapshotRetentionDays = model.SnapshotRetentionDays + } + if model.WeeklySnapshotRetentionWeeks != nil { + payload.WeeklySnapshotRetentionWeeks = model.WeeklySnapshotRetentionWeeks + } + + req = req.UpdateBackupSchedulePayload(payload) + return req +} + +// getUpdateBackupSchedulePayload creates a payload for the UpdateBackupSchedule API call +// it will use the values already set in the instance object +// falls back to default values if the values are not set +func getUpdateBackupSchedulePayload(instance *mongodbflex.Instance) mongodbflex.UpdateBackupSchedulePayload { + options := make(map[string]string) + if instance == nil || instance.Options != nil { + options = *instance.Options + } + + backupSchedule := instance.BackupSchedule + if backupSchedule == nil { + backupSchedule = utils.Ptr(defaultBackupSchedule) + } + dailySnapshotRetentionDays, err := strconv.ParseInt(options["dailySnapshotRetentionDays"], 10, 64) + if err != nil { + dailySnapshotRetentionDays = defaultDailySnapshotRetentionDays + } + weeklySnapshotRetentionWeeks, err := strconv.ParseInt(options["weeklySnapshotRetentionWeeks"], 10, 64) + if err != nil { + weeklySnapshotRetentionWeeks = defaultWeeklySnapshotRetentionWeeks + } + monthlySnapshotRetentionMonths, err := strconv.ParseInt(options["monthlySnapshotRetentionMonths"], 10, 64) + if err != nil { + monthlySnapshotRetentionMonths = defaultMonthlySnapshotRetentionMonths + } + pointInTimeWindowHours, err := strconv.ParseInt(options["pointInTimeWindowHours"], 10, 64) + if err != nil { + pointInTimeWindowHours = defaultPointInTimeWindowHours + } + snapshotRetentionDays, err := strconv.ParseInt(options["snapshotRetentionDays"], 10, 64) + if err != nil { + snapshotRetentionDays = defaultSnapshotRetentionDays + } + + defaultPayload := mongodbflex.UpdateBackupSchedulePayload{ + BackupSchedule: backupSchedule, + DailySnapshotRetentionDays: &dailySnapshotRetentionDays, + MonthlySnapshotRetentionMonths: &monthlySnapshotRetentionMonths, + PointInTimeWindowHours: &pointInTimeWindowHours, + SnapshotRetentionDays: &snapshotRetentionDays, + WeeklySnapshotRetentionWeeks: &weeklySnapshotRetentionWeeks, + } + return defaultPayload +} + +func buildGetInstanceRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiGetInstanceRequest { + req := apiClient.GetInstance(ctx, model.ProjectId, *model.InstanceId) + return req +} diff --git a/internal/cmd/mongodbflex/backup/update-schedule/update_schedule_test.go b/internal/cmd/mongodbflex/backup/update-schedule/update_schedule_test.go new file mode 100644 index 000000000..1f6cc5aa0 --- /dev/null +++ b/internal/cmd/mongodbflex/backup/update-schedule/update_schedule_test.go @@ -0,0 +1,324 @@ +package updateschedule + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "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/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testSchedule = "0 0/6 * * *" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + scheduleFlag: testSchedule, + instanceIdFlag: testInstanceId, + } + 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, + }, + InstanceId: utils.Ptr(testInstanceId), + BackupSchedule: &testSchedule, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixturePayload(mods ...func(payload *mongodbflex.UpdateBackupSchedulePayload)) mongodbflex.UpdateBackupSchedulePayload { + payload := mongodbflex.UpdateBackupSchedulePayload{ + BackupSchedule: utils.Ptr(testSchedule), + SnapshotRetentionDays: utils.Ptr(int64(3)), + DailySnapshotRetentionDays: utils.Ptr(int64(0)), + WeeklySnapshotRetentionWeeks: utils.Ptr(int64(3)), + MonthlySnapshotRetentionMonths: utils.Ptr(int64(1)), + PointInTimeWindowHours: utils.Ptr(int64(30)), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureUpdateBackupScheduleRequest(mods ...func(request *mongodbflex.ApiUpdateBackupScheduleRequest)) mongodbflex.ApiUpdateBackupScheduleRequest { + request := testClient.UpdateBackupSchedule(testCtx, testProjectId, testInstanceId) + request = request.UpdateBackupSchedulePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureGetInstanceRequest(mods ...func(request *mongodbflex.ApiGetInstanceRequest)) mongodbflex.ApiGetInstanceRequest { + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureInstance(mods ...func(instance *mongodbflex.Instance)) *mongodbflex.Instance { + instance := mongodbflex.Instance{ + BackupSchedule: &testSchedule, + Options: &map[string]string{ + "dailySnapshotRetentionDays": "0", + "weeklySnapshotRetentionWeeks": "3", + "monthlySnapshotRetentionMonths": "1", + "pointInTimeWindowHours": "30", + "snapshotRetentionDays": "3", + }, + } + for _, mod := range mods { + mod(&instance) + } + return &instance +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + aclValues []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, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "backup schedule missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, scheduleFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd(nil) + 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(nil, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %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 TestBuildGetInstanceRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiGetInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureGetInstanceRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildGetInstanceRequest(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 TestBuildUpdateBackupScheduleRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + instance *mongodbflex.Instance + expectedRequest mongodbflex.ApiUpdateBackupScheduleRequest + }{ + { + description: "update backup schedule, read retention policy from instance", + model: fixtureInputModel(), + instance: fixtureInstance(), + expectedRequest: fixtureUpdateBackupScheduleRequest(), + }, + { + description: "update retention policy, read backup schedule from instance", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: utils.Ptr(testInstanceId), + DailySnaphotRetentionDays: utils.Ptr(int64(2)), + }, + instance: fixtureInstance(), + expectedRequest: fixtureUpdateBackupScheduleRequest().UpdateBackupSchedulePayload( + fixturePayload(func(payload *mongodbflex.UpdateBackupSchedulePayload) { + payload.DailySnapshotRetentionDays = utils.Ptr(int64(2)) + }), + ), + }, + { + description: "update backup schedule and retention policy", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: utils.Ptr(testInstanceId), + BackupSchedule: utils.Ptr("0 0/6 5 2 1"), + DailySnaphotRetentionDays: utils.Ptr(int64(2)), + WeeklySnapshotRetentionWeeks: utils.Ptr(int64(2)), + MonthlySnapshotRetentionMonths: utils.Ptr(int64(2)), + SnapshotRetentionDays: utils.Ptr(int64(2)), + }, + instance: fixtureInstance(), + expectedRequest: fixtureUpdateBackupScheduleRequest().UpdateBackupSchedulePayload( + fixturePayload(func(payload *mongodbflex.UpdateBackupSchedulePayload) { + payload.BackupSchedule = utils.Ptr("0 0/6 5 2 1") + payload.DailySnapshotRetentionDays = utils.Ptr(int64(2)) + payload.WeeklySnapshotRetentionWeeks = utils.Ptr(int64(2)) + payload.MonthlySnapshotRetentionMonths = utils.Ptr(int64(2)) + payload.SnapshotRetentionDays = utils.Ptr(int64(2)) + }), + ), + }, + { + description: "no fields set, empty instance (use defaults)", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: utils.Ptr(testInstanceId), + }, + instance: &mongodbflex.Instance{}, + expectedRequest: fixtureUpdateBackupScheduleRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildUpdateBackupScheduleRequest(testCtx, tt.model, tt.instance, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/instance/describe/describe.go b/internal/cmd/mongodbflex/instance/describe/describe.go index 6d7fda1d5..ec1311991 100644 --- a/internal/cmd/mongodbflex/instance/describe/describe.go +++ b/internal/cmd/mongodbflex/instance/describe/describe.go @@ -141,6 +141,8 @@ func outputResult(p *print.Printer, outputFormat string, instance *mongodbflex.I table.AddSeparator() table.AddRow("RAM", *instance.Flavor.Memory) table.AddSeparator() + table.AddRow("BACKUP SCHEDULE", *instance.BackupSchedule) + table.AddSeparator() err = table.Display(p) if err != nil { return fmt.Errorf("render table: %w", err) diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index d421aa1b7..6b127bf67 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -22,6 +22,8 @@ or you can also set it through the environment variable [STACKIT_PROJECT_ID]` Get details on the available flags by re-running your command with the --help flag.` + REQUIRED_MUTUALLY_EXCLUSIVE_FLAGS = `the following flags are mutually exclusive and at least one of them is required: %s` + FAILED_AUTH = `you are not authenticated. You can authenticate as a user by running: @@ -237,6 +239,14 @@ func (e *FlagValidationError) Error() string { return fmt.Sprintf(FLAG_VALIDATION, e.Flag, e.Details) } +type RequiredMutuallyExclusiveFlagsError struct { + Flags []string +} + +func (e *RequiredMutuallyExclusiveFlagsError) Error() string { + return fmt.Sprintf(REQUIRED_MUTUALLY_EXCLUSIVE_FLAGS, strings.Join(e.Flags, ", ")) +} + type ArgValidationError struct { Arg string Details string