diff --git a/src/cli/restore/groups.go b/src/cli/restore/groups.go index fd4927685..f3f3290f4 100644 --- a/src/cli/restore/groups.go +++ b/src/cli/restore/groups.go @@ -5,8 +5,8 @@ import ( "github.com/spf13/pflag" "github.com/alcionai/corso/src/cli/flags" - . "github.com/alcionai/corso/src/cli/print" "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/internal/common/dttm" ) // called by restore.go to map subcommands to provider-specific handling. @@ -79,5 +79,25 @@ func restoreGroupsCmd(cmd *cobra.Command, args []string) error { return nil } - return Only(ctx, utils.ErrNotYetImplemented) + opts := utils.MakeGroupsOpts(cmd) + opts.RestoreCfg.DTTMFormat = dttm.HumanReadableDriveItem + + if flags.RunModeFV == flags.RunModeFlagTest { + return nil + } + + if err := utils.ValidateGroupsRestoreFlags(flags.BackupIDFV, opts); err != nil { + return err + } + + sel := utils.IncludeGroupsRestoreDataSelectors(ctx, opts) + utils.FilterGroupsRestoreInfoSelectors(sel, opts) + + return runRestore( + ctx, + cmd, + opts.RestoreCfg, + sel.Selector, + flags.BackupIDFV, + "Groups") } diff --git a/src/cli/restore/groups_test.go b/src/cli/restore/groups_test.go index 4ea7a7d19..e0d87a775 100644 --- a/src/cli/restore/groups_test.go +++ b/src/cli/restore/groups_test.go @@ -83,8 +83,7 @@ func (suite *GroupsUnitSuite) TestAddGroupsCommands() { cmd.SetOut(new(bytes.Buffer)) // drop output cmd.SetErr(new(bytes.Buffer)) // drop output err := cmd.Execute() - // assert.NoError(t, err, clues.ToCore(err)) - assert.ErrorIs(t, err, utils.ErrNotYetImplemented, clues.ToCore(err)) + assert.NoError(t, err, clues.ToCore(err)) opts := utils.MakeGroupsOpts(cmd) assert.Equal(t, testdata.BackupInput, flags.BackupIDFV) diff --git a/src/internal/m365/controller.go b/src/internal/m365/controller.go index 3a4c8be19..e9f8161dc 100644 --- a/src/internal/m365/controller.go +++ b/src/internal/m365/controller.go @@ -161,6 +161,10 @@ func (ctrl *Controller) incrementAwaitingMessages() { } func (ctrl *Controller) CacheItemInfo(dii details.ItemInfo) { + if dii.Groups != nil { + ctrl.backupDriveIDNames.Add(dii.Groups.DriveID, dii.Groups.DriveName) + } + if dii.SharePoint != nil { ctrl.backupDriveIDNames.Add(dii.SharePoint.DriveID, dii.SharePoint.DriveName) } diff --git a/src/internal/m365/controller_test.go b/src/internal/m365/controller_test.go index 4178505c7..991aa7955 100644 --- a/src/internal/m365/controller_test.go +++ b/src/internal/m365/controller_test.go @@ -270,6 +270,8 @@ func (suite *ControllerUnitSuite) TestController_CacheItemInfo() { odname = "od-name" spid = "sp-id" spname = "sp-name" + gpid = "gp-id" + gpname = "gp-name" // intentionally declared outside the test loop ctrl = &Controller{ wg: &sync.WaitGroup{}, @@ -324,6 +326,17 @@ func (suite *ControllerUnitSuite) TestController_CacheItemInfo() { expectID: spid, expectName: spname, }, + { + name: "groups", + dii: details.ItemInfo{ + Groups: &details.GroupsInfo{ + DriveID: gpid, + DriveName: gpname, + }, + }, + expectID: gpid, + expectName: gpname, + }, } for _, test := range table { suite.Run(test.name, func() { @@ -423,6 +436,20 @@ func (suite *ControllerIntegrationSuite) TestEmptyCollections() { Service: selectors.ServiceSharePoint, }, }, + { + name: "GroupsNil", + col: nil, + sel: selectors.Selector{ + Service: selectors.ServiceGroups, + }, + }, + { + name: "GroupsEmpty", + col: []data.RestoreCollection{}, + sel: selectors.Selector{ + Service: selectors.ServiceGroups, + }, + }, } for _, test := range table { @@ -1249,6 +1276,28 @@ func (suite *ControllerIntegrationSuite) TestBackup_CreatesPrefixCollections() { // path.ListsCategory.String(), }, }, + { + name: "Groups", + resourceCat: resource.Sites, + selectorFunc: func(t *testing.T) selectors.Selector { + sel := selectors.NewGroupsBackup([]string{tconfig.M365TeamID(t)}) + sel.Include( + sel.LibraryFolders([]string{selectors.NoneTgt}), + // not yet in use + // sel.Pages([]string{selectors.NoneTgt}), + // sel.Lists([]string{selectors.NoneTgt}), + ) + + return sel.Selector + }, + service: path.GroupsService, + categories: []string{ + path.LibrariesCategory.String(), + // not yet in use + // path.PagesCategory.String(), + // path.ListsCategory.String(), + }, + }, } for _, test := range table { @@ -1381,12 +1430,14 @@ func (suite *DisconnectedUnitSuite) TestController_Status() { func (suite *DisconnectedUnitSuite) TestVerifyBackupInputs_allServices() { sites := []string{"abc.site.foo", "bar.site.baz"} + groups := []string{"123", "456"} tests := []struct { name string excludes func(t *testing.T) selectors.Selector filters func(t *testing.T) selectors.Selector includes func(t *testing.T) selectors.Selector + cachedIDs []string checkError assert.ErrorAssertionFunc }{ { @@ -1433,6 +1484,7 @@ func (suite *DisconnectedUnitSuite) TestVerifyBackupInputs_allServices() { { name: "valid sites", checkError: assert.NoError, + cachedIDs: sites, excludes: func(t *testing.T) selectors.Selector { sel := selectors.NewSharePointBackup([]string{"abc.site.foo", "bar.site.baz"}) sel.DiscreteOwner = "abc.site.foo" @@ -1455,6 +1507,7 @@ func (suite *DisconnectedUnitSuite) TestVerifyBackupInputs_allServices() { { name: "invalid sites", checkError: assert.Error, + cachedIDs: sites, excludes: func(t *testing.T) selectors.Selector { sel := selectors.NewSharePointBackup([]string{"fnords.smarfs.brawnhilda"}) sel.Exclude(sel.AllData()) @@ -1471,17 +1524,61 @@ func (suite *DisconnectedUnitSuite) TestVerifyBackupInputs_allServices() { return sel.Selector }, }, + + { + name: "valid groups", + checkError: assert.NoError, + cachedIDs: groups, + excludes: func(t *testing.T) selectors.Selector { + sel := selectors.NewGroupsBackup([]string{"123", "456"}) + sel.DiscreteOwner = "123" + sel.Exclude(sel.AllData()) + return sel.Selector + }, + filters: func(t *testing.T) selectors.Selector { + sel := selectors.NewGroupsBackup([]string{"123", "456"}) + sel.DiscreteOwner = "123" + sel.Filter(sel.AllData()) + return sel.Selector + }, + includes: func(t *testing.T) selectors.Selector { + sel := selectors.NewGroupsBackup([]string{"123", "456"}) + sel.DiscreteOwner = "123" + sel.Include(sel.AllData()) + return sel.Selector + }, + }, + { + name: "invalid groups", + checkError: assert.Error, + cachedIDs: groups, + excludes: func(t *testing.T) selectors.Selector { + sel := selectors.NewGroupsBackup([]string{"789"}) + sel.Exclude(sel.AllData()) + return sel.Selector + }, + filters: func(t *testing.T) selectors.Selector { + sel := selectors.NewGroupsBackup([]string{"789"}) + sel.Filter(sel.AllData()) + return sel.Selector + }, + includes: func(t *testing.T) selectors.Selector { + sel := selectors.NewGroupsBackup([]string{"789"}) + sel.Include(sel.AllData()) + return sel.Selector + }, + }, } for _, test := range tests { suite.Run(test.name, func() { t := suite.T() - err := verifyBackupInputs(test.excludes(t), sites) + err := verifyBackupInputs(test.excludes(t), test.cachedIDs) test.checkError(t, err, clues.ToCore(err)) - err = verifyBackupInputs(test.filters(t), sites) + err = verifyBackupInputs(test.filters(t), test.cachedIDs) test.checkError(t, err, clues.ToCore(err)) - err = verifyBackupInputs(test.includes(t), sites) + err = verifyBackupInputs(test.includes(t), test.cachedIDs) test.checkError(t, err, clues.ToCore(err)) }) } diff --git a/src/internal/m365/restore.go b/src/internal/m365/restore.go index 1abd4d9b7..616fd6f2b 100644 --- a/src/internal/m365/restore.go +++ b/src/internal/m365/restore.go @@ -10,6 +10,7 @@ import ( "github.com/alcionai/corso/src/internal/m365/collection/drive" "github.com/alcionai/corso/src/internal/m365/graph" "github.com/alcionai/corso/src/internal/m365/service/exchange" + "github.com/alcionai/corso/src/internal/m365/service/groups" "github.com/alcionai/corso/src/internal/m365/service/onedrive" "github.com/alcionai/corso/src/internal/m365/service/sharepoint" "github.com/alcionai/corso/src/internal/m365/support" @@ -77,6 +78,16 @@ func (ctrl *Controller) ConsumeRestoreCollections( deets, errs, ctr) + case path.GroupsService: + status, err = groups.ConsumeRestoreCollections( + ctx, + rcc, + ctrl.AC, + ctrl.backupDriveIDNames, + dcs, + deets, + errs, + ctr) default: err = clues.Wrap(clues.New(service.String()), "service not supported") } diff --git a/src/internal/m365/service/groups/restore.go b/src/internal/m365/service/groups/restore.go index 52b7c5b9b..a233535e2 100644 --- a/src/internal/m365/service/groups/restore.go +++ b/src/internal/m365/service/groups/restore.go @@ -5,12 +5,17 @@ import ( "errors" "github.com/alcionai/clues" + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/alcionai/corso/src/internal/common/dttm" "github.com/alcionai/corso/src/internal/common/idname" + "github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/data" + "github.com/alcionai/corso/src/internal/m365/collection/drive" "github.com/alcionai/corso/src/internal/m365/support" "github.com/alcionai/corso/src/internal/operations/inject" "github.com/alcionai/corso/src/pkg/backup/details" + "github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/count" "github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/path" @@ -30,8 +35,9 @@ func ConsumeRestoreCollections( ) (*support.ControllerOperationStatus, error) { var ( restoreMetrics support.CollectionMetrics - // caches = onedrive.NewRestoreCaches(backupDriveIDNames) - el = errs.Local() + caches = drive.NewRestoreCaches(backupDriveIDNames) + lrh = drive.NewLibraryRestoreHandler(ac, rcc.Selector.PathService()) + el = errs.Local() ) // TODO: uncomment when a handler is available @@ -52,6 +58,7 @@ func ConsumeRestoreCollections( var ( err error + resp models.Siteable category = dc.FullPath().Category() metrics support.CollectionMetrics ictx = clues.Add(ctx, @@ -63,8 +70,39 @@ func ConsumeRestoreCollections( switch dc.FullPath().Category() { case path.LibrariesCategory: - // TODO + // TODO(meain): As of now we only restore the root site + // and that too to whatever is currently the root site of the + // group and not the original one. Not sure if the + // original can be changed. + resp, err = ac.Groups().GetRootSite(ctx, rcc.ProtectedResource.ID()) + if err != nil { + return nil, err + } + pr := idname.NewProvider(ptr.Val(resp.GetId()), ptr.Val(resp.GetName())) + srcc := inject.RestoreConsumerConfig{ + BackupVersion: rcc.BackupVersion, + Options: rcc.Options, + ProtectedResource: pr, + RestoreConfig: rcc.RestoreConfig, + Selector: rcc.Selector, + } + + err = caches.Populate(ctx, lrh, srcc.ProtectedResource.ID()) + if err != nil { + return nil, clues.Wrap(err, "initializing restore caches") + } + + metrics, err = drive.RestoreCollection( + ictx, + lrh, + srcc, + dc, + caches, + deets, + control.DefaultRestoreContainerName(dttm.HumanReadableDriveItem), + errs, + ctr) default: return nil, clues.New("data category not supported"). With("category", category). diff --git a/src/internal/operations/pathtransformer/restore_path_transformer.go b/src/internal/operations/pathtransformer/restore_path_transformer.go index a6e307c3f..a618deedd 100644 --- a/src/internal/operations/pathtransformer/restore_path_transformer.go +++ b/src/internal/operations/pathtransformer/restore_path_transformer.go @@ -28,7 +28,7 @@ func locationRef( // was in the root of the data type. elems := repoRef.Folders() - if ent.OneDrive != nil || ent.SharePoint != nil { + if ent.OneDrive != nil || ent.SharePoint != nil || ent.Groups != nil { dp, err := path.ToDrivePath(repoRef) if err != nil { return nil, clues.Wrap(err, "fallback for LocationRef") @@ -73,6 +73,8 @@ func drivePathMerge( if ent.SharePoint != nil { driveID = ent.SharePoint.DriveID + } else if ent.Groups != nil { + driveID = ent.Groups.DriveID } else if ent.OneDrive != nil { driveID = ent.OneDrive.DriveID } @@ -87,9 +89,21 @@ func drivePathMerge( driveID = odp.DriveID } - return basicLocationPath( - repoRef, - path.BuildDriveLocation(driveID, locRef.Elements()...)) + driveLoc := path.BuildDriveLocation(driveID, locRef.Elements()...) + + if ent.Groups != nil { + siteID := ent.Groups.SiteID + + // Fallback to getting from RepoRef. + if len(siteID) == 0 { + folders := repoRef.Folders() + siteID = folders[1] + } + + driveLoc = path.BuildGroupsDriveLocation(siteID, driveID, locRef.Elements()...) + } + + return basicLocationPath(repoRef, driveLoc) } func makeRestorePathsForEntry( @@ -129,13 +143,14 @@ func makeRestorePathsForEntry( // * Exchange Email/Contacts // * OneDrive/SharePoint (needs drive information) switch true { - case ent.Exchange != nil || ent.Groups != nil: + case ent.Exchange != nil: // TODO(ashmrtn): Eventually make Events have it's own function to handle // setting the restore destination properly. res.RestorePath, err = basicLocationPath(repoRef, locRef) case ent.OneDrive != nil || (ent.SharePoint != nil && ent.SharePoint.ItemType == details.SharePointLibrary) || - (ent.SharePoint != nil && ent.SharePoint.ItemType == details.OneDriveItem): + (ent.SharePoint != nil && ent.SharePoint.ItemType == details.OneDriveItem) || + (ent.Groups != nil && ent.Groups.ItemType == details.SharePointLibrary): res.RestorePath, err = drivePathMerge(ent, repoRef, locRef) default: return res, clues.New("unknown entry type").WithClues(ctx) diff --git a/src/internal/operations/pathtransformer/restore_path_transformer_test.go b/src/internal/operations/pathtransformer/restore_path_transformer_test.go index e848f0ead..a3a9a4b1c 100644 --- a/src/internal/operations/pathtransformer/restore_path_transformer_test.go +++ b/src/internal/operations/pathtransformer/restore_path_transformer_test.go @@ -48,8 +48,10 @@ func (suite *RestorePathTransformerUnitSuite) TestGetPaths() { var ( driveID = "some-drive-id" + siteID = "some-site-id" extraItemName = "some-item" SharePointRootItemPath = testdata.SharePointRootPath.MustAppend(extraItemName, true) + GroupsRootItemPath = testdata.GroupsRootPath.MustAppend(extraItemName, true) ) table := []struct { @@ -59,6 +61,67 @@ func (suite *RestorePathTransformerUnitSuite) TestGetPaths() { expectErr assert.ErrorAssertionFunc expected []expectPaths }{ + { + name: "Groups List Errors", + // No version bump for the change so we always have to check for this. + backupVersion: version.All8MigrateUserPNToID, + input: []*details.Entry{ + { + RepoRef: GroupsRootItemPath.RR.String(), + LocationRef: GroupsRootItemPath.Loc.String(), + ItemInfo: details.ItemInfo{ + Groups: &details.GroupsInfo{ + ItemType: details.SharePointList, + }, + }, + }, + }, + expectErr: assert.Error, + }, + { + name: "Groups Page Errors", + // No version bump for the change so we always have to check for this. + backupVersion: version.All8MigrateUserPNToID, + input: []*details.Entry{ + { + RepoRef: GroupsRootItemPath.RR.String(), + LocationRef: GroupsRootItemPath.Loc.String(), + ItemInfo: details.ItemInfo{ + Groups: &details.GroupsInfo{ + ItemType: details.SharePointPage, + }, + }, + }, + }, + expectErr: assert.Error, + }, + { + name: "Groups, no LocationRef, no DriveID, item in root", + backupVersion: version.OneDrive6NameInMeta, + input: []*details.Entry{ + { + RepoRef: GroupsRootItemPath.RR.String(), + ItemInfo: details.ItemInfo{ + Groups: &details.GroupsInfo{ + ItemType: details.SharePointLibrary, + SiteID: siteID, + }, + }, + }, + }, + expectErr: assert.NoError, + expected: []expectPaths{ + { + storage: GroupsRootItemPath.RR.String(), + restore: toRestore( + GroupsRootItemPath.RR, + append( + []string{"sites", siteID, "drives"}, + // testdata path has '.d' on the drives folder we need to remove. + GroupsRootItemPath.RR.Folders()[3:]...)...), + }, + }, + }, { name: "SharePoint List Errors", // No version bump for the change so we always have to check for this. diff --git a/src/internal/operations/restore.go b/src/internal/operations/restore.go index a692241e9..ed9fc863c 100644 --- a/src/internal/operations/restore.go +++ b/src/internal/operations/restore.go @@ -428,7 +428,9 @@ func formatDetailsForRestoration( return nil, clues.Wrap(err, "getting restore paths") } - if sel.Service == selectors.ServiceOneDrive || sel.Service == selectors.ServiceSharePoint { + if sel.Service == selectors.ServiceOneDrive || + sel.Service == selectors.ServiceSharePoint || + sel.Service == selectors.ServiceGroups { paths, err = onedrive.AugmentRestorePaths(backupVersion, paths) if err != nil { return nil, clues.Wrap(err, "augmenting paths") diff --git a/src/pkg/backup/details/testdata/testdata.go b/src/pkg/backup/details/testdata/testdata.go index 30f8a402f..ee56d36c3 100644 --- a/src/pkg/backup/details/testdata/testdata.go +++ b/src/pkg/backup/details/testdata/testdata.go @@ -45,7 +45,9 @@ func locFromRepo(rr path.Path, isItem bool) *path.Builder { loc = loc.Append(strings.TrimSuffix(e, folderSuffix)) } - if rr.Service() == path.OneDriveService || rr.Category() == path.LibrariesCategory { + if rr.Service() == path.GroupsService { + loc = loc.PopFront().PopFront().PopFront() + } else if rr.Service() == path.OneDriveService || rr.Category() == path.LibrariesCategory { loc = loc.PopFront() } @@ -730,6 +732,8 @@ var ( }, } + GroupsRootPath = mustPathRep("tenant-id/groups/group-id/libraries/sites/site-id/drives/foo/root:", false) + SharePointRootPath = mustPathRep("tenant-id/sharepoint/site-id/libraries/drives/foo/root:", false) SharePointLibraryPath = SharePointRootPath.MustAppend("library", false) SharePointBasePath1 = SharePointLibraryPath.MustAppend("a", false) diff --git a/src/pkg/path/drive.go b/src/pkg/path/drive.go index c5427e5bc..666dade42 100644 --- a/src/pkg/path/drive.go +++ b/src/pkg/path/drive.go @@ -1,6 +1,8 @@ package path -import "github.com/alcionai/clues" +import ( + "github.com/alcionai/clues" +) // TODO: Move this into m365/collection/drive // drivePath is used to represent path components @@ -27,6 +29,15 @@ func ToDrivePath(p Path) (*DrivePath, error) { With("path_folders", p.Folder(false)) } + // FIXME(meain): Don't have any service specific code within this + // function. Change this to either accept only the fragment of the + // path that is the drive path or have a separate function for each + // service. + if p.Service() == GroupsService { + // Groups have an extra /sites/ in the path + return &DrivePath{DriveID: folders[3], Root: folders[4], Folders: folders[5:]}, nil + } + return &DrivePath{DriveID: folders[1], Root: folders[2], Folders: folders[3:]}, nil } @@ -49,3 +60,13 @@ func BuildDriveLocation( ) *Builder { return Builder{}.Append("drives", driveID).Append(unescapedElements...) } + +// BuildGroupsDriveLocation is same as BuildDriveLocation, but for +// group drives and thus includes siteID. +func BuildGroupsDriveLocation( + siteID string, + driveID string, + unescapedElements ...string, +) *Builder { + return Builder{}.Append("sites", siteID, "drives", driveID).Append(unescapedElements...) +}