diff --git a/src/cli/backup/backup.go b/src/cli/backup/backup.go index 1c43058a3..b9ec6f5cc 100644 --- a/src/cli/backup/backup.go +++ b/src/cli/backup/backup.go @@ -13,6 +13,7 @@ var subCommands = []*cobra.Command{ var serviceCommands = []func(parent *cobra.Command) *cobra.Command{ addExchangeCommands, + addOneDriveCommands, } // AddCommands attaches all `corso backup * *` commands to the parent. diff --git a/src/cli/backup/onedrive.go b/src/cli/backup/onedrive.go new file mode 100644 index 000000000..d73b78021 --- /dev/null +++ b/src/cli/backup/onedrive.go @@ -0,0 +1,252 @@ +package backup + +import ( + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/alcionai/corso/src/cli/config" + "github.com/alcionai/corso/src/cli/options" + . "github.com/alcionai/corso/src/cli/print" + "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/internal/model" + "github.com/alcionai/corso/src/pkg/backup" + "github.com/alcionai/corso/src/pkg/repository" + "github.com/alcionai/corso/src/pkg/selectors" +) + +// ------------------------------------------------------------------------------------------------ +// setup and globals +// ------------------------------------------------------------------------------------------------ + +const oneDriveServiceCommand = "onedrive" + +// called by backup.go to map parent subcommands to provider-specific handling. +func addOneDriveCommands(parent *cobra.Command) *cobra.Command { + var ( + c *cobra.Command + fs *pflag.FlagSet + ) + + switch parent.Use { + case createCommand: + c, fs = utils.AddCommand(parent, oneDriveCreateCmd()) + + fs.StringArrayVar(&user, "user", nil, + "Backup OneDrive data by user ID; accepts "+utils.Wildcard+" to select all users") + options.AddOperationFlags(c) + + case listCommand: + c, _ = utils.AddCommand(parent, oneDriveListCmd()) + + case detailsCommand: + c, fs = utils.AddCommand(parent, oneDriveDetailsCmd()) + fs.StringVar(&backupID, "backup", "", "ID of the backup containing the details to be shown") + cobra.CheckErr(c.MarkFlagRequired("backup")) + + case deleteCommand: + c, fs = utils.AddCommand(parent, oneDriveDeleteCmd()) + fs.StringVar(&backupID, "backup", "", "ID of the backup containing the details to be shown") + cobra.CheckErr(c.MarkFlagRequired("backup")) + } + + return c +} + +// ------------------------------------------------------------------------------------------------ +// backup create +// ------------------------------------------------------------------------------------------------ + +// `corso backup create onedrive [...]` +func oneDriveCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: oneDriveServiceCommand, + Short: "Backup M365 OneDrive service data", + RunE: createOneDriveCmd, + Args: cobra.NoArgs, + } +} + +// processes an onedrive service backup. +func createOneDriveCmd(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if utils.HasNoFlagsAndShownHelp(cmd) { + return nil + } + + if err := validateOneDriveBackupCreateFlags(user); err != nil { + return err + } + + s, acct, err := config.GetStorageAndAccount(ctx, true, nil) + if err != nil { + return Only(ctx, err) + } + + r, err := repository.Connect(ctx, acct, s, options.Control()) + if err != nil { + return Only(ctx, errors.Wrapf(err, "Failed to connect to the %s repository", s.Provider)) + } + + defer utils.CloseRepo(ctx, r) + + sel := oneDriveBackupCreateSelectors(user) + + bo, err := r.NewBackup(ctx, sel) + if err != nil { + return Only(ctx, errors.Wrap(err, "Failed to initialize OneDrive backup")) + } + + err = bo.Run(ctx) + if err != nil { + return Only(ctx, errors.Wrap(err, "Failed to run OneDrive backup")) + } + + bu, err := r.Backup(ctx, bo.Results.BackupID) + if err != nil { + return errors.Wrap(err, "Unable to retrieve backup results from storage") + } + + bu.Print(ctx) + + return nil +} + +func validateOneDriveBackupCreateFlags(users []string) error { + if len(users) == 0 { + return errors.New("requires one or more --user ids or the wildcard --user *") + } + + return nil +} + +func oneDriveBackupCreateSelectors(users []string) selectors.Selector { + sel := selectors.NewOneDriveBackup() + sel.Include(sel.Users(users)) + + return sel.Selector +} + +// ------------------------------------------------------------------------------------------------ +// backup list +// ------------------------------------------------------------------------------------------------ + +// `corso backup list onedrive [...]` +func oneDriveListCmd() *cobra.Command { + return &cobra.Command{ + Use: oneDriveServiceCommand, + Short: "List the history of M365 OneDrive service backups", + RunE: listOneDriveCmd, + Args: cobra.NoArgs, + } +} + +// lists the history of backup operations +func listOneDriveCmd(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + s, acct, err := config.GetStorageAndAccount(ctx, true, nil) + if err != nil { + return Only(ctx, err) + } + + r, err := repository.Connect(ctx, acct, s, options.Control()) + if err != nil { + return Only(ctx, errors.Wrapf(err, "Failed to connect to the %s repository", s.Provider)) + } + + defer utils.CloseRepo(ctx, r) + + bs, err := r.Backups(ctx) + if err != nil { + return Only(ctx, errors.Wrap(err, "Failed to list backups in the repository")) + } + + backup.PrintAll(ctx, bs) + + return nil +} + +// ------------------------------------------------------------------------------------------------ +// backup details +// ------------------------------------------------------------------------------------------------ + +// `corso backup details onedrive [...]` +func oneDriveDetailsCmd() *cobra.Command { + return &cobra.Command{ + Use: oneDriveServiceCommand, + Short: "Shows the details of a M365 OneDrive service backup", + RunE: detailsOneDriveCmd, + Args: cobra.NoArgs, + } +} + +// lists the history of backup operations +func detailsOneDriveCmd(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if utils.HasNoFlagsAndShownHelp(cmd) { + return nil + } + + s, acct, err := config.GetStorageAndAccount(ctx, true, nil) + if err != nil { + return Only(ctx, err) + } + + r, err := repository.Connect(ctx, acct, s, options.Control()) + if err != nil { + return Only(ctx, errors.Wrapf(err, "Failed to connect to the %s repository", s.Provider)) + } + + defer utils.CloseRepo(ctx, r) + + ds, _, err := r.BackupDetails(ctx, backupID) + if err != nil { + return Only(ctx, errors.Wrap(err, "Failed to get backup details in the repository")) + } + + // TODO: Support selectors and filters + + ds.PrintEntries(ctx) + + return nil +} + +// `corso backup delete onedrive [...]` +func oneDriveDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: oneDriveServiceCommand, + Short: "Delete backed-up M365 OneDrive service data", + RunE: deleteOneDriveCmd, + Args: cobra.NoArgs, + } +} + +// deletes an exchange service backup. +func deleteOneDriveCmd(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if utils.HasNoFlagsAndShownHelp(cmd) { + return nil + } + + s, acct, err := config.GetStorageAndAccount(ctx, true, nil) + if err != nil { + return Only(ctx, err) + } + + r, err := repository.Connect(ctx, acct, s, options.Control()) + if err != nil { + return Only(ctx, errors.Wrapf(err, "Failed to connect to the %s repository", s.Provider)) + } + + defer utils.CloseRepo(ctx, r) + + if err := r.DeleteBackup(ctx, model.StableID(backupID)); err != nil { + return Only(ctx, errors.Wrapf(err, "Deleting backup %s", backupID)) + } + + return nil +} diff --git a/src/cli/backup/onedrive_test.go b/src/cli/backup/onedrive_test.go new file mode 100644 index 000000000..b9cba6339 --- /dev/null +++ b/src/cli/backup/onedrive_test.go @@ -0,0 +1,76 @@ +package backup + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/tester" +) + +type OneDriveSuite struct { + suite.Suite +} + +func TestOneDriveSuite(t *testing.T) { + suite.Run(t, new(OneDriveSuite)) +} + +func (suite *OneDriveSuite) TestAddOneDriveCommands() { + expectUse := oneDriveServiceCommand + + table := []struct { + name string + use string + expectUse string + expectShort string + expectRunE func(*cobra.Command, []string) error + }{ + {"create onedrive", createCommand, expectUse, oneDriveCreateCmd().Short, createOneDriveCmd}, + {"list onedrive", listCommand, expectUse, oneDriveListCmd().Short, listOneDriveCmd}, + {"details onedrive", detailsCommand, expectUse, oneDriveDetailsCmd().Short, detailsOneDriveCmd}, + {"delete onedrive", deleteCommand, expectUse, oneDriveDeleteCmd().Short, deleteOneDriveCmd}, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + cmd := &cobra.Command{Use: test.use} + + c := addOneDriveCommands(cmd) + require.NotNil(t, c) + + cmds := cmd.Commands() + require.Len(t, cmds, 1) + + child := cmds[0] + assert.Equal(t, test.expectUse, child.Use) + assert.Equal(t, test.expectShort, child.Short) + tester.AreSameFunc(t, test.expectRunE, child.RunE) + }) + } +} + +func (suite *OneDriveSuite) TestValidateOneDriveBackupCreateFlags() { + table := []struct { + name string + user []string + expect assert.ErrorAssertionFunc + }{ + { + name: "no users", + expect: assert.Error, + }, + { + name: "users", + user: []string{"fnord"}, + expect: assert.NoError, + }, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + test.expect(t, validateOneDriveBackupCreateFlags(test.user)) + }) + } +} diff --git a/src/cli/restore/onedrive.go b/src/cli/restore/onedrive.go new file mode 100644 index 000000000..5418b6a9d --- /dev/null +++ b/src/cli/restore/onedrive.go @@ -0,0 +1,98 @@ +package restore + +import ( + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/alcionai/corso/src/cli/config" + "github.com/alcionai/corso/src/cli/options" + . "github.com/alcionai/corso/src/cli/print" + "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/repository" + "github.com/alcionai/corso/src/pkg/selectors" +) + +// called by restore.go to map parent subcommands to provider-specific handling. +func addOneDriveCommands(parent *cobra.Command) *cobra.Command { + var ( + c *cobra.Command + fs *pflag.FlagSet + ) + + switch parent.Use { + case restoreCommand: + c, fs = utils.AddCommand(parent, oneDriveRestoreCmd()) + fs.StringVar(&backupID, "backup", "", "ID of the backup to restore") + cobra.CheckErr(c.MarkFlagRequired("backup")) + + fs.StringSliceVar(&user, + "user", nil, + "Restore all data by user ID; accepts "+utils.Wildcard+" to select all users") + + // others + options.AddOperationFlags(c) + } + + return c +} + +const oneDriveServiceCommand = "onedrive" + +// `corso restore onedrive [...]` +func oneDriveRestoreCmd() *cobra.Command { + return &cobra.Command{ + Use: oneDriveServiceCommand, + Short: "Restore M365 OneDrive service data", + RunE: restoreOneDriveCmd, + Args: cobra.NoArgs, + } +} + +// processes an onedrive service restore. +func restoreOneDriveCmd(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if utils.HasNoFlagsAndShownHelp(cmd) { + return nil + } + + if err := utils.ValidateOneDriveRestoreFlags(backupID); err != nil { + return err + } + + s, a, err := config.GetStorageAndAccount(ctx, true, nil) + if err != nil { + return Only(ctx, err) + } + + r, err := repository.Connect(ctx, a, s, options.Control()) + if err != nil { + return Only(ctx, errors.Wrapf(err, "Failed to connect to the %s repository", s.Provider)) + } + + defer utils.CloseRepo(ctx, r) + + sel := selectors.NewOneDriveRestore() + if user != nil { + sel.Include(sel.Users(user)) + } + + // if no selector flags were specified, get all data in the service. + if len(sel.Scopes()) == 0 { + sel.Include(sel.Users(selectors.Any())) + } + + ro, err := r.NewRestore(ctx, backupID, sel.Selector) + if err != nil { + return Only(ctx, errors.Wrap(err, "Failed to initialize OneDrive restore")) + } + + if err := ro.Run(ctx); err != nil { + return Only(ctx, errors.Wrap(err, "Failed to run OneDrive restore")) + } + + Infof(ctx, "Restored OneDrive in %s for user %s.\n", s.Provider, sel.ToPrintable().Resources()) + + return nil +} diff --git a/src/cli/restore/onedrive_test.go b/src/cli/restore/onedrive_test.go new file mode 100644 index 000000000..fd184c4d4 --- /dev/null +++ b/src/cli/restore/onedrive_test.go @@ -0,0 +1,50 @@ +package restore + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/tester" +) + +type OneDriveSuite struct { + suite.Suite +} + +func TestOneDriveSuite(t *testing.T) { + suite.Run(t, new(OneDriveSuite)) +} + +func (suite *OneDriveSuite) TestAddOneDriveCommands() { + expectUse := oneDriveServiceCommand + + table := []struct { + name string + use string + expectUse string + expectShort string + expectRunE func(*cobra.Command, []string) error + }{ + {"restore onedrive", restoreCommand, expectUse, oneDriveRestoreCmd().Short, restoreOneDriveCmd}, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + cmd := &cobra.Command{Use: test.use} + + c := addOneDriveCommands(cmd) + require.NotNil(t, c) + + cmds := cmd.Commands() + require.Len(t, cmds, 1) + + child := cmds[0] + assert.Equal(t, test.expectUse, child.Use) + assert.Equal(t, test.expectShort, child.Short) + tester.AreSameFunc(t, test.expectRunE, child.RunE) + }) + } +} diff --git a/src/cli/restore/restore.go b/src/cli/restore/restore.go index 53eeb9ec9..faed134f2 100644 --- a/src/cli/restore/restore.go +++ b/src/cli/restore/restore.go @@ -6,6 +6,7 @@ import ( var restoreCommands = []func(parent *cobra.Command) *cobra.Command{ addExchangeCommands, + addOneDriveCommands, } // AddCommands attaches all `corso restore * *` commands to the parent. diff --git a/src/cli/utils/onedrive.go b/src/cli/utils/onedrive.go new file mode 100644 index 000000000..6ee807837 --- /dev/null +++ b/src/cli/utils/onedrive.go @@ -0,0 +1,14 @@ +package utils + +import ( + "errors" +) + +// ValidateOneDriveRestoreFlags checks common flags for correctness and interdependencies +func ValidateOneDriveRestoreFlags(backupID string) error { + if len(backupID) == 0 { + return errors.New("a backup ID is required") + } + + return nil +} diff --git a/src/internal/operations/restore.go b/src/internal/operations/restore.go index 61d2058e8..a5320f21d 100644 --- a/src/internal/operations/restore.go +++ b/src/internal/operations/restore.go @@ -16,6 +16,7 @@ import ( "github.com/alcionai/corso/src/internal/path" "github.com/alcionai/corso/src/internal/stats" "github.com/alcionai/corso/src/pkg/account" + "github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/selectors" @@ -118,16 +119,27 @@ func (op *RestoreOperation) Run(ctx context.Context) (err error) { return err } - er, err := op.Selectors.ToExchangeRestore() - if err != nil { - opStats.readErr = err - return err - } + var fds *details.Details - // format the details and retrieve the items from kopia - fds := er.Reduce(ctx, d) - if len(fds.Entries) == 0 { - return errors.New("nothing to restore: no items in the backup match the provided selectors") + switch op.Selectors.Service { + case selectors.ServiceExchange: + er, err := op.Selectors.ToExchangeRestore() + if err != nil { + opStats.readErr = err + return err + } + + // format the details and retrieve the items from kopia + fds = er.Reduce(ctx, d) + if len(fds.Entries) == 0 { + return errors.New("nothing to restore: no items in the backup match the provided selectors") + } + + case selectors.ServiceOneDrive: + // TODO: Reduce `details` here when we add support for OneDrive restore filters + fds = d + default: + return errors.Errorf("Service %s not supported", op.Selectors.Service) } fdsPaths := fds.Paths() diff --git a/src/pkg/selectors/onedrive.go b/src/pkg/selectors/onedrive.go index 84f417f6e..8f2583d67 100644 --- a/src/pkg/selectors/onedrive.go +++ b/src/pkg/selectors/onedrive.go @@ -22,6 +22,13 @@ type ( OneDriveBackup struct { oneDrive } + + // OneDriveRestorep provides an api for selecting + // data scopes applicable to the OneDrive service, + // plus restore-specific methods. + OneDriveRestore struct { + oneDrive + } ) // NewOneDriveBackup produces a new Selector with the service set to ServiceOneDrive. @@ -47,6 +54,29 @@ func (s Selector) ToOneDriveBackup() (*OneDriveBackup, error) { return &src, nil } +// NewOneDriveRestore produces a new Selector with the service set to ServiceOneDrive. +func NewOneDriveRestore() *OneDriveRestore { + src := OneDriveRestore{ + oneDrive{ + newSelector(ServiceOneDrive), + }, + } + + return &src +} + +// ToOneDriveRestore transforms the generic selector into an OneDriveRestore. +// Errors if the service defined by the selector is not ServiceOneDrive. +func (s Selector) ToOneDriveRestore() (*OneDriveRestore, error) { + if s.Service != ServiceOneDrive { + return nil, badCastErr(ServiceOneDrive, s.Service) + } + + src := OneDriveRestore{oneDrive{s}} + + return &src, nil +} + // Printable creates the minimized display of a selector, formatted for human readability. func (s oneDrive) Printable() Printable { return toPrintable[OneDriveScope](s.Selector) diff --git a/src/pkg/selectors/onedrive_test.go b/src/pkg/selectors/onedrive_test.go index f5b2f92c7..a9fb7e71f 100644 --- a/src/pkg/selectors/onedrive_test.go +++ b/src/pkg/selectors/onedrive_test.go @@ -157,3 +157,20 @@ func (suite *OneDriveSelectorSuite) TestOneDriveSelector_Exclude_Users() { ) } } + +func (suite *OneDriveSelectorSuite) TestNewOneDriveRestore() { + t := suite.T() + or := NewOneDriveRestore() + assert.Equal(t, or.Service, ServiceOneDrive) + assert.NotZero(t, or.Scopes()) +} + +func (suite *OneDriveSelectorSuite) TestToOneDriveRestore() { + t := suite.T() + eb := NewOneDriveRestore() + s := eb.Selector + or, err := s.ToOneDriveRestore() + require.NoError(t, err) + assert.Equal(t, or.Service, ServiceOneDrive) + assert.NotZero(t, or.Scopes()) +}