Add export support for Group SharePoint (#4260)

<!-- PR description-->

Ideally goes in after https://github.com/alcionai/corso/pull/4257 .
We could merge this instead of https://github.com/alcionai/corso/pull/4258 but I thought we could skip for now as this as been tested much less. But outside of me being paranoid, I think this should be in a good shape.

If this do go in, we can update the CHANGELOG entry to say that we do support export for SP libs.

This will also need more e2e(sanity) tests which I'll add in a follow up PR.

---

#### Does this PR need a docs update or release note?

- [ ]  Yes, it's included
- [x] 🕐 Yes, but in a later PR
- [ ]  No

#### Type of change

<!--- Please check the type of change your PR introduces: --->
- [x] 🌻 Feature
- [ ] 🐛 Bugfix
- [ ] 🗺️ Documentation
- [ ] 🤖 Supportability/Tests
- [ ] 💻 CI/Deployment
- [ ] 🧹 Tech Debt/Cleanup

#### Issue(s)

<!-- Can reference multiple issues. Use one of the following "magic words" - "closes, fixes" to auto-close the Github issue. -->
* closes https://github.com/alcionai/corso/issues/4259

#### Test Plan

<!-- How will this be tested prior to merging.-->
- [ ] 💪 Manual
- [x]  Unit test
- [ ] 💚 E2E
This commit is contained in:
Abin Simon 2023-09-20 12:12:42 +05:30 committed by GitHub
parent 4814154928
commit 1389b53eaf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 204 additions and 33 deletions

View File

@ -76,7 +76,7 @@ func streamItems(
// isMetadataFile is used to determine if a path corresponds to a // isMetadataFile is used to determine if a path corresponds to a
// metadata file. This is OneDrive specific logic and depends on the // metadata file. This is OneDrive specific logic and depends on the
// version of the backup unlike metadata.IsMetadataFile which only has // version of the backup unlike metadata.isMetadataFile which only has
// to be concerned about the current version. // to be concerned about the current version.
func isMetadataFile(id string, backupVersion int) bool { func isMetadataFile(id string, backupVersion int) bool {
if backupVersion < version.OneDrive1DataAndMetaFiles { if backupVersion < version.OneDrive1DataAndMetaFiles {

View File

@ -50,10 +50,15 @@ type Controller struct {
mu sync.Mutex mu sync.Mutex
status support.ControllerOperationStatus // contains the status of the last run status status support.ControllerOperationStatus // contains the status of the last run status
// backupDriveIDNames is populated on restore. It maps the backup's // backupDriveIDNames is populated on restore and export. It maps
// drive names to their id. Primarily for use when creating or looking // the backup's drive names to their id. Primarily for use when
// up a new drive. // creating or looking up a new drive.
backupDriveIDNames idname.CacheBuilder backupDriveIDNames idname.CacheBuilder
// backupSiteIDWebURL is populated on restore and export. It maps
// the backup's site names to their id. Primarily for use in
// exports for groups.
backupSiteIDWebURL idname.CacheBuilder
} }
func NewController( func NewController(
@ -99,6 +104,7 @@ func NewController(
tenant: acct.ID(), tenant: acct.ID(),
wg: &sync.WaitGroup{}, wg: &sync.WaitGroup{},
backupDriveIDNames: idname.NewCache(nil), backupDriveIDNames: idname.NewCache(nil),
backupSiteIDWebURL: idname.NewCache(nil),
} }
return &ctrl, nil return &ctrl, nil
@ -163,10 +169,12 @@ func (ctrl *Controller) incrementAwaitingMessages() {
func (ctrl *Controller) CacheItemInfo(dii details.ItemInfo) { func (ctrl *Controller) CacheItemInfo(dii details.ItemInfo) {
if dii.Groups != nil { if dii.Groups != nil {
ctrl.backupDriveIDNames.Add(dii.Groups.DriveID, dii.Groups.DriveName) ctrl.backupDriveIDNames.Add(dii.Groups.DriveID, dii.Groups.DriveName)
ctrl.backupSiteIDWebURL.Add(dii.Groups.SiteID, dii.Groups.WebURL)
} }
if dii.SharePoint != nil { if dii.SharePoint != nil {
ctrl.backupDriveIDNames.Add(dii.SharePoint.DriveID, dii.SharePoint.DriveName) ctrl.backupDriveIDNames.Add(dii.SharePoint.DriveID, dii.SharePoint.DriveName)
ctrl.backupSiteIDWebURL.Add(dii.SharePoint.SiteID, dii.SharePoint.WebURL)
} }
if dii.OneDrive != nil { if dii.OneDrive != nil {

View File

@ -270,6 +270,8 @@ func (suite *ControllerUnitSuite) TestController_CacheItemInfo() {
odname = "od-name" odname = "od-name"
spid = "sp-id" spid = "sp-id"
spname = "sp-name" spname = "sp-name"
spsid = "sp-sid"
spurl = "sp-url"
gpid = "gp-id" gpid = "gp-id"
gpname = "gp-name" gpname = "gp-name"
// intentionally declared outside the test loop // intentionally declared outside the test loop
@ -277,32 +279,35 @@ func (suite *ControllerUnitSuite) TestController_CacheItemInfo() {
wg: &sync.WaitGroup{}, wg: &sync.WaitGroup{},
region: &trace.Region{}, region: &trace.Region{},
backupDriveIDNames: idname.NewCache(nil), backupDriveIDNames: idname.NewCache(nil),
backupSiteIDWebURL: idname.NewCache(nil),
} }
) )
table := []struct { table := []struct {
name string name string
service path.ServiceType service path.ServiceType
cat path.CategoryType cat path.CategoryType
dii details.ItemInfo dii details.ItemInfo
expectID string expectDriveID string
expectName string expectDriveName string
expectSiteID string
expectSiteWebURL string
}{ }{
{ {
name: "exchange", name: "exchange",
dii: details.ItemInfo{ dii: details.ItemInfo{
Exchange: &details.ExchangeInfo{}, Exchange: &details.ExchangeInfo{},
}, },
expectID: "", expectDriveID: "",
expectName: "", expectDriveName: "",
}, },
{ {
name: "folder", name: "folder",
dii: details.ItemInfo{ dii: details.ItemInfo{
Folder: &details.FolderInfo{}, Folder: &details.FolderInfo{},
}, },
expectID: "", expectDriveID: "",
expectName: "", expectDriveName: "",
}, },
{ {
name: "onedrive", name: "onedrive",
@ -312,8 +317,8 @@ func (suite *ControllerUnitSuite) TestController_CacheItemInfo() {
DriveName: odname, DriveName: odname,
}, },
}, },
expectID: odid, expectDriveID: odid,
expectName: odname, expectDriveName: odname,
}, },
{ {
name: "sharepoint", name: "sharepoint",
@ -321,10 +326,14 @@ func (suite *ControllerUnitSuite) TestController_CacheItemInfo() {
SharePoint: &details.SharePointInfo{ SharePoint: &details.SharePointInfo{
DriveID: spid, DriveID: spid,
DriveName: spname, DriveName: spname,
SiteID: spsid,
WebURL: spurl,
}, },
}, },
expectID: spid, expectDriveID: spid,
expectName: spname, expectDriveName: spname,
expectSiteID: spsid,
expectSiteWebURL: spurl,
}, },
{ {
name: "groups", name: "groups",
@ -332,10 +341,14 @@ func (suite *ControllerUnitSuite) TestController_CacheItemInfo() {
Groups: &details.GroupsInfo{ Groups: &details.GroupsInfo{
DriveID: gpid, DriveID: gpid,
DriveName: gpname, DriveName: gpname,
SiteID: spsid,
WebURL: spurl,
}, },
}, },
expectID: gpid, expectDriveID: gpid,
expectName: gpname, expectDriveName: gpname,
expectSiteID: spsid,
expectSiteWebURL: spurl,
}, },
} }
for _, test := range table { for _, test := range table {
@ -344,11 +357,17 @@ func (suite *ControllerUnitSuite) TestController_CacheItemInfo() {
ctrl.CacheItemInfo(test.dii) ctrl.CacheItemInfo(test.dii)
name, _ := ctrl.backupDriveIDNames.NameOf(test.expectID) name, _ := ctrl.backupDriveIDNames.NameOf(test.expectDriveID)
assert.Equal(t, test.expectName, name) assert.Equal(t, test.expectDriveName, name)
id, _ := ctrl.backupDriveIDNames.IDOf(test.expectName) id, _ := ctrl.backupDriveIDNames.IDOf(test.expectDriveName)
assert.Equal(t, test.expectID, id) assert.Equal(t, test.expectDriveID, id)
url, _ := ctrl.backupSiteIDWebURL.NameOf(test.expectSiteID)
assert.Equal(t, test.expectSiteWebURL, url)
sid, _ := ctrl.backupSiteIDWebURL.IDOf(test.expectSiteWebURL)
assert.Equal(t, test.expectSiteID, sid)
}) })
} }
} }

