diff --git a/src/cli/backup/backup.go b/src/cli/backup/backup.go index 008d8eb44..425b176e5 100644 --- a/src/cli/backup/backup.go +++ b/src/cli/backup/backup.go @@ -14,6 +14,7 @@ var subCommandFuncs = []func() *cobra.Command{ var serviceCommands = []func(parent *cobra.Command) *cobra.Command{ addExchangeCommands, addOneDriveCommands, + addSharePointCommands, } // AddCommands attaches all `corso backup * *` commands to the parent. diff --git a/src/cli/backup/onedrive.go b/src/cli/backup/onedrive.go index 7a853f853..59a4c4d37 100644 --- a/src/cli/backup/onedrive.go +++ b/src/cli/backup/onedrive.go @@ -388,7 +388,7 @@ func oneDriveDeleteCmd() *cobra.Command { } } -// deletes an exchange service backup. +// deletes a oneDrive service backup. func deleteOneDriveCmd(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/src/cli/backup/sharepoint.go b/src/cli/backup/sharepoint.go new file mode 100644 index 000000000..e56d3e794 --- /dev/null +++ b/src/cli/backup/sharepoint.go @@ -0,0 +1,284 @@ +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/kopia" + "github.com/alcionai/corso/src/internal/model" + "github.com/alcionai/corso/src/pkg/backup" + "github.com/alcionai/corso/src/pkg/path" + "github.com/alcionai/corso/src/pkg/repository" + "github.com/alcionai/corso/src/pkg/selectors" + "github.com/alcionai/corso/src/pkg/store" +) + +// ------------------------------------------------------------------------------------------------ +// setup and globals +// ------------------------------------------------------------------------------------------------ + +var ( + site []string + + sharepointData []string +) + +const ( + dataLibraries = "libraries" +) + +const ( + sharePointServiceCommand = "sharepoint" + sharePointServiceCommandCreateUseSuffix = "--site | '" + utils.Wildcard + "'" + sharePointServiceCommandDeleteUseSuffix = "--backup " + // sharePointServiceCommandDetailsUseSuffix = "--backup " +) + +const ( + sharePointServiceCommandCreateExamples = `# Backup SharePoint data for +corso backup create sharepoint --site + +# Backup SharePoint for Alice and Bob +corso backup create sharepoint --site , + +# TODO: Site IDs may contain commas. We'll need to warn the site about escaping them. + +# Backup all SharePoint data for all sites +corso backup create sharepoint --site '*'` + + sharePointServiceCommandDeleteExamples = `# Delete SharePoint backup with ID 1234abcd-12ab-cd34-56de-1234abcd +corso backup delete sharepoint --backup 1234abcd-12ab-cd34-56de-1234abcd` + +// sharePointServiceCommandDetailsExamples = `# Explore 's files from backup 1234abcd-12ab-cd34-56de-1234abcd +// +// corso backup details sharepoint --backup 1234abcd-12ab-cd34-56de-1234abcd --site ` +) + +// called by backup.go to map parent subcommands to provider-specific handling. +func addSharePointCommands(parent *cobra.Command) *cobra.Command { + var ( + c *cobra.Command + fs *pflag.FlagSet + ) + + switch parent.Use { + case createCommand: + c, fs = utils.AddCommand(parent, sharePointCreateCmd(), utils.HideCommand()) + + c.Use = c.Use + " " + sharePointServiceCommandCreateUseSuffix + c.Example = sharePointServiceCommandCreateExamples + + fs.StringArrayVar(&site, + utils.SiteFN, nil, + "Backup SharePoint data by site ID; accepts '"+utils.Wildcard+"' to select all sites. (required)") + // TODO: implement + fs.StringSliceVar( + &sharepointData, + utils.DataFN, nil, + "Select one or more types of data to backup: "+dataLibraries) + options.AddOperationFlags(c) + + case listCommand: + c, fs = utils.AddCommand(parent, sharePointListCmd(), utils.HideCommand()) + + fs.StringVar(&backupID, + "backup", "", + "ID of the backup to retrieve.") + + // case detailsCommand: + // c, fs = utils.AddCommand(parent, sharePointDetailsCmd()) + + case deleteCommand: + c, fs = utils.AddCommand(parent, sharePointDeleteCmd(), utils.HideCommand()) + + c.Use = c.Use + " " + sharePointServiceCommandDeleteUseSuffix + c.Example = sharePointServiceCommandDeleteExamples + + fs.StringVar(&backupID, + utils.BackupFN, "", + "ID of the backup to delete. (required)") + cobra.CheckErr(c.MarkFlagRequired(utils.BackupFN)) + } + + return c +} + +// ------------------------------------------------------------------------------------------------ +// backup create +// ------------------------------------------------------------------------------------------------ + +// `corso backup create sharepoint [...]` +func sharePointCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: sharePointServiceCommand, + Short: "Backup M365 SharePoint service data", + RunE: createSharePointCmd, + Args: cobra.NoArgs, + Example: sharePointServiceCommandCreateExamples, + } +} + +// processes an sharepoint service backup. +func createSharePointCmd(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if utils.HasNoFlagsAndShownHelp(cmd) { + return nil + } + + if err := validateSharePointBackupCreateFlags(site); 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 := sharePointBackupCreateSelectors(site) + + bo, err := r.NewBackup(ctx, sel) + if err != nil { + return Only(ctx, errors.Wrap(err, "Failed to initialize SharePoint backup")) + } + + err = bo.Run(ctx) + if err != nil { + return Only(ctx, errors.Wrap(err, "Failed to run SharePoint 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 validateSharePointBackupCreateFlags(sites []string) error { + if len(sites) == 0 { + return errors.New("requires one or more --site ids or the wildcard --site *") + } + + return nil +} + +func sharePointBackupCreateSelectors(sites []string) selectors.Selector { + sel := selectors.NewSharePointBackup() + sel.Include(sel.Sites(sites)) + + return sel.Selector +} + +// ------------------------------------------------------------------------------------------------ +// backup list +// ------------------------------------------------------------------------------------------------ + +// `corso backup list sharepoint [...]` +func sharePointListCmd() *cobra.Command { + return &cobra.Command{ + Use: sharePointServiceCommand, + Short: "List the history of M365 SharePoint service backups", + RunE: listSharePointCmd, + Args: cobra.NoArgs, + } +} + +// lists the history of backup operations +func listSharePointCmd(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) + + if len(backupID) > 0 { + b, err := r.Backup(ctx, model.StableID(backupID)) + if err != nil { + if errors.Is(err, kopia.ErrNotFound) { + return Only(ctx, errors.Errorf("No backup exists with the id %s", backupID)) + } + + return Only(ctx, errors.Wrap(err, "Failed to find backup "+backupID)) + } + + b.Print(ctx) + + return nil + } + + bs, err := r.Backups(ctx, store.Service(path.SharePointService)) + if err != nil { + return Only(ctx, errors.Wrap(err, "Failed to list backups in the repository")) + } + + backup.PrintAll(ctx, bs) + + return nil +} + +// ------------------------------------------------------------------------------------------------ +// backup delete +// ------------------------------------------------------------------------------------------------ + +// `corso backup delete sharepoint [...]` +func sharePointDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: sharePointServiceCommand, + Short: "Delete backed-up M365 SharePoint service data", + RunE: deleteSharePointCmd, + Args: cobra.NoArgs, + Example: sharePointServiceCommandDeleteExamples, + } +} + +// deletes a sharePoint service backup. +func deleteSharePointCmd(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)) + } + + Info(ctx, "Deleted SharePoint backup ", backupID) + + return nil +} diff --git a/src/cli/backup/sharepoint_test.go b/src/cli/backup/sharepoint_test.go new file mode 100644 index 000000000..673e7def9 --- /dev/null +++ b/src/cli/backup/sharepoint_test.go @@ -0,0 +1,126 @@ +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 SharePointSuite struct { + suite.Suite +} + +func TestSharePointSuite(t *testing.T) { + suite.Run(t, new(SharePointSuite)) +} + +func (suite *SharePointSuite) TestAddSharePointCommands() { + expectUse := sharePointServiceCommand + + table := []struct { + name string + use string + expectUse string + expectShort string + expectRunE func(*cobra.Command, []string) error + }{ + { + "create sharepoint", createCommand, expectUse + " " + sharePointServiceCommandCreateUseSuffix, + sharePointCreateCmd().Short, createSharePointCmd, + }, + { + "list sharepoint", listCommand, expectUse, + sharePointListCmd().Short, listSharePointCmd, + }, + // { + // "details sharepoint", detailsCommand, expectUse + " " + sharePointServiceCommandDetailsUseSuffix, + // sharePointDetailsCmd().Short, detailsSharePointCmd, + // }, + { + "delete sharepoint", deleteCommand, expectUse + " " + sharePointServiceCommandDeleteUseSuffix, + sharePointDeleteCmd().Short, deleteSharePointCmd, + }, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + cmd := &cobra.Command{Use: test.use} + + c := addSharePointCommands(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 *SharePointSuite) TestValidateSharePointBackupCreateFlags() { + table := []struct { + name string + site []string + expect assert.ErrorAssertionFunc + }{ + { + name: "no sites", + expect: assert.Error, + }, + { + name: "sites", + site: []string{"fnord"}, + expect: assert.NoError, + }, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + test.expect(t, validateSharePointBackupCreateFlags(test.site)) + }) + } +} + +// func (suite *SharePointSuite) TestSharePointBackupDetailsSelectors() { +// ctx, flush := tester.NewContext() +// defer flush() + +// for _, test := range testdata.SharePointOptionDetailLookups { +// suite.T().Run(test.Name, func(t *testing.T) { +// output, err := runDetailsSharePointCmd( +// ctx, +// test.BackupGetter, +// "backup-ID", +// test.Opts, +// ) +// assert.NoError(t, err) + +// assert.ElementsMatch(t, test.Expected, output.Entries) +// }) +// } +// } + +// func (suite *SharePointSuite) TestSharePointBackupDetailsSelectorsBadFormats() { +// ctx, flush := tester.NewContext() +// defer flush() + +// for _, test := range testdata.BadSharePointOptionsFormats { +// suite.T().Run(test.Name, func(t *testing.T) { +// output, err := runDetailsSharePointCmd( +// ctx, +// test.BackupGetter, +// "backup-ID", +// test.Opts, +// ) + +// assert.Error(t, err) +// assert.Empty(t, output) +// }) +// } +// } diff --git a/src/cli/utils/utils.go b/src/cli/utils/utils.go index 305fcae6a..0dc43d079 100644 --- a/src/cli/utils/utils.go +++ b/src/cli/utils/utils.go @@ -17,6 +17,7 @@ import ( const ( BackupFN = "backup" DataFN = "data" + SiteFN = "site" UserFN = "user" ) @@ -57,10 +58,32 @@ func HasNoFlagsAndShownHelp(cmd *cobra.Command) bool { return false } +type cmdCfg struct { + hidden bool +} + +type cmdOpt func(*cmdCfg) + +func (cc *cmdCfg) populate(opts ...cmdOpt) { + for _, opt := range opts { + opt(cc) + } +} + +func HideCommand() cmdOpt { + return func(cc *cmdCfg) { + cc.hidden = true + } +} + // AddCommand adds a clone of the subCommand to the parent, // and returns both the clone and its pflags. -func AddCommand(parent, c *cobra.Command) (*cobra.Command, *pflag.FlagSet) { +func AddCommand(parent, c *cobra.Command, opts ...cmdOpt) (*cobra.Command, *pflag.FlagSet) { + cc := &cmdCfg{} + cc.populate(opts...) + parent.AddCommand(c) + c.Hidden = cc.hidden c.Flags().SortFlags = false