View File

@ -69,6 +69,8 @@ func (ctrl *Controller) ProduceExportCollections(
exportCfg, exportCfg,
opts, opts,
dcs, dcs,
ctrl.backupDriveIDNames,
ctrl.backupSiteIDWebURL,
deets, deets,
errs) errs)

View File

@ -2,13 +2,19 @@ package groups
import ( import (
"context" "context"
stdlibpath "path"
"github.com/alcionai/clues"
"github.com/alcionai/corso/src/internal/common/idname"
"github.com/alcionai/corso/src/internal/data" "github.com/alcionai/corso/src/internal/data"
"github.com/alcionai/corso/src/internal/m365/collection/drive"
"github.com/alcionai/corso/src/internal/m365/collection/groups" "github.com/alcionai/corso/src/internal/m365/collection/groups"
"github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/export" "github.com/alcionai/corso/src/pkg/export"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
) )
@ -20,6 +26,8 @@ func ProduceExportCollections(
exportCfg control.ExportConfig, exportCfg control.ExportConfig,
opts control.Options, opts control.Options,
dcs []data.RestoreCollection, dcs []data.RestoreCollection,
backupDriveIDNames idname.Cacher,
backupSiteIDWebURL idname.Cacher,
deets *details.Builder, deets *details.Builder,
errs *fault.Bus, errs *fault.Bus,
) ([]export.Collectioner, error) { ) ([]export.Collectioner, error) {
@ -33,21 +41,65 @@ func ProduceExportCollections(
fp = restoreColl.FullPath() fp = restoreColl.FullPath()
cat = fp.Category() cat = fp.Category()
folders = []string{cat.String()} folders = []string{cat.String()}
coll export.Collectioner
) )
switch cat { switch cat {
case path.ChannelMessagesCategory: case path.ChannelMessagesCategory:
folders = append(folders, fp.Folders()...) folders = append(folders, fp.Folders()...)
coll = groups.NewExportCollection(
path.Builder{}.Append(folders...).String(),
[]data.RestoreCollection{restoreColl},
backupVersion,
exportCfg)
case path.LibrariesCategory:
drivePath, err := path.ToDrivePath(restoreColl.FullPath())
if err != nil {
return nil, clues.Wrap(err, "transforming path to drive path").WithClues(ctx)
}
driveName, ok := backupDriveIDNames.NameOf(drivePath.DriveID)
if !ok {
// This should not happen, but just in case
logger.Ctx(ctx).With("drive_id", drivePath.DriveID).Info("drive name not found, using drive id")
driveName = drivePath.DriveID
}
folders := restoreColl.FullPath().Folders()
siteName := folders[1] // use siteID by default
webURL, ok := backupSiteIDWebURL.NameOf(siteName)
if !ok {
// This should not happen, but just in case
logger.Ctx(ctx).With("site_id", folders[1]).Info("site weburl not found, using site id")
}
if len(webURL) != 0 {
// We can't use the actual name anyways as it might
// contain invalid characters. This should also avoid
// possibility of name collisions.
siteName = stdlibpath.Base(webURL)
}
baseDir := path.Builder{}.
Append("Libraries").
Append(siteName).
Append(driveName).
Append(drivePath.Folders...)
coll = drive.NewExportCollection(
baseDir.String(),
[]data.RestoreCollection{restoreColl},
backupVersion)
default: default:
el.AddRecoverable(
ctx,
clues.New("unsupported category for export").With("category", cat))
continue continue
} }
coll := groups.NewExportCollection(
path.Builder{}.Append(folders...).String(),
[]data.RestoreCollection{restoreColl},
backupVersion,
exportCfg)
ec = append(ec, coll) ec = append(ec, coll)
} }

View File

@ -4,14 +4,18 @@ import (
"bytes" "bytes"
"context" "context"
"io" "io"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/common/idname"
"github.com/alcionai/corso/src/internal/data" "github.com/alcionai/corso/src/internal/data"
dataMock "github.com/alcionai/corso/src/internal/data/mock" dataMock "github.com/alcionai/corso/src/internal/data/mock"
groupMock "github.com/alcionai/corso/src/internal/m365/service/groups/mock" groupMock "github.com/alcionai/corso/src/internal/m365/service/groups/mock"
odConsts "github.com/alcionai/corso/src/internal/m365/service/onedrive/consts"
odStub "github.com/alcionai/corso/src/internal/m365/service/onedrive/stub"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/internal/version" "github.com/alcionai/corso/src/internal/version"
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
@ -30,6 +34,7 @@ func TestExportUnitSuite(t *testing.T) {
type finD struct { type finD struct {
id string id string
key string
name string name string
err error err error
} }
@ -42,14 +47,14 @@ func (fd finD) FetchItemByName(ctx context.Context, name string) (data.Item, err
if name == fd.id { if name == fd.id {
return &dataMock.Item{ return &dataMock.Item{
ItemID: fd.id, ItemID: fd.id,
Reader: io.NopCloser(bytes.NewBufferString(`{"displayname": "` + fd.name + `"}`)), Reader: io.NopCloser(bytes.NewBufferString(`{"` + fd.key + `": "` + fd.name + `"}`)),
}, nil }, nil
} }
return nil, assert.AnError return nil, assert.AnError
} }
func (suite *ExportUnitSuite) TestExportRestoreCollections() { func (suite *ExportUnitSuite) TestExportRestoreCollections_messages() {
t := suite.T() t := suite.T()
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
@ -87,7 +92,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections() {
}, },
}, },
}, },
FetchItemByNamer: finD{id: itemID, name: dii.Groups.ItemName}, FetchItemByNamer: finD{id: itemID, key: "displayname", name: dii.Groups.ItemName},
}, },
} }
@ -98,6 +103,8 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections() {
control.DefaultOptions(), control.DefaultOptions(),
dcs, dcs,
nil, nil,
nil,
nil,
fault.New(true)) fault.New(true))
assert.NoError(t, err, "export collections error") assert.NoError(t, err, "export collections error")
assert.Len(t, ecs, 1, "num of collections") assert.Len(t, ecs, 1, "num of collections")
@ -115,3 +122,86 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections() {
assert.Equal(t, expectedItems, fitems, "items") assert.Equal(t, expectedItems, fitems, "items")
} }
func (suite *ExportUnitSuite) TestExportRestoreCollections_libraries() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
var (
siteID = "siteID1"
siteEscapedName = "siteName1"
siteWebURL = "https://site1.sharepoint.com/sites/" + siteEscapedName
driveID = "driveID1"
driveName = "driveName1"
exportCfg = control.ExportConfig{}
dpb = odConsts.DriveFolderPrefixBuilder(driveID)
driveNameCache = idname.NewCache(
// Cache check with lowercased ids
map[string]string{strings.ToLower(driveID): driveName})
siteWebURLCache = idname.NewCache(
// Cache check with lowercased ids
map[string]string{strings.ToLower(siteID): siteWebURL})
dii = odStub.DriveItemInfo()
expectedPath = "Libraries/" + siteEscapedName + "/" + driveName
expectedItems = []export.Item{
{
ID: "id1.data",
Name: "name1",
Body: io.NopCloser((bytes.NewBufferString("body1"))),
},
}
)
dii.OneDrive.ItemName = "name1"
p, err := dpb.ToDataLayerPath(
"t",
"u",
path.GroupsService,
path.LibrariesCategory,
false,
odConsts.SitesPathDir,
siteID)
assert.NoError(t, err, "build path")
dcs := []data.RestoreCollection{
data.FetchRestoreCollection{
Collection: dataMock.Collection{
Path: p,
ItemData: []data.Item{
&dataMock.Item{
ItemID: "id1.data",
Reader: io.NopCloser(bytes.NewBufferString("body1")),
ItemInfo: dii,
},
},
},
FetchItemByNamer: finD{id: "id1.meta", key: "filename", name: "name1"},
},
}
ecs, err := ProduceExportCollections(
ctx,
int(version.Backup),
exportCfg,
control.DefaultOptions(),
dcs,
driveNameCache,
siteWebURLCache,
nil,
fault.New(true))
assert.NoError(t, err, "export collections error")
assert.Len(t, ecs, 1, "num of collections")
assert.Equal(t, expectedPath, ecs[0].BasePath(), "base dir")
fitems := []export.Item{}
for item := range ecs[0].Items(ctx) {
fitems = append(fitems, item)
}
assert.Equal(t, expectedItems, fitems, "items")
}