merge main

This commit is contained in:
neha-Gupta1 2023-06-02 11:23:21 +05:30
commit e739ef5d4f
60 changed files with 1920 additions and 2603 deletions

View File

@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added ProtectedResourceName to the backup list json output. ProtectedResourceName holds either a UPN or a WebURL, depending on the resource type. - Added ProtectedResourceName to the backup list json output. ProtectedResourceName holds either a UPN or a WebURL, depending on the resource type.
- Rework base selection logic for incremental backups so it's more likely to find a valid base. - Rework base selection logic for incremental backups so it's more likely to find a valid base.
- Improve OneDrive restore performance by paralleling item restores
### Fixed ### Fixed
- Fix Exchange folder cache population error when parent folder isn't found. - Fix Exchange folder cache population error when parent folder isn't found.
- Fix Exchange backup issue caused by incorrect json serialization - Fix Exchange backup issue caused by incorrect json serialization
- Fix issues with details model containing duplicate entry for api consumers
### Changed ### Changed
- Do not display all the items that we restored at the end if there are more than 15. You can override this with `--verbose`. - Do not display all the items that we restored at the end if there are more than 15. You can override this with `--verbose`.

View File

@ -185,77 +185,109 @@ function Get-FoldersToPurge {
[string[]]$FolderNamePurgeList = @(), [string[]]$FolderNamePurgeList = @(),
[Parameter(Mandatory = $False, HelpMessage = "Purge folders with these prefixes")] [Parameter(Mandatory = $False, HelpMessage = "Purge folders with these prefixes")]
[string[]]$FolderPrefixPurgeList = @() [string[]]$FolderPrefixPurgeList = @(),
[Parameter(Mandatory = $False, HelpMessage = "Perform shallow traversal only")]
[bool]$PurgeTraversalShallow = $false
) )
$foldersToDelete = @() Write-Host "`nLooking for folders under well-known folder: $WellKnownRoot matching folders: $FolderNamePurgeList or prefixes: $FolderPrefixPurgeList for user: $User"
# SOAP message for getting the folders $foldersToDelete = @()
$body = @" $traversal = "Deep"
<FindFolder Traversal="Deep" xmlns="http://schemas.microsoft.com/exchange/services/2006/messages"> if ($PurgeTraversalShallow) {
$traversal = "Shallow"
}
$offset = 0
$moreToList = $true
# get all folder pages
while ($moreToList) {
# SOAP message for getting the folders
$body = @"
<FindFolder Traversal="$traversal" xmlns="http://schemas.microsoft.com/exchange/services/2006/messages">
<FolderShape> <FolderShape>
<t:BaseShape>Default</t:BaseShape> <t:BaseShape>Default</t:BaseShape>
<t:AdditionalProperties> <t:AdditionalProperties>
<t:ExtendedFieldURI PropertyTag="0x3007" PropertyType="SystemTime"/> <t:ExtendedFieldURI PropertyTag="0x3007" PropertyType="SystemTime"/>
</t:AdditionalProperties> </t:AdditionalProperties>
</FolderShape> </FolderShape>
<m:IndexedPageFolderView MaxEntriesReturned="1000" Offset="$offset" BasePoint="Beginning" />
<ParentFolderIds> <ParentFolderIds>
<t:DistinguishedFolderId Id="$WellKnownRoot"/> <t:DistinguishedFolderId Id="$WellKnownRoot"/>
</ParentFolderIds> </ParentFolderIds>
</FindFolder> </FindFolder>
"@ "@
Write-Host "`nLooking for folders under well-known folder: $WellKnownRoot matching folders: $FolderNamePurgeList or prefixes: $FolderPrefixPurgeList for user: $User" try {
$getFolderIdMsg = Initialize-SOAPMessage -User $User -Body $body Write-Host "`nRetrieving folders starting from offset: $offset"
$response = Invoke-SOAPRequest -Token $Token -Message $getFolderIdMsg
# Get the folders from the response $getFolderIdMsg = Initialize-SOAPMessage -User $User -Body $body
$folders = $response | Select-Xml -XPath "//t:Folders/*" -Namespace @{t = "http://schemas.microsoft.com/exchange/services/2006/types" } | $response = Invoke-SOAPRequest -Token $Token -Message $getFolderIdMsg
Select-Object -ExpandProperty Node
# Are there more folders to list # Are there more folders to list
$rootFolder = $response | Select-Xml -XPath "//m:RootFolder" -Namespace @{m = "http://schemas.microsoft.com/exchange/services/2006/messages" } | $rootFolder = $response | Select-Xml -XPath "//m:RootFolder" -Namespace @{m = "http://schemas.microsoft.com/exchange/services/2006/messages" } |
Select-Object -ExpandProperty Node Select-Object -ExpandProperty Node
$moreToList = ![System.Convert]::ToBoolean($rootFolder.IncludesLastItemInRange) $moreToList = ![System.Convert]::ToBoolean($rootFolder.IncludesLastItemInRange)
}
catch {
Write-Host "Error retrieving folders"
# Loop through folders Write-Host $response.OuterXml
foreach ($folder in $folders) { Exit
$folderName = $folder.DisplayName }
$folderCreateTime = $folder.ExtendedProperty
| Where-Object { $_.ExtendedFieldURI.PropertyTag -eq "0x3007" } # Get the folders from the response
| Select-Object -ExpandProperty Value $folders = $response | Select-Xml -XPath "//t:Folders/*" -Namespace @{t = "http://schemas.microsoft.com/exchange/services/2006/types" } |
| Get-Date Select-Object -ExpandProperty Node
if ($FolderNamePurgeList.count -gt 0) { # Loop through folders
$IsNameMatchParams = @{ foreach ($folder in $folders) {
'FolderName' = $folderName; $folderName = $folder.DisplayName
'FolderNamePurgeList' = $FolderNamePurgeList $folderCreateTime = $folder.ExtendedProperty
} | Where-Object { $_.ExtendedFieldURI.PropertyTag -eq "0x3007" }
| Select-Object -ExpandProperty Value
| Get-Date
if ((IsNameMatch @IsNameMatchParams)) { if ($FolderNamePurgeList.count -gt 0) {
Write-Host "• Found name match: $folderName ($folderCreateTime)" $IsNameMatchParams = @{
$foldersToDelete += $folder 'FolderName' = $folderName;
continue 'FolderNamePurgeList' = $FolderNamePurgeList
}
if ((IsNameMatch @IsNameMatchParams)) {
Write-Host "• Found name match: $folderName ($folderCreateTime)"
$foldersToDelete += $folder
continue
}
}
if ($FolderPrefixPurgeList.count -gt 0) {
$IsPrefixAndAgeMatchParams = @{
'FolderName' = $folderName;
'FolderCreateTime' = $folderCreateTime;
'FolderPrefixPurgeList' = $FolderPrefixPurgeList;
'PurgeBeforeTimestamp' = $PurgeBeforeTimestamp;
}
if ((IsPrefixAndAgeMatch @IsPrefixAndAgeMatchParams)) {
Write-Host "• Found prefix match: $folderName ($folderCreateTime)"
$foldersToDelete += $folder
}
} }
} }
if ($FolderPrefixPurgeList.count -gt 0) { if (!$moreToList -or $null -eq $folders) {
$IsPrefixAndAgeMatchParams = @{ Write-Host "Retrieved all folders."
'FolderName' = $folderName; }
'FolderCreateTime' = $folderCreateTime; else {
'FolderPrefixPurgeList' = $FolderPrefixPurgeList; $offset += $folders.count
'PurgeBeforeTimestamp' = $PurgeBeforeTimestamp;
}
if ((IsPrefixAndAgeMatch @IsPrefixAndAgeMatchParams)) {
Write-Host "• Found prefix match: $folderName ($folderCreateTime)"
$foldersToDelete += $folder
}
} }
} }
# powershel does not do well when returning empty arrays # powershel does not do well when returning empty arrays
return $foldersToDelete, $moreToList return , $foldersToDelete
} }
function Empty-Folder { function Empty-Folder {
@ -355,7 +387,10 @@ function Purge-Folders {
[string[]]$FolderPrefixPurgeList = @(), [string[]]$FolderPrefixPurgeList = @(),
[Parameter(Mandatory = $False, HelpMessage = "Purge folders before this date time (UTC)")] [Parameter(Mandatory = $False, HelpMessage = "Purge folders before this date time (UTC)")]
[datetime]$PurgeBeforeTimestamp [datetime]$PurgeBeforeTimestamp,
[Parameter(Mandatory = $False, HelpMessage = "Perform shallow traversal only")]
[bool]$PurgeTraversalShallow = $false
) )
if (($FolderNamePurgeList.count -eq 0) -and if (($FolderNamePurgeList.count -eq 0) -and
@ -364,9 +399,6 @@ function Purge-Folders {
Exit Exit
} }
Write-Host "`nPurging CI-produced folders..."
Write-Host "--------------------------------"
if ($FolderNamePurgeList.count -gt 0) { if ($FolderNamePurgeList.count -gt 0) {
Write-Host "Folders with names: $FolderNamePurgeList" Write-Host "Folders with names: $FolderNamePurgeList"
} }
@ -382,30 +414,27 @@ function Purge-Folders {
'WellKnownRoot' = $WellKnownRoot; 'WellKnownRoot' = $WellKnownRoot;
'FolderNamePurgeList' = $FolderNamePurgeList; 'FolderNamePurgeList' = $FolderNamePurgeList;
'FolderPrefixPurgeList' = $FolderPrefixPurgeList; 'FolderPrefixPurgeList' = $FolderPrefixPurgeList;
'PurgeBeforeTimestamp' = $PurgeBeforeTimestamp 'PurgeBeforeTimestamp' = $PurgeBeforeTimestamp;
'PurgeTraversalShallow' = $PurgeTraversalShallow
} }
$moreToList = $True $foldersToDelete = Get-FoldersToPurge @foldersToDeleteParams
# only get max of 1000 results so we may need to iterate over eligible folders $foldersToDeleteCount = $foldersToDelete.count
while ($moreToList) { $foldersToDeleteIds = @()
$foldersToDelete, $moreToList = Get-FoldersToPurge @foldersToDeleteParams $folderNames = @()
$foldersToDeleteCount = $foldersToDelete.count
$foldersToDeleteIds = @()
$folderNames = @()
if ($foldersToDeleteCount -eq 0) { if ($foldersToDeleteCount -eq 0) {
Write-Host "`nNo folders to purge matching the criteria" Write-Host "`nNo folders to purge matching the criteria"
break return
}
foreach ($folder in $foldersToDelete) {
$foldersToDeleteIds += $folder.FolderId.Id
$folderNames += $folder.DisplayName
}
Empty-Folder -FolderIdList $foldersToDeleteIds -FolderNameList $folderNames
Delete-Folder -FolderIdList $foldersToDeleteIds -FolderNameList $folderNames
} }
foreach ($folder in $foldersToDelete) {
$foldersToDeleteIds += $folder.FolderId.Id
$folderNames += $folder.DisplayName
}
Empty-Folder -FolderIdList $foldersToDeleteIds -FolderNameList $folderNames
Delete-Folder -FolderIdList $foldersToDeleteIds -FolderNameList $folderNames
} }
function Create-Contact { function Create-Contact {
@ -459,7 +488,7 @@ function Get-ItemsToPurge {
$foldersToSearchBody = "<t:DistinguishedFolderId Id='$WellKnownRoot'/>" $foldersToSearchBody = "<t:DistinguishedFolderId Id='$WellKnownRoot'/>"
if (![String]::IsNullOrEmpty($SubFolderName)) { if (![String]::IsNullOrEmpty($SubFolderName)) {
$subFolders, $moreToList = Get-FoldersToPurge -WellKnownRoot $WellKnownRoot -FolderNamePurgeList $SubFolderName -PurgeBeforeTimestamp $PurgeBeforeTimestamp $subFolders = Get-FoldersToPurge -WellKnownRoot $WellKnownRoot -FolderNamePurgeList $SubFolderName -PurgeBeforeTimestamp $PurgeBeforeTimestamp
if ($subFolders.count -gt 0 ) { if ($subFolders.count -gt 0 ) {
$foldersToSearchBody = "" $foldersToSearchBody = ""
@ -615,6 +644,8 @@ function Purge-Items {
} }
} }
### MAIN ####
Write-Host 'Authenticating with Exchange Web Services ...' Write-Host 'Authenticating with Exchange Web Services ...'
$global:Token = Get-AccessToken | ConvertTo-SecureString -AsPlainText -Force $global:Token = Get-AccessToken | ConvertTo-SecureString -AsPlainText -Force
@ -622,14 +653,17 @@ $global:Token = Get-AccessToken | ConvertTo-SecureString -AsPlainText -Force
$FolderNamePurgeList = $FolderNamePurgeList | ForEach-Object { @($_.Split(',').Trim()) } $FolderNamePurgeList = $FolderNamePurgeList | ForEach-Object { @($_.Split(',').Trim()) }
$FolderPrefixPurgeList = $FolderPrefixPurgeList | ForEach-Object { @($_.Split(',').Trim()) } $FolderPrefixPurgeList = $FolderPrefixPurgeList | ForEach-Object { @($_.Split(',').Trim()) }
Write-Host "`nPurging CI-produced folders under 'msgfolderroot' ..."
Write-Host "--------------------------------------------------------"
$purgeFolderParams = @{ $purgeFolderParams = @{
'WellKnownRoot' = "root"; 'WellKnownRoot' = "msgfolderroot";
'FolderNamePurgeList' = $FolderNamePurgeList; 'FolderNamePurgeList' = $FolderNamePurgeList;
'FolderPrefixPurgeList' = $FolderPrefixPurgeList; 'FolderPrefixPurgeList' = $FolderPrefixPurgeList;
'PurgeBeforeTimestamp' = $PurgeBeforeTimestamp 'PurgeBeforeTimestamp' = $PurgeBeforeTimestamp
} }
#purge older prefix folders #purge older prefix folders from msgfolderroot
Purge-Folders @purgeFolderParams Purge-Folders @purgeFolderParams
#purge older contacts #purge older contacts
@ -647,4 +681,20 @@ Purge-Items -ItemsFolder "calendar" -ItemsSubFolder "Birthdays" -PurgeBeforeTime
# -/Recoverable Items/SubstrateHolds # -/Recoverable Items/SubstrateHolds
Write-Host "`nProcess well-known folders that are always purged" Write-Host "`nProcess well-known folders that are always purged"
Write-Host "---------------------------------------------------" Write-Host "---------------------------------------------------"
Empty-Folder -WellKnownRoot "deleteditems", "recoverableitemsroot"
# We explicitly also clean direct folders under Deleted Items since there is some evidence
# that suggests that emptying alone may not be reliable
Write-Host "`nExplicit delete of all folders under 'DeletedItems' ..."
Write-Host "----------------------------------------------------------"
$purgeFolderParams = @{
'WellKnownRoot' = "deleteditems";
'FolderNamePurgeList' = $FolderNamePurgeList;
'FolderPrefixPurgeList' = @('*');
'PurgeBeforeTimestamp' = (Get-Date);
'PurgeTraversalShallow' = $true
}
Purge-Folders @purgeFolderParams
Empty-Folder -WellKnownRootList "deleteditems", "recoverableitemsroot"

View File

@ -34,7 +34,7 @@ require (
go.uber.org/zap v1.24.0 go.uber.org/zap v1.24.0
golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb
golang.org/x/time v0.3.0 golang.org/x/time v0.3.0
golang.org/x/tools v0.9.1 golang.org/x/tools v0.9.2
) )
require ( require (

View File

@ -672,8 +672,8 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= golang.org/x/tools v0.9.2 h1:UXbndbirwCAx6TULftIfie/ygDNCwxEie+IiNP1IcNc=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= golang.org/x/tools v0.9.2/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -3,6 +3,7 @@ package connector
import ( import (
"context" "context"
"strings" "strings"
"sync"
"github.com/alcionai/clues" "github.com/alcionai/clues"
@ -26,6 +27,13 @@ import (
"github.com/alcionai/corso/src/pkg/selectors" "github.com/alcionai/corso/src/pkg/selectors"
) )
const (
// copyBufferSize is used for chunked upload
// Microsoft recommends 5-10MB buffers
// https://docs.microsoft.com/en-us/graph/api/driveitem-createuploadsession?view=graph-rest-1.0#best-practices
copyBufferSize = 5 * 1024 * 1024
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data Collections // Data Collections
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -65,8 +73,7 @@ func (gc *GraphConnector) ProduceBackupCollections(
ctx, ctx,
gc.Discovery.Users(), gc.Discovery.Users(),
path.ServiceType(sels.Service), path.ServiceType(sels.Service),
sels.DiscreteOwner, sels.DiscreteOwner)
)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -90,10 +97,11 @@ func (gc *GraphConnector) ProduceBackupCollections(
case selectors.ServiceExchange: case selectors.ServiceExchange:
colls, ssmb, err = exchange.DataCollections( colls, ssmb, err = exchange.DataCollections(
ctx, ctx,
gc.Discovery,
sels, sels,
gc.credentials.AzureTenantID,
owner, owner,
metadata, metadata,
gc.credentials,
gc.UpdateStatus, gc.UpdateStatus,
ctrlOpts, ctrlOpts,
errs) errs)
@ -256,13 +264,46 @@ func (gc *GraphConnector) ConsumeRestoreCollections(
return nil, clues.Wrap(err, "malformed azure credentials") return nil, clues.Wrap(err, "malformed azure credentials")
} }
// Buffer pool for uploads
pool := sync.Pool{
New: func() interface{} {
b := make([]byte, copyBufferSize)
return &b
},
}
switch sels.Service { switch sels.Service {
case selectors.ServiceExchange: case selectors.ServiceExchange:
status, err = exchange.RestoreCollections(ctx, creds, gc.Discovery, gc.Service, dest, dcs, deets, errs) status, err = exchange.RestoreCollections(ctx,
creds,
gc.Discovery,
gc.Service,
dest,
dcs,
deets,
errs)
case selectors.ServiceOneDrive: case selectors.ServiceOneDrive:
status, err = onedrive.RestoreCollections(ctx, creds, backupVersion, gc.Service, dest, opts, dcs, deets, errs) status, err = onedrive.RestoreCollections(ctx,
creds,
backupVersion,
gc.Service,
dest,
opts,
dcs,
deets,
&pool,
errs)
case selectors.ServiceSharePoint: case selectors.ServiceSharePoint:
status, err = sharepoint.RestoreCollections(ctx, backupVersion, creds, gc.Service, dest, opts, dcs, deets, errs) status, err = sharepoint.RestoreCollections(ctx,
backupVersion,
creds,
gc.Service,
dest,
opts,
dcs,
deets,
&pool,
errs)
default: default:
err = clues.Wrap(clues.New(sels.Service.String()), "service not supported") err = clues.Wrap(clues.New(sels.Service.String()), "service not supported")
} }

View File

@ -21,6 +21,7 @@ import (
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/selectors" "github.com/alcionai/corso/src/pkg/selectors"
selTD "github.com/alcionai/corso/src/pkg/selectors/testdata" selTD "github.com/alcionai/corso/src/pkg/selectors/testdata"
"github.com/alcionai/corso/src/pkg/services/m365/api"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -29,8 +30,10 @@ import (
type DataCollectionIntgSuite struct { type DataCollectionIntgSuite struct {
tester.Suite tester.Suite
user string user string
site string site string
tenantID string
ac api.Client
} }
func TestDataCollectionIntgSuite(t *testing.T) { func TestDataCollectionIntgSuite(t *testing.T) {
@ -42,10 +45,19 @@ func TestDataCollectionIntgSuite(t *testing.T) {
} }
func (suite *DataCollectionIntgSuite) SetupSuite() { func (suite *DataCollectionIntgSuite) SetupSuite() {
suite.user = tester.M365UserID(suite.T()) t := suite.T()
suite.site = tester.M365SiteID(suite.T())
tester.LogTimeOfTest(suite.T()) suite.user = tester.M365UserID(t)
suite.site = tester.M365SiteID(t)
acct := tester.NewM365Account(t)
creds, err := acct.M365Config()
require.NoError(t, err, clues.ToCore(err))
suite.tenantID = creds.AzureTenantID
suite.ac, err = api.NewClient(creds)
require.NoError(t, err, clues.ToCore(err))
} }
// TestExchangeDataCollection verifies interface between operation and // TestExchangeDataCollection verifies interface between operation and
@ -111,16 +123,18 @@ func (suite *DataCollectionIntgSuite) TestExchangeDataCollection() {
defer flush() defer flush()
sel := test.getSelector(t) sel := test.getSelector(t)
uidn := inMock.NewProvider(sel.ID(), sel.Name())
ctrlOpts := control.Defaults() ctrlOpts := control.Defaults()
ctrlOpts.ToggleFeatures.DisableDelta = !canMakeDeltaQueries ctrlOpts.ToggleFeatures.DisableDelta = !canMakeDeltaQueries
collections, excludes, err := exchange.DataCollections( collections, excludes, err := exchange.DataCollections(
ctx, ctx,
suite.ac,
sel, sel,
sel, suite.tenantID,
uidn,
nil, nil,
connector.credentials,
connector.UpdateStatus, connector.UpdateStatus,
ctrlOpts, ctrlOpts,
fault.New(true)) fault.New(true))
@ -133,7 +147,7 @@ func (suite *DataCollectionIntgSuite) TestExchangeDataCollection() {
// Categories with delta endpoints will produce a collection for metadata // Categories with delta endpoints will produce a collection for metadata
// as well as the actual data pulled, and the "temp" root collection. // as well as the actual data pulled, and the "temp" root collection.
assert.GreaterOrEqual(t, len(collections), 1, "expected 1 <= num collections <= 2") assert.LessOrEqual(t, 1, len(collections), "expected 1 <= num collections <= 3")
assert.GreaterOrEqual(t, 3, len(collections), "expected 1 <= num collections <= 3") assert.GreaterOrEqual(t, 3, len(collections), "expected 1 <= num collections <= 3")
for _, col := range collections { for _, col := range collections {

View File

@ -71,9 +71,9 @@ func (cfc *contactFolderCache) Populate(
ctx context.Context, ctx context.Context,
errs *fault.Bus, errs *fault.Bus,
baseID string, baseID string,
baseContainerPather ...string, baseContainerPath ...string,
) error { ) error {
if err := cfc.init(ctx, baseID, baseContainerPather); err != nil { if err := cfc.init(ctx, baseID, baseContainerPath); err != nil {
return clues.Wrap(err, "initializing") return clues.Wrap(err, "initializing")
} }
@ -95,7 +95,7 @@ func (cfc *contactFolderCache) init(
baseContainerPath []string, baseContainerPath []string,
) error { ) error {
if len(baseNode) == 0 { if len(baseNode) == 0 {
return clues.New("m365 folderID required for base folder").WithClues(ctx) return clues.New("m365 folderID required for base contact folder").WithClues(ctx)
} }
if cfc.containerResolver == nil { if cfc.containerResolver == nil {

View File

@ -0,0 +1,40 @@
package exchange
import (
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
var _ backupHandler = &contactBackupHandler{}
type contactBackupHandler struct {
ac api.Contacts
}
func newContactBackupHandler(
ac api.Client,
) contactBackupHandler {
acc := ac.Contacts()
return contactBackupHandler{
ac: acc,
}
}
func (h contactBackupHandler) itemEnumerator() addedAndRemovedItemGetter {
return h.ac
}
func (h contactBackupHandler) itemHandler() itemGetterSerializer {
return h.ac
}
func (h contactBackupHandler) NewContainerCache(
userID string,
) (string, graph.ContainerResolver) {
return DefaultContactFolder, &contactFolderCache{
userID: userID,
enumer: h.ac,
getter: h.ac,
}
}

View File

@ -0,0 +1,86 @@
package exchange
import (
"context"
"github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
var _ itemRestorer = &contactRestoreHandler{}
type contactRestoreHandler struct {
ac api.Contacts
ip itemPoster[models.Contactable]
}
func newContactRestoreHandler(
ac api.Client,
) contactRestoreHandler {
return contactRestoreHandler{
ac: ac.Contacts(),
ip: ac.Contacts(),
}
}
func (h contactRestoreHandler) newContainerCache(userID string) graph.ContainerResolver {
return &contactFolderCache{
userID: userID,
enumer: h.ac,
getter: h.ac,
}
}
func (h contactRestoreHandler) formatRestoreDestination(
destinationContainerName string,
_ path.Path, // contact folders cannot be nested
) *path.Builder {
return path.Builder{}.Append(destinationContainerName)
}
func (h contactRestoreHandler) CreateContainer(
ctx context.Context,
userID, containerName, _ string, // parent container not used
) (graph.Container, error) {
return h.ac.CreateContainer(ctx, userID, containerName, "")
}
func (h contactRestoreHandler) containerSearcher() containerByNamer {
return nil
}
// always returns the provided value
func (h contactRestoreHandler) orRootContainer(c string) string {
return c
}
func (h contactRestoreHandler) restore(
ctx context.Context,
body []byte,
userID, destinationID string,
errs *fault.Bus,
) (*details.ExchangeInfo, error) {
contact, err := api.BytesToContactable(body)
if err != nil {
return nil, graph.Wrap(ctx, err, "creating contact from bytes")
}
ctx = clues.Add(ctx, "item_id", ptr.Val(contact.GetId()))
item, err := h.ip.PostItem(ctx, userID, destinationID, contact)
if err != nil {
return nil, graph.Wrap(ctx, err, "restoring mail message")
}
info := api.ContactInfo(item)
info.Size = int64(len(body))
return info, nil
}

View File

@ -0,0 +1,57 @@
package exchange
import (
"testing"
"github.com/alcionai/clues"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
type ContactsRestoreIntgSuite struct {
tester.Suite
creds account.M365Config
ac api.Client
userID string
}
func TestContactsRestoreIntgSuite(t *testing.T) {
suite.Run(t, &ContactsRestoreIntgSuite{
Suite: tester.NewIntegrationSuite(
t,
[][]string{tester.M365AcctCredEnvs}),
})
}
func (suite *ContactsRestoreIntgSuite) SetupSuite() {
t := suite.T()
a := tester.NewM365Account(t)
creds, err := a.M365Config()
require.NoError(t, err, clues.ToCore(err))
suite.creds = creds
suite.ac, err = api.NewClient(creds)
require.NoError(t, err, clues.ToCore(err))
suite.userID = tester.M365UserID(t)
}
// Testing to ensure that cache system works for in multiple different environments
func (suite *ContactsRestoreIntgSuite) TestCreateContainerDestination() {
runCreateDestinationTest(
suite.T(),
newMailRestoreHandler(suite.ac),
path.EmailCategory,
suite.creds.AzureTenantID,
suite.userID,
tester.DefaultTestRestoreDestination("").ContainerName,
[]string{"Hufflepuff"},
[]string{"Ravenclaw"})
}

View File

@ -15,7 +15,6 @@ import (
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/connector/graph" "github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
) )
@ -676,181 +675,69 @@ func (suite *ConfiguredFolderCacheUnitSuite) TestAddToCache() {
// integration suite // integration suite
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type FolderCacheIntegrationSuite struct { func runCreateDestinationTest(
tester.Suite t *testing.T,
credentials account.M365Config handler restoreHandler,
gs graph.Servicer category path.CategoryType,
} tenantID, userID, destinationName string,
containerNames1 []string,
func TestFolderCacheIntegrationSuite(t *testing.T) { containerNames2 []string,
suite.Run(t, &FolderCacheIntegrationSuite{ ) {
Suite: tester.NewIntegrationSuite( ctx, flush := tester.NewContext(t)
t, defer flush()
[][]string{tester.M365AcctCredEnvs},
),
})
}
func (suite *FolderCacheIntegrationSuite) SetupSuite() {
t := suite.T()
a := tester.NewM365Account(t)
m365, err := a.M365Config()
require.NoError(t, err, clues.ToCore(err))
suite.credentials = m365
adpt, err := graph.CreateAdapter(
m365.AzureTenantID,
m365.AzureClientID,
m365.AzureClientSecret)
require.NoError(t, err, clues.ToCore(err))
suite.gs = graph.NewService(adpt)
}
// Testing to ensure that cache system works for in multiple different environments
func (suite *FolderCacheIntegrationSuite) TestCreateContainerDestination() {
a := tester.NewM365Account(suite.T())
m365, err := a.M365Config()
require.NoError(suite.T(), err, clues.ToCore(err))
var ( var (
user = tester.M365UserID(suite.T()) svc = path.ExchangeService
directoryCaches = make(map[path.CategoryType]graph.ContainerResolver) gcr = handler.newContainerCache(userID)
folderName = tester.DefaultTestRestoreDestination("").ContainerName
tests = []struct {
name string
pathFunc1 func(t *testing.T) path.Path
pathFunc2 func(t *testing.T) path.Path
category path.CategoryType
folderPrefix string
}{
{
name: "Mail Cache Test",
category: path.EmailCategory,
pathFunc1: func(t *testing.T) path.Path {
pth, err := path.Build(
suite.credentials.AzureTenantID,
user,
path.ExchangeService,
path.EmailCategory,
false,
"Griffindor", "Croix")
require.NoError(t, err, clues.ToCore(err))
return pth
},
pathFunc2: func(t *testing.T) path.Path {
pth, err := path.Build(
suite.credentials.AzureTenantID,
user,
path.ExchangeService,
path.EmailCategory,
false,
"Griffindor", "Felicius")
require.NoError(t, err, clues.ToCore(err))
return pth
},
},
{
name: "Contact Cache Test",
category: path.ContactsCategory,
pathFunc1: func(t *testing.T) path.Path {
pth, err := path.Build(
suite.credentials.AzureTenantID,
user,
path.ExchangeService,
path.ContactsCategory,
false,
"HufflePuff")
require.NoError(t, err, clues.ToCore(err))
return pth
},
pathFunc2: func(t *testing.T) path.Path {
pth, err := path.Build(
suite.credentials.AzureTenantID,
user,
path.ExchangeService,
path.ContactsCategory,
false,
"Ravenclaw")
require.NoError(t, err, clues.ToCore(err))
return pth
},
},
{
name: "Event Cache Test",
category: path.EventsCategory,
pathFunc1: func(t *testing.T) path.Path {
pth, err := path.Build(
suite.credentials.AzureTenantID,
user,
path.ExchangeService,
path.EventsCategory,
false,
"Durmstrang")
require.NoError(t, err, clues.ToCore(err))
return pth
},
pathFunc2: func(t *testing.T) path.Path {
pth, err := path.Build(
suite.credentials.AzureTenantID,
user,
path.ExchangeService,
path.EventsCategory,
false,
"Beauxbatons")
require.NoError(t, err, clues.ToCore(err))
return pth
},
},
}
) )
for _, test := range tests { path1, err := path.Build(
suite.Run(test.name, func() { tenantID,
t := suite.T() userID,
svc,
category,
false,
containerNames1...)
require.NoError(t, err, clues.ToCore(err))
ctx, flush := tester.NewContext(t) containerID, gcr, err := createDestination(
defer flush() ctx,
handler,
handler.formatRestoreDestination(destinationName, path1),
userID,
gcr,
true,
fault.New(true))
require.NoError(t, err, clues.ToCore(err))
folderID, err := CreateContainerDestination( _, _, err = gcr.IDToPath(ctx, containerID)
ctx, assert.NoError(t, err, clues.ToCore(err))
m365,
test.pathFunc1(t),
folderName,
directoryCaches,
fault.New(true))
require.NoError(t, err, clues.ToCore(err))
resolver := directoryCaches[test.category] path2, err := path.Build(
tenantID,
userID,
svc,
category,
false,
containerNames2...)
require.NoError(t, err, clues.ToCore(err))
_, _, err = resolver.IDToPath(ctx, folderID) containerID, gcr, err = createDestination(
assert.NoError(t, err, clues.ToCore(err)) ctx,
handler,
handler.formatRestoreDestination(destinationName, path2),
userID,
gcr,
false,
fault.New(true))
require.NoError(t, err, clues.ToCore(err))
secondID, err := CreateContainerDestination( p, l, err := gcr.IDToPath(ctx, containerID)
ctx, require.NoError(t, err, clues.ToCore(err))
m365,
test.pathFunc2(t),
folderName,
directoryCaches,
fault.New(true))
require.NoError(t, err, clues.ToCore(err))
p, l, err := resolver.IDToPath(ctx, secondID) _, ok := gcr.LocationInCache(l.String())
require.NoError(t, err, clues.ToCore(err)) require.True(t, ok, "looking for location in cache: %s", l)
_, ok := resolver.LocationInCache(l.String()) _, ok = gcr.PathInCache(p.String())
require.True(t, ok, "looking for location in cache: %s", l) require.True(t, ok, "looking for path in cache: %s", p)
_, ok = resolver.PathInCache(p.String())
require.True(t, ok, "looking for path in cache: %s", p)
})
}
} }

View File

@ -12,7 +12,6 @@ import (
"github.com/alcionai/corso/src/internal/connector/support" "github.com/alcionai/corso/src/internal/connector/support"
"github.com/alcionai/corso/src/internal/data" "github.com/alcionai/corso/src/internal/data"
"github.com/alcionai/corso/src/internal/observe" "github.com/alcionai/corso/src/internal/observe"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
@ -159,15 +158,13 @@ func parseMetadataCollections(
// DataCollections returns a DataCollection which the caller can // DataCollections returns a DataCollection which the caller can
// use to read mailbox data out for the specified user // use to read mailbox data out for the specified user
// Assumption: User exists
//
// Add iota to this call -> mail, contacts, calendar, etc.
func DataCollections( func DataCollections(
ctx context.Context, ctx context.Context,
ac api.Client,
selector selectors.Selector, selector selectors.Selector,
tenantID string,
user idname.Provider, user idname.Provider,
metadata []data.RestoreCollection, metadata []data.RestoreCollection,
acct account.M365Config,
su support.StatusUpdater, su support.StatusUpdater,
ctrlOpts control.Options, ctrlOpts control.Options,
errs *fault.Bus, errs *fault.Bus,
@ -181,6 +178,7 @@ func DataCollections(
collections = []data.BackupCollection{} collections = []data.BackupCollection{}
el = errs.Local() el = errs.Local()
categories = map[path.CategoryType]struct{}{} categories = map[path.CategoryType]struct{}{}
handlers = BackupHandlers(ac)
) )
// Turn on concurrency limiter middleware for exchange backups // Turn on concurrency limiter middleware for exchange backups
@ -201,7 +199,8 @@ func DataCollections(
dcs, err := createCollections( dcs, err := createCollections(
ctx, ctx,
acct, handlers,
tenantID,
user, user,
scope, scope,
cdps[scope.Category().PathType()], cdps[scope.Category().PathType()],
@ -222,7 +221,7 @@ func DataCollections(
baseCols, err := graph.BaseCollections( baseCols, err := graph.BaseCollections(
ctx, ctx,
collections, collections,
acct.AzureTenantID, tenantID,
user.ID(), user.ID(),
path.ExchangeService, path.ExchangeService,
categories, categories,
@ -238,25 +237,13 @@ func DataCollections(
return collections, nil, el.Failure() return collections, nil, el.Failure()
} }
func getterByType(ac api.Client, category path.CategoryType) (addedAndRemovedItemIDsGetter, error) {
switch category {
case path.EmailCategory:
return ac.Mail(), nil
case path.EventsCategory:
return ac.Events(), nil
case path.ContactsCategory:
return ac.Contacts(), nil
default:
return nil, clues.New("no api client registered for category")
}
}
// createCollections - utility function that retrieves M365 // createCollections - utility function that retrieves M365
// IDs through Microsoft Graph API. The selectors.ExchangeScope // IDs through Microsoft Graph API. The selectors.ExchangeScope
// determines the type of collections that are retrieved. // determines the type of collections that are retrieved.
func createCollections( func createCollections(
ctx context.Context, ctx context.Context,
creds account.M365Config, handlers map[path.CategoryType]backupHandler,
tenantID string,
user idname.Provider, user idname.Provider,
scope selectors.ExchangeScope, scope selectors.ExchangeScope,
dps DeltaPaths, dps DeltaPaths,
@ -264,46 +251,40 @@ func createCollections(
su support.StatusUpdater, su support.StatusUpdater,
errs *fault.Bus, errs *fault.Bus,
) ([]data.BackupCollection, error) { ) ([]data.BackupCollection, error) {
ctx = clues.Add(ctx, "category", scope.Category().PathType())
var ( var (
allCollections = make([]data.BackupCollection, 0) allCollections = make([]data.BackupCollection, 0)
category = scope.Category().PathType() category = scope.Category().PathType()
qp = graph.QueryParams{
Category: category,
ResourceOwner: user,
TenantID: tenantID,
}
) )
ac, err := api.NewClient(creds) handler, ok := handlers[category]
if err != nil { if !ok {
return nil, clues.Wrap(err, "getting api client").WithClues(ctx) return nil, clues.New("unsupported backup category type").WithClues(ctx)
} }
ctx = clues.Add(ctx, "category", category) foldersComplete := observe.MessageWithCompletion(
getter, err := getterByType(ac, category)
if err != nil {
return nil, clues.Stack(err).WithClues(ctx)
}
qp := graph.QueryParams{
Category: category,
ResourceOwner: user,
Credentials: creds,
}
foldersComplete, closer := observe.MessageWithCompletion(
ctx, ctx,
observe.Bulletf("%s", qp.Category)) observe.Bulletf("%s", qp.Category))
defer closer()
defer close(foldersComplete) defer close(foldersComplete)
resolver, err := PopulateExchangeContainerResolver(ctx, qp, errs) rootFolder, cc := handler.NewContainerCache(user.ID())
if err != nil {
if err := cc.Populate(ctx, errs, rootFolder); err != nil {
return nil, clues.Wrap(err, "populating container cache") return nil, clues.Wrap(err, "populating container cache")
} }
collections, err := filterContainersAndFillCollections( collections, err := filterContainersAndFillCollections(
ctx, ctx,
qp, qp,
getter, handler,
su, su,
resolver, cc,
scope, scope,
dps, dps,
ctrlOpts, ctrlOpts,

View File

@ -222,8 +222,10 @@ func newStatusUpdater(t *testing.T, wg *sync.WaitGroup) func(status *support.Con
type DataCollectionsIntegrationSuite struct { type DataCollectionsIntegrationSuite struct {
tester.Suite tester.Suite
user string user string
site string site string
tenantID string
ac api.Client
} }
func TestDataCollectionsIntegrationSuite(t *testing.T) { func TestDataCollectionsIntegrationSuite(t *testing.T) {
@ -239,18 +241,25 @@ func (suite *DataCollectionsIntegrationSuite) SetupSuite() {
suite.user = tester.M365UserID(suite.T()) suite.user = tester.M365UserID(suite.T())
suite.site = tester.M365SiteID(suite.T()) suite.site = tester.M365SiteID(suite.T())
acct := tester.NewM365Account(suite.T())
creds, err := acct.M365Config()
require.NoError(suite.T(), err, clues.ToCore(err))
suite.ac, err = api.NewClient(creds)
require.NoError(suite.T(), err, clues.ToCore(err))
suite.tenantID = creds.AzureTenantID
tester.LogTimeOfTest(suite.T()) tester.LogTimeOfTest(suite.T())
} }
func (suite *DataCollectionsIntegrationSuite) TestMailFetch() { func (suite *DataCollectionsIntegrationSuite) TestMailFetch() {
var ( var (
userID = tester.M365UserID(suite.T()) userID = tester.M365UserID(suite.T())
users = []string{userID} users = []string{userID}
acct, err = tester.NewM365Account(suite.T()).M365Config() handlers = BackupHandlers(suite.ac)
) )
require.NoError(suite.T(), err, clues.ToCore(err))
tests := []struct { tests := []struct {
name string name string
scope selectors.ExchangeScope scope selectors.ExchangeScope
@ -293,7 +302,8 @@ func (suite *DataCollectionsIntegrationSuite) TestMailFetch() {
collections, err := createCollections( collections, err := createCollections(
ctx, ctx,
acct, handlers,
suite.tenantID,
inMock.NewProvider(userID, userID), inMock.NewProvider(userID, userID),
test.scope, test.scope,
DeltaPaths{}, DeltaPaths{},
@ -329,13 +339,11 @@ func (suite *DataCollectionsIntegrationSuite) TestMailFetch() {
func (suite *DataCollectionsIntegrationSuite) TestDelta() { func (suite *DataCollectionsIntegrationSuite) TestDelta() {
var ( var (
userID = tester.M365UserID(suite.T()) userID = tester.M365UserID(suite.T())
users = []string{userID} users = []string{userID}
acct, err = tester.NewM365Account(suite.T()).M365Config() handlers = BackupHandlers(suite.ac)
) )
require.NoError(suite.T(), err, clues.ToCore(err))
tests := []struct { tests := []struct {
name string name string
scope selectors.ExchangeScope scope selectors.ExchangeScope
@ -372,7 +380,8 @@ func (suite *DataCollectionsIntegrationSuite) TestDelta() {
// get collections without providing any delta history (ie: full backup) // get collections without providing any delta history (ie: full backup)
collections, err := createCollections( collections, err := createCollections(
ctx, ctx,
acct, handlers,
suite.tenantID,
inMock.NewProvider(userID, userID), inMock.NewProvider(userID, userID),
test.scope, test.scope,
DeltaPaths{}, DeltaPaths{},
@ -403,7 +412,8 @@ func (suite *DataCollectionsIntegrationSuite) TestDelta() {
// which should only contain the difference. // which should only contain the difference.
collections, err = createCollections( collections, err = createCollections(
ctx, ctx,
acct, handlers,
suite.tenantID,
inMock.NewProvider(userID, userID), inMock.NewProvider(userID, userID),
test.scope, test.scope,
dps, dps,
@ -438,19 +448,18 @@ func (suite *DataCollectionsIntegrationSuite) TestMailSerializationRegression()
defer flush() defer flush()
var ( var (
wg sync.WaitGroup wg sync.WaitGroup
users = []string{suite.user} users = []string{suite.user}
handlers = BackupHandlers(suite.ac)
) )
acct, err := tester.NewM365Account(t).M365Config()
require.NoError(t, err, clues.ToCore(err))
sel := selectors.NewExchangeBackup(users) sel := selectors.NewExchangeBackup(users)
sel.Include(sel.MailFolders([]string{DefaultMailFolder}, selectors.PrefixMatch())) sel.Include(sel.MailFolders([]string{DefaultMailFolder}, selectors.PrefixMatch()))
collections, err := createCollections( collections, err := createCollections(
ctx, ctx,
acct, handlers,
suite.tenantID,
inMock.NewProvider(suite.user, suite.user), inMock.NewProvider(suite.user, suite.user),
sel.Scopes()[0], sel.Scopes()[0],
DeltaPaths{}, DeltaPaths{},
@ -497,10 +506,10 @@ func (suite *DataCollectionsIntegrationSuite) TestMailSerializationRegression()
// and to store contact within Collection. Downloaded contacts are run through // and to store contact within Collection. Downloaded contacts are run through
// a regression test to ensure that downloaded items can be uploaded. // a regression test to ensure that downloaded items can be uploaded.
func (suite *DataCollectionsIntegrationSuite) TestContactSerializationRegression() { func (suite *DataCollectionsIntegrationSuite) TestContactSerializationRegression() {
acct, err := tester.NewM365Account(suite.T()).M365Config() var (
require.NoError(suite.T(), err, clues.ToCore(err)) users = []string{suite.user}
handlers = BackupHandlers(suite.ac)
users := []string{suite.user} )
tests := []struct { tests := []struct {
name string name string
@ -525,7 +534,8 @@ func (suite *DataCollectionsIntegrationSuite) TestContactSerializationRegression
edcs, err := createCollections( edcs, err := createCollections(
ctx, ctx,
acct, handlers,
suite.tenantID,
inMock.NewProvider(suite.user, suite.user), inMock.NewProvider(suite.user, suite.user),
test.scope, test.scope,
DeltaPaths{}, DeltaPaths{},
@ -589,17 +599,11 @@ func (suite *DataCollectionsIntegrationSuite) TestEventsSerializationRegression(
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
defer flush() defer flush()
acct, err := tester.NewM365Account(t).M365Config()
require.NoError(t, err, clues.ToCore(err))
users := []string{suite.user}
ac, err := api.NewClient(acct)
require.NoError(t, err, "creating client", clues.ToCore(err))
var ( var (
calID string users = []string{suite.user}
bdayID string handlers = BackupHandlers(suite.ac)
calID string
bdayID string
) )
fn := func(gcf graph.CachedContainer) error { fn := func(gcf graph.CachedContainer) error {
@ -614,7 +618,7 @@ func (suite *DataCollectionsIntegrationSuite) TestEventsSerializationRegression(
return nil return nil
} }
err = ac.Events().EnumerateContainers(ctx, suite.user, DefaultCalendar, fn, fault.New(true)) err := suite.ac.Events().EnumerateContainers(ctx, suite.user, DefaultCalendar, fn, fault.New(true))
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
tests := []struct { tests := []struct {
@ -650,7 +654,8 @@ func (suite *DataCollectionsIntegrationSuite) TestEventsSerializationRegression(
collections, err := createCollections( collections, err := createCollections(
ctx, ctx,
acct, handlers,
suite.tenantID,
inMock.NewProvider(suite.user, suite.user), inMock.NewProvider(suite.user, suite.user),
test.scope, test.scope,
DeltaPaths{}, DeltaPaths{},

View File

@ -0,0 +1,40 @@
package exchange
import (
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
var _ backupHandler = &eventBackupHandler{}
type eventBackupHandler struct {
ac api.Events
}
func newEventBackupHandler(
ac api.Client,
) eventBackupHandler {
ace := ac.Events()
return eventBackupHandler{
ac: ace,
}
}
func (h eventBackupHandler) itemEnumerator() addedAndRemovedItemGetter {
return h.ac
}
func (h eventBackupHandler) itemHandler() itemGetterSerializer {
return h.ac
}
func (h eventBackupHandler) NewContainerCache(
userID string,
) (string, graph.ContainerResolver) {
return DefaultCalendar, &eventCalendarCache{
userID: userID,
enumer: h.ac,
getter: h.ac,
}
}

View File

@ -0,0 +1,109 @@
package exchange
import (
"context"
"github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
var _ itemRestorer = &eventRestoreHandler{}
type eventRestoreHandler struct {
ac api.Events
ip itemPoster[models.Eventable]
}
func newEventRestoreHandler(
ac api.Client,
) eventRestoreHandler {
ace := ac.Events()
return eventRestoreHandler{
ac: ace,
ip: ace,
}
}
func (h eventRestoreHandler) newContainerCache(userID string) graph.ContainerResolver {
return &eventCalendarCache{
userID: userID,
enumer: h.ac,
getter: h.ac,
}
}
func (h eventRestoreHandler) formatRestoreDestination(
destinationContainerName string,
_ path.Path, // ignored because calendars cannot be nested
) *path.Builder {
return path.Builder{}.Append(destinationContainerName)
}
func (h eventRestoreHandler) CreateContainer(
ctx context.Context,
userID, containerName, _ string, // parent container not used
) (graph.Container, error) {
return h.ac.CreateContainer(ctx, userID, containerName, "")
}
func (h eventRestoreHandler) containerSearcher() containerByNamer {
return h.ac
}
// always returns the provided value
func (h eventRestoreHandler) orRootContainer(c string) string {
return c
}
func (h eventRestoreHandler) restore(
ctx context.Context,
body []byte,
userID, destinationID string,
errs *fault.Bus,
) (*details.ExchangeInfo, error) {
event, err := api.BytesToEventable(body)
if err != nil {
return nil, clues.Wrap(err, "creating event from bytes").WithClues(ctx)
}
ctx = clues.Add(ctx, "item_id", ptr.Val(event.GetId()))
event = toEventSimplified(event)
var attachments []models.Attachmentable
if ptr.Val(event.GetHasAttachments()) {
attachments = event.GetAttachments()
event.SetAttachments([]models.Attachmentable{})
}
item, err := h.ip.PostItem(ctx, userID, destinationID, event)
if err != nil {
return nil, graph.Wrap(ctx, err, "restoring mail message")
}
err = uploadAttachments(
ctx,
h.ac,
attachments,
userID,
destinationID,
ptr.Val(item.GetId()),
errs)
if err != nil {
return nil, clues.Stack(err)
}
info := api.EventInfo(event)
info.Size = int64(len(body))
return info, nil
}

View File

@ -0,0 +1,57 @@
package exchange
import (
"testing"
"github.com/alcionai/clues"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
type EventsRestoreIntgSuite struct {
tester.Suite
creds account.M365Config
ac api.Client
userID string
}
func TestEventsRestoreIntgSuite(t *testing.T) {
suite.Run(t, &EventsRestoreIntgSuite{
Suite: tester.NewIntegrationSuite(
t,
[][]string{tester.M365AcctCredEnvs}),
})
}
func (suite *EventsRestoreIntgSuite) SetupSuite() {
t := suite.T()
a := tester.NewM365Account(t)
creds, err := a.M365Config()
require.NoError(t, err, clues.ToCore(err))
suite.creds = creds
suite.ac, err = api.NewClient(creds)
require.NoError(t, err, clues.ToCore(err))
suite.userID = tester.M365UserID(t)
}
// Testing to ensure that cache system works for in multiple different environments
func (suite *EventsRestoreIntgSuite) TestCreateContainerDestination() {
runCreateDestinationTest(
suite.T(),
newMailRestoreHandler(suite.ac),
path.EmailCategory,
suite.creds.AzureTenantID,
suite.userID,
tester.DefaultTestRestoreDestination("").ContainerName,
[]string{"Durmstrang"},
[]string{"Beauxbatons"})
}

View File

@ -12,7 +12,6 @@ import (
"time" "time"
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/microsoft/kiota-abstractions-go/serialization"
"github.com/alcionai/corso/src/internal/connector/graph" "github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/internal/connector/support" "github.com/alcionai/corso/src/internal/connector/support"
@ -37,20 +36,6 @@ const (
numberOfRetries = 4 numberOfRetries = 4
) )
type itemer interface {
GetItem(
ctx context.Context,
user, itemID string,
immutableIDs bool,
errs *fault.Bus,
) (serialization.Parsable, *details.ExchangeInfo, error)
Serialize(
ctx context.Context,
item serialization.Parsable,
user, itemID string,
) ([]byte, error)
}
// Collection implements the interface from data.Collection // Collection implements the interface from data.Collection
// Structure holds data for an Exchange application for a single user // Structure holds data for an Exchange application for a single user
type Collection struct { type Collection struct {
@ -63,7 +48,7 @@ type Collection struct {
// removed is a list of item IDs that were deleted from, or moved out, of a container // removed is a list of item IDs that were deleted from, or moved out, of a container
removed map[string]struct{} removed map[string]struct{}
items itemer items itemGetterSerializer
category path.CategoryType category path.CategoryType
statusUpdater support.StatusUpdater statusUpdater support.StatusUpdater
@ -98,7 +83,7 @@ func NewCollection(
curr, prev path.Path, curr, prev path.Path,
location *path.Builder, location *path.Builder,
category path.CategoryType, category path.CategoryType,
items itemer, items itemGetterSerializer,
statusUpdater support.StatusUpdater, statusUpdater support.StatusUpdater,
ctrlOpts control.Options, ctrlOpts control.Options,
doNotMergeItems bool, doNotMergeItems bool,
@ -178,14 +163,11 @@ func (col *Collection) streamItems(ctx context.Context, errs *fault.Bus) {
}() }()
if len(col.added)+len(col.removed) > 0 { if len(col.added)+len(col.removed) > 0 {
var closer func() colProgress = observe.CollectionProgress(
colProgress, closer = observe.CollectionProgress(
ctx, ctx,
col.fullPath.Category().String(), col.fullPath.Category().String(),
col.LocationPath().Elements()) col.LocationPath().Elements())
go closer()
defer func() { defer func() {
close(colProgress) close(colProgress)
}() }()

View File

@ -0,0 +1,131 @@
package exchange
import (
"context"
"github.com/microsoft/kiota-abstractions-go/serialization"
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
// ---------------------------------------------------------------------------
// backup
// ---------------------------------------------------------------------------
type backupHandler interface {
itemEnumerator() addedAndRemovedItemGetter
itemHandler() itemGetterSerializer
NewContainerCache(userID string) (string, graph.ContainerResolver)
}
type addedAndRemovedItemGetter interface {
GetAddedAndRemovedItemIDs(
ctx context.Context,
user, containerID, oldDeltaToken string,
immutableIDs bool,
canMakeDeltaQueries bool,
) ([]string, []string, api.DeltaUpdate, error)
}
type itemGetterSerializer interface {
GetItem(
ctx context.Context,
user, itemID string,
immutableIDs bool,
errs *fault.Bus,
) (serialization.Parsable, *details.ExchangeInfo, error)
Serialize(
ctx context.Context,
item serialization.Parsable,
user, itemID string,
) ([]byte, error)
}
func BackupHandlers(ac api.Client) map[path.CategoryType]backupHandler {
return map[path.CategoryType]backupHandler{
path.ContactsCategory: newContactBackupHandler(ac),
path.EmailCategory: newMailBackupHandler(ac),
path.EventsCategory: newEventBackupHandler(ac),
}
}
// ---------------------------------------------------------------------------
// restore
// ---------------------------------------------------------------------------
type restoreHandler interface {
itemRestorer
containerAPI
newContainerCache(userID string) graph.ContainerResolver
formatRestoreDestination(
destinationContainerName string,
collectionFullPath path.Path,
) *path.Builder
}
// runs the item restoration (ie: item creation) process
// for a single item, whose summary contents are held in
// the body property.
type itemRestorer interface {
restore(
ctx context.Context,
body []byte,
userID, destinationID string,
errs *fault.Bus,
) (*details.ExchangeInfo, error)
}
// runs the actual graph API post request.
type itemPoster[T any] interface {
PostItem(
ctx context.Context,
userID, dirID string,
body T,
) (T, error)
}
// produces structs that interface with the graph/cache_container
// CachedContainer interface.
type containerAPI interface {
// POSTs the creation of a new container
CreateContainer(
ctx context.Context,
userID, containerName, parentContainerID string,
) (graph.Container, error)
// GETs a container by name.
// if containerByNamer is nil, this functionality is not supported
// and should be skipped by the caller.
// normally, we'd alias the func directly. The indirection here
// is because not all types comply with GetContainerByName.
containerSearcher() containerByNamer
// returns either the provided value (assumed to be the root
// folder for that cache tree), or the default root container
// (if the category uses a root folder that exists above the
// restore location path).
orRootContainer(string) string
}
type containerByNamer interface {
// searches for a container by name.
GetContainerByName(
ctx context.Context,
userID, containerName string,
) (graph.Container, error)
}
// primary interface controller for all per-cateogry restoration behavior.
func restoreHandlers(
ac api.Client,
) map[path.CategoryType]restoreHandler {
return map[path.CategoryType]restoreHandler{
path.ContactsCategory: newContactRestoreHandler(ac),
path.EmailCategory: newMailRestoreHandler(ac),
path.EventsCategory: newEventRestoreHandler(ac),
}
}

View File

@ -0,0 +1,40 @@
package exchange
import (
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
var _ backupHandler = &mailBackupHandler{}
type mailBackupHandler struct {
ac api.Mail
}
func newMailBackupHandler(
ac api.Client,
) mailBackupHandler {
acm := ac.Mail()
return mailBackupHandler{
ac: acm,
}
}
func (h mailBackupHandler) itemEnumerator() addedAndRemovedItemGetter {
return h.ac
}
func (h mailBackupHandler) itemHandler() itemGetterSerializer {
return h.ac
}
func (h mailBackupHandler) NewContainerCache(
userID string,
) (string, graph.ContainerResolver) {
return rootFolderAlias, &mailFolderCache{
userID: userID,
enumer: h.ac,
getter: h.ac,
}
}

View File

@ -0,0 +1,140 @@
package exchange
import (
"context"
"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/ptr"
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
var _ itemRestorer = &mailRestoreHandler{}
type mailRestoreHandler struct {
ac api.Mail
ip itemPoster[models.Messageable]
}
func newMailRestoreHandler(
ac api.Client,
) mailRestoreHandler {
acm := ac.Mail()
return mailRestoreHandler{
ac: acm,
ip: acm,
}
}
func (h mailRestoreHandler) newContainerCache(userID string) graph.ContainerResolver {
return &mailFolderCache{
userID: userID,
enumer: h.ac,
getter: h.ac,
}
}
func (h mailRestoreHandler) formatRestoreDestination(
destinationContainerName string,
collectionFullPath path.Path,
) *path.Builder {
return path.Builder{}.Append(destinationContainerName).Append(collectionFullPath.Folders()...)
}
func (h mailRestoreHandler) CreateContainer(
ctx context.Context,
userID, containerName, parentContainerID string,
) (graph.Container, error) {
if len(parentContainerID) == 0 {
parentContainerID = rootFolderAlias
}
return h.ac.CreateContainer(ctx, userID, containerName, parentContainerID)
}
func (h mailRestoreHandler) containerSearcher() containerByNamer {
return nil
}
// always returns rootFolderAlias
func (h mailRestoreHandler) orRootContainer(string) string {
return rootFolderAlias
}
func (h mailRestoreHandler) restore(
ctx context.Context,
body []byte,
userID, destinationID string,
errs *fault.Bus,
) (*details.ExchangeInfo, error) {
msg, err := api.BytesToMessageable(body)
if err != nil {
return nil, clues.Wrap(err, "creating mail from bytes").WithClues(ctx)
}
ctx = clues.Add(ctx, "item_id", ptr.Val(msg.GetId()))
msg = setMessageSVEPs(toMessage(msg))
attachments := msg.GetAttachments()
// Item.Attachments --> HasAttachments doesn't always have a value populated when deserialized
msg.SetAttachments([]models.Attachmentable{})
item, err := h.ip.PostItem(ctx, userID, destinationID, msg)
if err != nil {
return nil, graph.Wrap(ctx, err, "restoring mail message")
}
err = uploadAttachments(
ctx,
h.ac,
attachments,
userID,
destinationID,
ptr.Val(item.GetId()),
errs)
if err != nil {
return nil, clues.Stack(err)
}
return api.MailInfo(msg, int64(len(body))), nil
}
func setMessageSVEPs(msg models.Messageable) models.Messageable {
// Set Extended Properties:
svlep := make([]models.SingleValueLegacyExtendedPropertyable, 0)
// prevent "resending" of the mail in the graph api backstore
sv1 := models.NewSingleValueLegacyExtendedProperty()
sv1.SetId(ptr.To(MailRestorePropertyTag))
sv1.SetValue(ptr.To(RestoreCanonicalEnableValue))
svlep = append(svlep, sv1)
// establish the sent date
if msg.GetSentDateTime() != nil {
sv2 := models.NewSingleValueLegacyExtendedProperty()
sv2.SetId(ptr.To(MailSendDateTimeOverrideProperty))
sv2.SetValue(ptr.To(dttm.FormatToLegacy(ptr.Val(msg.GetSentDateTime()))))
svlep = append(svlep, sv2)
}
// establish the received Date
if msg.GetReceivedDateTime() != nil {
sv3 := models.NewSingleValueLegacyExtendedProperty()
sv3.SetId(ptr.To(MailReceiveDateTimeOverriveProperty))
sv3.SetValue(ptr.To(dttm.FormatToLegacy(ptr.Val(msg.GetReceivedDateTime()))))
svlep = append(svlep, sv3)
}
msg.SetSingleValueExtendedProperties(svlep)
return msg
}

View File

@ -0,0 +1,57 @@
package exchange
import (
"testing"
"github.com/alcionai/clues"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
type MailRestoreIntgSuite struct {
tester.Suite
creds account.M365Config
ac api.Client
userID string
}
func TestMailRestoreIntgSuite(t *testing.T) {
suite.Run(t, &MailRestoreIntgSuite{
Suite: tester.NewIntegrationSuite(
t,
[][]string{tester.M365AcctCredEnvs}),
})
}
func (suite *MailRestoreIntgSuite) SetupSuite() {
t := suite.T()
a := tester.NewM365Account(t)
creds, err := a.M365Config()
require.NoError(t, err, clues.ToCore(err))
suite.creds = creds
suite.ac, err = api.NewClient(creds)
require.NoError(t, err, clues.ToCore(err))
suite.userID = tester.M365UserID(t)
}
// Testing to ensure that cache system works for in multiple different environments
func (suite *MailRestoreIntgSuite) TestCreateContainerDestination() {
runCreateDestinationTest(
suite.T(),
newMailRestoreHandler(suite.ac),
path.EmailCategory,
suite.creds.AzureTenantID,
suite.userID,
tester.DefaultTestRestoreDestination("").ContainerName,
[]string{"Griffindor", "Croix"},
[]string{"Griffindor", "Felicius"})
}

View File

@ -14,7 +14,6 @@ import (
"github.com/alcionai/corso/src/internal/connector/graph" "github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/account" "github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api"
@ -66,9 +65,10 @@ func (suite *RestoreIntgSuite) TestRestoreContact() {
var ( var (
userID = tester.M365UserID(t) userID = tester.M365UserID(t)
folderName = tester.DefaultTestRestoreDestination("contact").ContainerName folderName = tester.DefaultTestRestoreDestination("contact").ContainerName
handler = newContactRestoreHandler(suite.ac)
) )
aFolder, err := suite.ac.Contacts().CreateContactFolder(ctx, userID, folderName) aFolder, err := handler.ac.CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
folderID := ptr.Val(aFolder.GetId()) folderID := ptr.Val(aFolder.GetId())
@ -79,13 +79,11 @@ func (suite *RestoreIntgSuite) TestRestoreContact() {
assert.NoError(t, err, clues.ToCore(err)) assert.NoError(t, err, clues.ToCore(err))
}() }()
info, err := RestoreContact( info, err := handler.restore(
ctx, ctx,
exchMock.ContactBytes("Corso TestContact"), exchMock.ContactBytes("Corso TestContact"),
suite.ac.Contacts(), userID, folderID,
control.Copy, fault.New(true))
folderID,
userID)
assert.NoError(t, err, clues.ToCore(err)) assert.NoError(t, err, clues.ToCore(err))
assert.NotNil(t, info, "contact item info") assert.NotNil(t, info, "contact item info")
} }
@ -101,9 +99,10 @@ func (suite *RestoreIntgSuite) TestRestoreEvent() {
var ( var (
userID = tester.M365UserID(t) userID = tester.M365UserID(t)
subject = tester.DefaultTestRestoreDestination("event").ContainerName subject = tester.DefaultTestRestoreDestination("event").ContainerName
handler = newEventRestoreHandler(suite.ac)
) )
calendar, err := suite.ac.Events().CreateCalendar(ctx, userID, subject) calendar, err := handler.ac.CreateContainer(ctx, userID, subject, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
calendarID := ptr.Val(calendar.GetId()) calendarID := ptr.Val(calendar.GetId())
@ -135,15 +134,10 @@ func (suite *RestoreIntgSuite) TestRestoreEvent() {
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
defer flush() defer flush()
info, err := RestoreEvent( info, err := handler.restore(
ctx, ctx,
test.bytes, test.bytes,
suite.ac.Events(), userID, calendarID,
suite.ac.Events(),
suite.gs,
control.Copy,
calendarID,
userID,
fault.New(true)) fault.New(true))
assert.NoError(t, err, clues.ToCore(err)) assert.NoError(t, err, clues.ToCore(err))
assert.NotNil(t, info, "event item info") assert.NotNil(t, info, "event item info")
@ -154,12 +148,8 @@ func (suite *RestoreIntgSuite) TestRestoreEvent() {
// TestRestoreExchangeObject verifies path.Category usage for restored objects // TestRestoreExchangeObject verifies path.Category usage for restored objects
func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
t := suite.T() t := suite.T()
a := tester.NewM365Account(t)
m365, err := a.M365Config()
require.NoError(t, err, clues.ToCore(err))
service, err := createService(m365) handlers := restoreHandlers(suite.ac)
require.NoError(t, err, clues.ToCore(err))
userID := tester.M365UserID(suite.T()) userID := tester.M365UserID(suite.T())
@ -175,7 +165,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EmailCategory, category: path.EmailCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("mailobj").ContainerName folderName := tester.DefaultTestRestoreDestination("mailobj").ContainerName
folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) folder, err := handlers[path.EmailCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
@ -187,7 +178,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EmailCategory, category: path.EmailCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("mailwattch").ContainerName folderName := tester.DefaultTestRestoreDestination("mailwattch").ContainerName
folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) folder, err := handlers[path.EmailCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
@ -199,7 +191,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EmailCategory, category: path.EmailCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("eventwattch").ContainerName folderName := tester.DefaultTestRestoreDestination("eventwattch").ContainerName
folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) folder, err := handlers[path.EmailCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
@ -211,13 +204,13 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EmailCategory, category: path.EmailCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("mailitemattch").ContainerName folderName := tester.DefaultTestRestoreDestination("mailitemattch").ContainerName
folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) folder, err := handlers[path.EmailCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
}, },
}, },
//TODO: Neha complete as part of https://github.com/alcionai/corso/issues/2428 //TODO: Neha complete as part of https://github.com/alcionai/corso/issues/2428
// { // {
// name: "Test Mail: Hydrated Item Attachment Mail", // name: "Test Mail: Hydrated Item Attachment Mail",
@ -228,7 +221,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
// category: path.EmailCategory, // category: path.EmailCategory,
// destination: func(t *testing.T, ctx context.Context) string { // destination: func(t *testing.T, ctx context.Context) string {
// folderName := tester.DefaultTestRestoreDestination("mailbasicattch").ContainerName // folderName := tester.DefaultTestRestoreDestination("mailbasicattch").ContainerName
// folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) // folder, err := handlers[path.EmailCategory].
// CreateContainer(ctx, userID, folderName, "")
// require.NoError(t, err, clues.ToCore(err)) // require.NoError(t, err, clues.ToCore(err))
// return ptr.Val(folder.GetId()) // return ptr.Val(folder.GetId())
@ -243,7 +237,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
// category: path.EmailCategory, // category: path.EmailCategory,
// destination: func(t *testing.T, ctx context.Context) string { // destination: func(t *testing.T, ctx context.Context) string {
// folderName := tester.DefaultTestRestoreDestination("mailnestattch").ContainerName // folderName := tester.DefaultTestRestoreDestination("mailnestattch").ContainerName
// folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) // folder, err := handlers[path.EmailCategory].
// CreateContainer(ctx, userID, folderName, "")
// require.NoError(t, err, clues.ToCore(err)) // require.NoError(t, err, clues.ToCore(err))
// return ptr.Val(folder.GetId()) // return ptr.Val(folder.GetId())
@ -258,7 +253,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EmailCategory, category: path.EmailCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("mailcontactattch").ContainerName folderName := tester.DefaultTestRestoreDestination("mailcontactattch").ContainerName
folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) folder, err := handlers[path.EmailCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
@ -270,7 +266,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EmailCategory, category: path.EmailCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("nestedattch").ContainerName folderName := tester.DefaultTestRestoreDestination("nestedattch").ContainerName
folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) folder, err := handlers[path.EmailCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
@ -282,7 +279,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EmailCategory, category: path.EmailCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("maillargeattch").ContainerName folderName := tester.DefaultTestRestoreDestination("maillargeattch").ContainerName
folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) folder, err := handlers[path.EmailCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
@ -294,7 +292,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EmailCategory, category: path.EmailCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("mailtwoattch").ContainerName folderName := tester.DefaultTestRestoreDestination("mailtwoattch").ContainerName
folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) folder, err := handlers[path.EmailCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
@ -306,20 +305,21 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EmailCategory, category: path.EmailCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("mailrefattch").ContainerName folderName := tester.DefaultTestRestoreDestination("mailrefattch").ContainerName
folder, err := suite.ac.Mail().CreateMailFolder(ctx, userID, folderName) folder, err := handlers[path.EmailCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
}, },
}, },
// TODO: #884 - reinstate when able to specify root folder by name
{ {
name: "Test Contact", name: "Test Contact",
bytes: exchMock.ContactBytes("Test_Omega"), bytes: exchMock.ContactBytes("Test_Omega"),
category: path.ContactsCategory, category: path.ContactsCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("contact").ContainerName folderName := tester.DefaultTestRestoreDestination("contact").ContainerName
folder, err := suite.ac.Contacts().CreateContactFolder(ctx, userID, folderName) folder, err := handlers[path.ContactsCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(folder.GetId()) return ptr.Val(folder.GetId())
@ -331,7 +331,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EventsCategory, category: path.EventsCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("event").ContainerName folderName := tester.DefaultTestRestoreDestination("event").ContainerName
calendar, err := suite.ac.Events().CreateCalendar(ctx, userID, folderName) calendar, err := handlers[path.EventsCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(calendar.GetId()) return ptr.Val(calendar.GetId())
@ -343,7 +344,8 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
category: path.EventsCategory, category: path.EventsCategory,
destination: func(t *testing.T, ctx context.Context) string { destination: func(t *testing.T, ctx context.Context) string {
folderName := tester.DefaultTestRestoreDestination("eventobj").ContainerName folderName := tester.DefaultTestRestoreDestination("eventobj").ContainerName
calendar, err := suite.ac.Events().CreateCalendar(ctx, userID, folderName) calendar, err := handlers[path.EventsCategory].
CreateContainer(ctx, userID, folderName, "")
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
return ptr.Val(calendar.GetId()) return ptr.Val(calendar.GetId())
@ -359,15 +361,10 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() {
defer flush() defer flush()
destination := test.destination(t, ctx) destination := test.destination(t, ctx)
info, err := RestoreItem( info, err := handlers[test.category].restore(
ctx, ctx,
test.bytes, test.bytes,
test.category, userID, destination,
control.Copy,
suite.ac,
service,
destination,
userID,
fault.New(true)) fault.New(true))
assert.NoError(t, err, clues.ToCore(err)) assert.NoError(t, err, clues.ToCore(err))
assert.NotNil(t, info, "item info was not populated") assert.NotNil(t, info, "item info was not populated")

View File

@ -1,160 +0,0 @@
package exchange
import (
"context"
"github.com/alcionai/clues"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/pkg/account"
"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/selectors"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
var ErrFolderNotFound = clues.New("folder not found")
func createService(credentials account.M365Config) (*graph.Service, error) {
adapter, err := graph.CreateAdapter(
credentials.AzureTenantID,
credentials.AzureClientID,
credentials.AzureClientSecret)
if err != nil {
return nil, clues.Wrap(err, "creating microsoft graph service for exchange")
}
return graph.NewService(adapter), nil
}
// populateExchangeContainerResolver gets a folder resolver if one is available for
// this category of data. If one is not available, returns nil so that other
// logic in the caller can complete as long as they check if the resolver is not
// nil. If an error occurs populating the resolver, returns an error.
func PopulateExchangeContainerResolver(
ctx context.Context,
qp graph.QueryParams,
errs *fault.Bus,
) (graph.ContainerResolver, error) {
var (
res graph.ContainerResolver
cacheRoot string
)
ac, err := api.NewClient(qp.Credentials)
if err != nil {
return nil, err
}
switch qp.Category {
case path.EmailCategory:
acm := ac.Mail()
res = &mailFolderCache{
userID: qp.ResourceOwner.ID(),
getter: acm,
enumer: acm,
}
cacheRoot = rootFolderAlias
case path.ContactsCategory:
acc := ac.Contacts()
res = &contactFolderCache{
userID: qp.ResourceOwner.ID(),
getter: acc,
enumer: acc,
}
cacheRoot = DefaultContactFolder
case path.EventsCategory:
ecc := ac.Events()
res = &eventCalendarCache{
userID: qp.ResourceOwner.ID(),
getter: ecc,
enumer: ecc,
}
cacheRoot = DefaultCalendar
default:
return nil, clues.New("no container resolver registered for category").WithClues(ctx)
}
if err := res.Populate(ctx, errs, cacheRoot); err != nil {
return nil, clues.Wrap(err, "populating directory resolver").WithClues(ctx)
}
return res, nil
}
// Returns true if the container passes the scope comparison and should be included.
// Returns:
// - the path representing the directory as it should be stored in the repository.
// - the human-readable path using display names.
// - true if the path passes the scope comparison.
func includeContainer(
ctx context.Context,
qp graph.QueryParams,
c graph.CachedContainer,
scope selectors.ExchangeScope,
category path.CategoryType,
) (path.Path, *path.Builder, bool) {
var (
directory string
locPath path.Path
pb = c.Path()
loc = c.Location()
)
// Clause ensures that DefaultContactFolder is inspected properly
if category == path.ContactsCategory && ptr.Val(c.GetDisplayName()) == DefaultContactFolder {
loc = loc.Append(DefaultContactFolder)
}
dirPath, err := pb.ToDataLayerExchangePathForCategory(
qp.Credentials.AzureTenantID,
qp.ResourceOwner.ID(),
category,
false)
// Containers without a path (e.g. Root mail folder) always err here.
if err != nil {
return nil, nil, false
}
directory = dirPath.Folder(false)
if loc != nil {
locPath, err = loc.ToDataLayerExchangePathForCategory(
qp.Credentials.AzureTenantID,
qp.ResourceOwner.ID(),
category,
false)
// Containers without a path (e.g. Root mail folder) always err here.
if err != nil {
return nil, nil, false
}
directory = locPath.Folder(false)
}
var ok bool
switch category {
case path.EmailCategory:
ok = scope.Matches(selectors.ExchangeMailFolder, directory)
case path.ContactsCategory:
ok = scope.Matches(selectors.ExchangeContactFolder, directory)
case path.EventsCategory:
ok = scope.Matches(selectors.ExchangeEventCalendar, directory)
default:
return nil, nil, false
}
logger.Ctx(ctx).With(
"included", ok,
"scope", scope,
"matches_input", directory,
).Debug("backup folder selection filter")
return dirPath, loc, ok
}

View File

@ -18,15 +18,6 @@ import (
"github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api"
) )
type addedAndRemovedItemIDsGetter interface {
GetAddedAndRemovedItemIDs(
ctx context.Context,
user, containerID, oldDeltaToken string,
immutableIDs bool,
canMakeDeltaQueries bool,
) ([]string, []string, api.DeltaUpdate, error)
}
// filterContainersAndFillCollections is a utility function // filterContainersAndFillCollections is a utility function
// that places the M365 object ids belonging to specific directories // that places the M365 object ids belonging to specific directories
// into a BackupCollection. Messages outside of those directories are omitted. // into a BackupCollection. Messages outside of those directories are omitted.
@ -39,7 +30,7 @@ type addedAndRemovedItemIDsGetter interface {
func filterContainersAndFillCollections( func filterContainersAndFillCollections(
ctx context.Context, ctx context.Context,
qp graph.QueryParams, qp graph.QueryParams,
getter addedAndRemovedItemIDsGetter, bh backupHandler,
statusUpdater support.StatusUpdater, statusUpdater support.StatusUpdater,
resolver graph.ContainerResolver, resolver graph.ContainerResolver,
scope selectors.ExchangeScope, scope selectors.ExchangeScope,
@ -61,19 +52,6 @@ func filterContainersAndFillCollections(
logger.Ctx(ctx).Infow("filling collections", "len_deltapaths", len(dps)) logger.Ctx(ctx).Infow("filling collections", "len_deltapaths", len(dps))
// TODO(rkeepers): this should be passed in from the caller, probably
// as an interface that satisfies the NewCollection requirements.
// But this will work for the short term.
ac, err := api.NewClient(qp.Credentials)
if err != nil {
return nil, err
}
ibt, err := itemerByType(ac, category)
if err != nil {
return nil, err
}
el := errs.Local() el := errs.Local()
for _, c := range resolver.Items() { for _, c := range resolver.Items() {
@ -85,6 +63,7 @@ func filterContainersAndFillCollections(
delete(tombstones, cID) delete(tombstones, cID)
var ( var (
err error
dp = dps[cID] dp = dps[cID]
prevDelta = dp.Delta prevDelta = dp.Delta
prevPathStr = dp.Path // do not log: pii; log prevPath instead prevPathStr = dp.Path // do not log: pii; log prevPath instead
@ -115,13 +94,14 @@ func filterContainersAndFillCollections(
ictx = clues.Add(ictx, "previous_path", prevPath) ictx = clues.Add(ictx, "previous_path", prevPath)
added, removed, newDelta, err := getter.GetAddedAndRemovedItemIDs( added, removed, newDelta, err := bh.itemEnumerator().
ictx, GetAddedAndRemovedItemIDs(
qp.ResourceOwner.ID(), ictx,
cID, qp.ResourceOwner.ID(),
prevDelta, cID,
ctrlOpts.ToggleFeatures.ExchangeImmutableIDs, prevDelta,
!ctrlOpts.ToggleFeatures.DisableDelta) ctrlOpts.ToggleFeatures.ExchangeImmutableIDs,
!ctrlOpts.ToggleFeatures.DisableDelta)
if err != nil { if err != nil {
if !graph.IsErrDeletedInFlight(err) { if !graph.IsErrDeletedInFlight(err) {
el.AddRecoverable(clues.Stack(err).Label(fault.LabelForceNoBackupCreation)) el.AddRecoverable(clues.Stack(err).Label(fault.LabelForceNoBackupCreation))
@ -148,7 +128,7 @@ func filterContainersAndFillCollections(
prevPath, prevPath,
locPath, locPath,
category, category,
ibt, bh.itemHandler(),
statusUpdater, statusUpdater,
ctrlOpts, ctrlOpts,
newDelta.Reset) newDelta.Reset)
@ -181,7 +161,10 @@ func filterContainersAndFillCollections(
return nil, el.Failure() return nil, el.Failure()
} }
ictx := clues.Add(ctx, "tombstone_id", id) var (
err error
ictx = clues.Add(ctx, "tombstone_id", id)
)
if collections[id] != nil { if collections[id] != nil {
el.AddRecoverable(clues.Wrap(err, "conflict: tombstone exists for a live collection").WithClues(ictx)) el.AddRecoverable(clues.Wrap(err, "conflict: tombstone exists for a live collection").WithClues(ictx))
@ -207,7 +190,7 @@ func filterContainersAndFillCollections(
prevPath, prevPath,
nil, // tombstones don't need a location nil, // tombstones don't need a location
category, category,
ibt, bh.itemHandler(),
statusUpdater, statusUpdater,
ctrlOpts, ctrlOpts,
false) false)
@ -220,7 +203,7 @@ func filterContainersAndFillCollections(
"num_deltas_entries", len(deltaURLs)) "num_deltas_entries", len(deltaURLs))
col, err := graph.MakeMetadataCollection( col, err := graph.MakeMetadataCollection(
qp.Credentials.AzureTenantID, qp.TenantID,
qp.ResourceOwner.ID(), qp.ResourceOwner.ID(),
path.ExchangeService, path.ExchangeService,
qp.Category, qp.Category,
@ -260,15 +243,74 @@ func pathFromPrevString(ps string) (path.Path, error) {
return p, nil return p, nil
} }
func itemerByType(ac api.Client, category path.CategoryType) (itemer, error) { // Returns true if the container passes the scope comparison and should be included.
// Returns:
// - the path representing the directory as it should be stored in the repository.
// - the human-readable path using display names.
// - true if the path passes the scope comparison.
func includeContainer(
ctx context.Context,
qp graph.QueryParams,
c graph.CachedContainer,
scope selectors.ExchangeScope,
category path.CategoryType,
) (path.Path, *path.Builder, bool) {
var (
directory string
locPath path.Path
pb = c.Path()
loc = c.Location()
)
// Clause ensures that DefaultContactFolder is inspected properly
if category == path.ContactsCategory && ptr.Val(c.GetDisplayName()) == DefaultContactFolder {
loc = loc.Append(DefaultContactFolder)
}
dirPath, err := pb.ToDataLayerExchangePathForCategory(
qp.TenantID,
qp.ResourceOwner.ID(),
category,
false)
// Containers without a path (e.g. Root mail folder) always err here.
if err != nil {
return nil, nil, false
}
directory = dirPath.Folder(false)
if loc != nil {
locPath, err = loc.ToDataLayerExchangePathForCategory(
qp.TenantID,
qp.ResourceOwner.ID(),
category,
false)
// Containers without a path (e.g. Root mail folder) always err here.
if err != nil {
return nil, nil, false
}
directory = locPath.Folder(false)
}
var ok bool
switch category { switch category {
case path.EmailCategory: case path.EmailCategory:
return ac.Mail(), nil ok = scope.Matches(selectors.ExchangeMailFolder, directory)
case path.EventsCategory:
return ac.Events(), nil
case path.ContactsCategory: case path.ContactsCategory:
return ac.Contacts(), nil ok = scope.Matches(selectors.ExchangeContactFolder, directory)
case path.EventsCategory:
ok = scope.Matches(selectors.ExchangeEventCalendar, directory)
default: default:
return nil, clues.New("category not registered in getFetchIDFunc") return nil, nil, false
} }
logger.Ctx(ctx).With(
"included", ok,
"scope", scope,
"matches_input", directory,
).Debug("backup folder selection filter")
return dirPath, loc, ok
} }

View File

@ -27,7 +27,25 @@ import (
// mocks // mocks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
var _ addedAndRemovedItemIDsGetter = &mockGetter{} var _ backupHandler = &mockBackupHandler{}
type mockBackupHandler struct {
mg mockGetter
category path.CategoryType
ac api.Client
userID string
}
func (bh mockBackupHandler) itemEnumerator() addedAndRemovedItemGetter { return bh.mg }
func (bh mockBackupHandler) itemHandler() itemGetterSerializer { return nil }
func (bh mockBackupHandler) NewContainerCache(
userID string,
) (string, graph.ContainerResolver) {
return BackupHandlers(bh.ac)[bh.category].NewContainerCache(bh.userID)
}
var _ addedAndRemovedItemGetter = &mockGetter{}
type ( type (
mockGetter struct { mockGetter struct {
@ -115,7 +133,7 @@ type ServiceIteratorsSuite struct {
creds account.M365Config creds account.M365Config
} }
func TestServiceIteratorsSuite(t *testing.T) { func TestServiceIteratorsUnitSuite(t *testing.T) {
suite.Run(t, &ServiceIteratorsSuite{Suite: tester.NewUnitSuite(t)}) suite.Run(t, &ServiceIteratorsSuite{Suite: tester.NewUnitSuite(t)})
} }
@ -131,7 +149,7 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections() {
qp = graph.QueryParams{ qp = graph.QueryParams{
Category: path.EmailCategory, // doesn't matter which one we use. Category: path.EmailCategory, // doesn't matter which one we use.
ResourceOwner: inMock.NewProvider("user_id", "user_name"), ResourceOwner: inMock.NewProvider("user_id", "user_name"),
Credentials: suite.creds, TenantID: suite.creds.AzureTenantID,
} }
statusUpdater = func(*support.ConnectorOperationStatus) {} statusUpdater = func(*support.ConnectorOperationStatus) {}
allScope = selectors.NewExchangeBackup(nil).MailFolders(selectors.Any())[0] allScope = selectors.NewExchangeBackup(nil).MailFolders(selectors.Any())[0]
@ -326,10 +344,15 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections() {
ctrlOpts := control.Options{FailureHandling: test.failFast} ctrlOpts := control.Options{FailureHandling: test.failFast}
ctrlOpts.ToggleFeatures.DisableDelta = !canMakeDeltaQueries ctrlOpts.ToggleFeatures.DisableDelta = !canMakeDeltaQueries
mbh := mockBackupHandler{
mg: test.getter,
category: qp.Category,
}
collections, err := filterContainersAndFillCollections( collections, err := filterContainersAndFillCollections(
ctx, ctx,
qp, qp,
test.getter, mbh,
statusUpdater, statusUpdater,
test.resolver, test.resolver,
test.scope, test.scope,
@ -422,7 +445,7 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections_Dupli
var ( var (
qp = graph.QueryParams{ qp = graph.QueryParams{
ResourceOwner: inMock.NewProvider("user_id", "user_name"), ResourceOwner: inMock.NewProvider("user_id", "user_name"),
Credentials: suite.creds, TenantID: suite.creds.AzureTenantID,
} }
statusUpdater = func(*support.ConnectorOperationStatus) {} statusUpdater = func(*support.ConnectorOperationStatus) {}
@ -660,10 +683,15 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections_Dupli
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
defer flush() defer flush()
mbh := mockBackupHandler{
mg: test.getter,
category: qp.Category,
}
collections, err := filterContainersAndFillCollections( collections, err := filterContainersAndFillCollections(
ctx, ctx,
qp, qp,
test.getter, mbh,
statusUpdater, statusUpdater,
test.resolver, test.resolver,
sc.scope, sc.scope,
@ -803,7 +831,7 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections_repea
qp = graph.QueryParams{ qp = graph.QueryParams{
Category: path.EmailCategory, // doesn't matter which one we use. Category: path.EmailCategory, // doesn't matter which one we use.
ResourceOwner: inMock.NewProvider("user_id", "user_name"), ResourceOwner: inMock.NewProvider("user_id", "user_name"),
Credentials: suite.creds, TenantID: suite.creds.AzureTenantID,
} }
statusUpdater = func(*support.ConnectorOperationStatus) {} statusUpdater = func(*support.ConnectorOperationStatus) {}
allScope = selectors.NewExchangeBackup(nil).MailFolders(selectors.Any())[0] allScope = selectors.NewExchangeBackup(nil).MailFolders(selectors.Any())[0]
@ -815,6 +843,10 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections_repea
l: path.Builder{}.Append("display_name_1"), l: path.Builder{}.Append("display_name_1"),
} }
resolver = newMockResolver(container1) resolver = newMockResolver(container1)
mbh = mockBackupHandler{
mg: test.getter,
category: qp.Category,
}
) )
require.Equal(t, "user_id", qp.ResourceOwner.ID(), qp.ResourceOwner) require.Equal(t, "user_id", qp.ResourceOwner.ID(), qp.ResourceOwner)
@ -823,7 +855,7 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections_repea
collections, err := filterContainersAndFillCollections( collections, err := filterContainersAndFillCollections(
ctx, ctx,
qp, qp,
test.getter, mbh,
statusUpdater, statusUpdater,
resolver, resolver,
allScope, allScope,
@ -884,7 +916,7 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections_incre
qp = graph.QueryParams{ qp = graph.QueryParams{
Category: cat, Category: cat,
ResourceOwner: inMock.NewProvider("user_id", "user_name"), ResourceOwner: inMock.NewProvider("user_id", "user_name"),
Credentials: suite.creds, TenantID: suite.creds.AzureTenantID,
} }
statusUpdater = func(*support.ConnectorOperationStatus) {} statusUpdater = func(*support.ConnectorOperationStatus) {}
allScope = selectors.NewExchangeBackup(nil).MailFolders(selectors.Any())[0] allScope = selectors.NewExchangeBackup(nil).MailFolders(selectors.Any())[0]
@ -1226,6 +1258,11 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections_incre
getter.noReturnDelta = false getter.noReturnDelta = false
} }
mbh := mockBackupHandler{
mg: test.getter,
category: qp.Category,
}
dps := test.dps dps := test.dps
if !deltaBefore { if !deltaBefore {
for k, dp := range dps { for k, dp := range dps {
@ -1237,7 +1274,7 @@ func (suite *ServiceIteratorsSuite) TestFilterContainersAndFillCollections_incre
collections, err := filterContainersAndFillCollections( collections, err := filterContainersAndFillCollections(
ctx, ctx,
qp, qp,
test.getter, mbh,
statusUpdater, statusUpdater,
test.resolver, test.resolver,
allScope, allScope,

View File

@ -3,13 +3,11 @@ package exchange
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"runtime/trace" "runtime/trace"
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models" "github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/alcionai/corso/src/internal/common/dttm"
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/connector/graph" "github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/internal/connector/support" "github.com/alcionai/corso/src/internal/connector/support"
@ -25,216 +23,6 @@ import (
"github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api"
) )
type itemPoster[T any] interface {
PostItem(
ctx context.Context,
userID, dirID string,
body T,
) (T, error)
}
// RestoreItem directs restore pipeline towards restore function
// based on the path.CategoryType. All input params are necessary to perform
// the type-specific restore function.
func RestoreItem(
ctx context.Context,
bits []byte,
category path.CategoryType,
policy control.CollisionPolicy,
ac api.Client,
gs graph.Servicer,
destination, user string,
errs *fault.Bus,
) (*details.ExchangeInfo, error) {
if policy != control.Copy {
return nil, clues.Wrap(clues.New(policy.String()), "policy not supported for Exchange restore").WithClues(ctx)
}
switch category {
case path.EmailCategory:
return RestoreMessage(ctx, bits, ac.Mail(), ac.Mail(), gs, control.Copy, destination, user, errs)
case path.ContactsCategory:
return RestoreContact(ctx, bits, ac.Contacts(), control.Copy, destination, user)
case path.EventsCategory:
return RestoreEvent(ctx, bits, ac.Events(), ac.Events(), gs, control.Copy, destination, user, errs)
default:
return nil, clues.Wrap(clues.New(category.String()), "not supported for Exchange restore")
}
}
// RestoreContact wraps api.Contacts().PostItem()
func RestoreContact(
ctx context.Context,
body []byte,
cli itemPoster[models.Contactable],
cp control.CollisionPolicy,
destination, user string,
) (*details.ExchangeInfo, error) {
contact, err := api.BytesToContactable(body)
if err != nil {
return nil, graph.Wrap(ctx, err, "creating contact from bytes")
}
ctx = clues.Add(ctx, "item_id", ptr.Val(contact.GetId()))
_, err = cli.PostItem(ctx, user, destination, contact)
if err != nil {
return nil, clues.Stack(err)
}
info := api.ContactInfo(contact)
info.Size = int64(len(body))
return info, nil
}
// RestoreEvent wraps api.Events().PostItem()
func RestoreEvent(
ctx context.Context,
body []byte,
itemCli itemPoster[models.Eventable],
attachmentCli attachmentPoster,
gs graph.Servicer,
cp control.CollisionPolicy,
destination, user string,
errs *fault.Bus,
) (*details.ExchangeInfo, error) {
event, err := api.BytesToEventable(body)
if err != nil {
return nil, clues.Wrap(err, "creating event from bytes").WithClues(ctx)
}
ctx = clues.Add(ctx, "item_id", ptr.Val(event.GetId()))
var (
el = errs.Local()
transformedEvent = toEventSimplified(event)
attached []models.Attachmentable
)
if ptr.Val(event.GetHasAttachments()) {
attached = event.GetAttachments()
transformedEvent.SetAttachments([]models.Attachmentable{})
}
item, err := itemCli.PostItem(ctx, user, destination, event)
if err != nil {
return nil, clues.Stack(err)
}
for _, a := range attached {
if el.Failure() != nil {
break
}
err := uploadAttachment(
ctx,
attachmentCli,
user,
destination,
ptr.Val(item.GetId()),
a)
if err != nil {
el.AddRecoverable(err)
}
}
info := api.EventInfo(event)
info.Size = int64(len(body))
return info, el.Failure()
}
// RestoreMessage wraps api.Mail().PostItem(), handling attachment creation along the way
func RestoreMessage(
ctx context.Context,
body []byte,
itemCli itemPoster[models.Messageable],
attachmentCli attachmentPoster,
gs graph.Servicer,
cp control.CollisionPolicy,
destination, user string,
errs *fault.Bus,
) (*details.ExchangeInfo, error) {
// Creates messageable object from original bytes
msg, err := api.BytesToMessageable(body)
if err != nil {
return nil, clues.Wrap(err, "creating mail from bytes").WithClues(ctx)
}
ctx = clues.Add(ctx, "item_id", ptr.Val(msg.GetId()))
var (
clone = toMessage(msg)
valueID = MailRestorePropertyTag
enableValue = RestoreCanonicalEnableValue
)
// Set Extended Properties:
// 1st: No transmission
// 2nd: Send Date
// 3rd: Recv Date
svlep := make([]models.SingleValueLegacyExtendedPropertyable, 0)
sv1 := models.NewSingleValueLegacyExtendedProperty()
sv1.SetId(&valueID)
sv1.SetValue(&enableValue)
svlep = append(svlep, sv1)
if clone.GetSentDateTime() != nil {
sv2 := models.NewSingleValueLegacyExtendedProperty()
sendPropertyValue := dttm.FormatToLegacy(ptr.Val(clone.GetSentDateTime()))
sendPropertyTag := MailSendDateTimeOverrideProperty
sv2.SetId(&sendPropertyTag)
sv2.SetValue(&sendPropertyValue)
svlep = append(svlep, sv2)
}
if clone.GetReceivedDateTime() != nil {
sv3 := models.NewSingleValueLegacyExtendedProperty()
recvPropertyValue := dttm.FormatToLegacy(ptr.Val(clone.GetReceivedDateTime()))
recvPropertyTag := MailReceiveDateTimeOverriveProperty
sv3.SetId(&recvPropertyTag)
sv3.SetValue(&recvPropertyValue)
svlep = append(svlep, sv3)
}
clone.SetSingleValueExtendedProperties(svlep)
attached := clone.GetAttachments()
// Item.Attachments --> HasAttachments doesn't always have a value populated when deserialized
clone.SetAttachments([]models.Attachmentable{})
item, err := itemCli.PostItem(ctx, user, destination, clone)
if err != nil {
return nil, graph.Wrap(ctx, err, "restoring mail message")
}
el := errs.Local()
for _, a := range attached {
if el.Failure() != nil {
return nil, el.Failure()
}
err := uploadAttachment(
ctx,
attachmentCli,
user,
destination,
ptr.Val(item.GetId()),
a)
if err != nil {
el.AddRecoverable(clues.Wrap(err, "uploading mail attachment"))
}
}
return api.MailInfo(clone, int64(len(body))), el.Failure()
}
// RestoreCollections restores M365 objects in data.RestoreCollection to MSFT // RestoreCollections restores M365 objects in data.RestoreCollection to MSFT
// store through GraphAPI. // store through GraphAPI.
func RestoreCollections( func RestoreCollections(
@ -247,49 +35,82 @@ func RestoreCollections(
deets *details.Builder, deets *details.Builder,
errs *fault.Bus, errs *fault.Bus,
) (*support.ConnectorOperationStatus, error) { ) (*support.ConnectorOperationStatus, error) {
if len(dcs) == 0 {
return support.CreateStatus(ctx, support.Restore, 0, support.CollectionMetrics{}, ""), nil
}
var ( var (
directoryCaches = make(map[string]map[path.CategoryType]graph.ContainerResolver) userID = dcs[0].FullPath().ResourceOwner()
metrics support.CollectionMetrics directoryCache = make(map[path.CategoryType]graph.ContainerResolver)
userID string handlers = restoreHandlers(ac)
metrics support.CollectionMetrics
// TODO policy to be updated from external source after completion of refactoring // TODO policy to be updated from external source after completion of refactoring
policy = control.Copy policy = control.Copy
el = errs.Local() el = errs.Local()
) )
if len(dcs) > 0 { ctx = clues.Add(ctx, "resource_owner", clues.Hide(userID))
userID = dcs[0].FullPath().ResourceOwner()
ctx = clues.Add(ctx, "resource_owner", clues.Hide(userID))
}
for _, dc := range dcs { for _, dc := range dcs {
if el.Failure() != nil { if el.Failure() != nil {
break break
} }
userCaches := directoryCaches[userID] var (
if userCaches == nil { isNewCache bool
directoryCaches[userID] = make(map[path.CategoryType]graph.ContainerResolver) category = dc.FullPath().Category()
userCaches = directoryCaches[userID] ictx = clues.Add(
} ctx,
"restore_category", category,
"restore_full_path", dc.FullPath())
)
containerID, err := CreateContainerDestination( handler, ok := handlers[category]
ctx, if !ok {
creds, el.AddRecoverable(clues.New("unsupported restore path category").WithClues(ictx))
dc.FullPath(),
dest.ContainerName,
userCaches,
errs)
if err != nil {
el.AddRecoverable(clues.Wrap(err, "creating destination").WithClues(ctx))
continue continue
} }
temp, canceled := restoreCollection(ctx, ac, gs, dc, containerID, policy, deets, errs) if directoryCache[category] == nil {
directoryCache[category] = handler.newContainerCache(userID)
isNewCache = true
}
containerID, gcr, err := createDestination(
ictx,
handler,
handler.formatRestoreDestination(dest.ContainerName, dc.FullPath()),
userID,
directoryCache[category],
isNewCache,
errs)
if err != nil {
el.AddRecoverable(err)
continue
}
directoryCache[category] = gcr
ictx = clues.Add(ictx, "restore_destination_id", containerID)
temp, err := restoreCollection(
ictx,
handler,
dc,
userID,
containerID,
policy,
deets,
errs)
metrics = support.CombineMetrics(metrics, temp) metrics = support.CombineMetrics(metrics, temp)
if canceled { if err != nil {
break if graph.IsErrTimeout(err) {
break
}
el.AddRecoverable(err)
} }
} }
@ -306,48 +127,38 @@ func RestoreCollections(
// restoreCollection handles restoration of an individual collection. // restoreCollection handles restoration of an individual collection.
func restoreCollection( func restoreCollection(
ctx context.Context, ctx context.Context,
ac api.Client, ir itemRestorer,
gs graph.Servicer,
dc data.RestoreCollection, dc data.RestoreCollection,
folderID string, userID, destinationID string,
policy control.CollisionPolicy, policy control.CollisionPolicy,
deets *details.Builder, deets *details.Builder,
errs *fault.Bus, errs *fault.Bus,
) (support.CollectionMetrics, bool) { ) (support.CollectionMetrics, error) {
ctx, end := diagnostics.Span(ctx, "gc:exchange:restoreCollection", diagnostics.Label("path", dc.FullPath())) ctx, end := diagnostics.Span(ctx, "gc:exchange:restoreCollection", diagnostics.Label("path", dc.FullPath()))
defer end() defer end()
var ( var (
metrics support.CollectionMetrics el = errs.Local()
items = dc.Items(ctx, errs) metrics support.CollectionMetrics
directory = dc.FullPath() items = dc.Items(ctx, errs)
service = directory.Service() fullPath = dc.FullPath()
category = directory.Category() category = fullPath.Category()
user = directory.ResourceOwner()
) )
ctx = clues.Add( colProgress := observe.CollectionProgress(
ctx,
"full_path", directory,
"service", service,
"category", category)
colProgress, closer := observe.CollectionProgress(
ctx, ctx,
category.String(), category.String(),
clues.Hide(directory.Folder(false))) fullPath.Folder(false))
defer closer()
defer close(colProgress) defer close(colProgress)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
errs.AddRecoverable(clues.Wrap(ctx.Err(), "context cancelled").WithClues(ctx)) return metrics, clues.Wrap(ctx.Err(), "context cancelled").WithClues(ctx)
return metrics, true
case itemData, ok := <-items: case itemData, ok := <-items:
if !ok || errs.Failure() != nil { if !ok || el.Failure() != nil {
return metrics, false return metrics, el.Failure()
} }
ictx := clues.Add(ctx, "item_id", itemData.UUID()) ictx := clues.Add(ctx, "item_id", itemData.UUID())
@ -358,33 +169,26 @@ func restoreCollection(
_, err := buf.ReadFrom(itemData.ToReader()) _, err := buf.ReadFrom(itemData.ToReader())
if err != nil { if err != nil {
errs.AddRecoverable(clues.Wrap(err, "reading item bytes").WithClues(ictx)) el.AddRecoverable(clues.Wrap(err, "reading item bytes").WithClues(ictx))
continue continue
} }
byteArray := buf.Bytes() body := buf.Bytes()
info, err := RestoreItem( info, err := ir.restore(ictx, body, userID, destinationID, errs)
ictx,
byteArray,
category,
policy,
ac,
gs,
folderID,
user,
errs)
if err != nil { if err != nil {
errs.AddRecoverable(err) el.AddRecoverable(err)
continue continue
} }
metrics.Bytes += int64(len(byteArray)) metrics.Bytes += int64(len(body))
metrics.Successes++ metrics.Successes++
itemPath, err := dc.FullPath().AppendItem(itemData.UUID()) // FIXME: this may be the incorrect path. If we restored within a top-level
// destination folder, then the restore path no longer matches the fullPath.
itemPath, err := fullPath.AppendItem(itemData.UUID())
if err != nil { if err != nil {
errs.AddRecoverable(clues.Wrap(err, "building full path with item").WithClues(ctx)) el.AddRecoverable(clues.Wrap(err, "adding item to collection path").WithClues(ctx))
continue continue
} }
@ -398,7 +202,8 @@ func restoreCollection(
Exchange: info, Exchange: info,
}) })
if err != nil { if err != nil {
// Not critical enough to need to stop restore operation. // These deets additions are for cli display purposes only.
// no need to fail out on error.
logger.Ctx(ctx).Infow("accounting for restored item", "error", err) logger.Ctx(ctx).Infow("accounting for restored item", "error", err)
} }
@ -407,257 +212,131 @@ func restoreCollection(
} }
} }
// CreateContainerDestination builds the destination into the container // createDestination creates folders in sequence
// at the provided path. As a precondition, the destination cannot // [root leaf1 leaf2] similar to a linked list.
// already exist. If it does then an error is returned. The provided // @param directory is the desired path from the root to the container
// containerResolver is updated with the new destination. // that the items will be restored into.
// @ returns the container ID of the new destination container. func createDestination(
func CreateContainerDestination(
ctx context.Context, ctx context.Context,
creds account.M365Config, ca containerAPI,
directory path.Path, destination *path.Builder,
destination string, userID string,
caches map[path.CategoryType]graph.ContainerResolver, gcr graph.ContainerResolver,
isNewCache bool,
errs *fault.Bus, errs *fault.Bus,
) (string, error) { ) (string, graph.ContainerResolver, error) {
var ( var (
newCache = false cache = gcr
user = directory.ResourceOwner() restoreLoc = &path.Builder{}
category = directory.Category() containerParentID string
directoryCache = caches[category]
) )
// TODO(rkeepers): pass the api client into this func, rather than generating one. for _, container := range destination.Elements() {
ac, err := api.NewClient(creds) restoreLoc = restoreLoc.Append(container)
if err != nil {
return "", clues.Stack(err).WithClues(ctx)
}
switch category { ictx := clues.Add(
case path.EmailCategory:
folders := append([]string{destination}, directory.Folders()...)
if directoryCache == nil {
acm := ac.Mail()
mfc := &mailFolderCache{
userID: user,
enumer: acm,
getter: acm,
}
caches[category] = mfc
newCache = true
directoryCache = mfc
}
return establishMailRestoreLocation(
ctx, ctx,
ac, "is_new_cache", isNewCache,
folders, "container_parent_id", containerParentID,
directoryCache, "container_name", container,
user, "restore_location", restoreLoc)
newCache,
fid, err := getOrPopulateContainer(
ictx,
ca,
cache,
restoreLoc,
userID,
containerParentID,
container,
isNewCache,
errs) errs)
case path.ContactsCategory:
folders := append([]string{destination}, directory.Folders()...)
if directoryCache == nil {
acc := ac.Contacts()
cfc := &contactFolderCache{
userID: user,
enumer: acc,
getter: acc,
}
caches[category] = cfc
newCache = true
directoryCache = cfc
}
return establishContactsRestoreLocation(
ctx,
ac,
folders,
directoryCache,
user,
newCache,
errs)
case path.EventsCategory:
dest := destination
if directoryCache == nil {
ace := ac.Events()
ecc := &eventCalendarCache{
userID: user,
getter: ace,
enumer: ace,
}
caches[category] = ecc
newCache = true
directoryCache = ecc
}
folders := append([]string{dest}, directory.Folders()...)
return establishEventsRestoreLocation(
ctx,
ac,
folders,
directoryCache,
user,
newCache,
errs)
default:
return "", clues.New(fmt.Sprintf("type not supported: %T", category)).WithClues(ctx)
}
}
// establishMailRestoreLocation creates Mail folders in sequence
// [root leaf1 leaf2] in a similar to a linked list.
// @param folders is the desired path from the root to the container
// that the items will be restored into
// @param isNewCache identifies if the cache is created and not populated
func establishMailRestoreLocation(
ctx context.Context,
ac api.Client,
folders []string,
mfc graph.ContainerResolver,
user string,
isNewCache bool,
errs *fault.Bus,
) (string, error) {
// Process starts with the root folder in order to recreate
// the top-level folder with the same tactic
folderID := rootFolderAlias
pb := path.Builder{}
ctx = clues.Add(ctx, "is_new_cache", isNewCache)
for _, folder := range folders {
pb = *pb.Append(folder)
cached, ok := mfc.LocationInCache(pb.String())
if ok {
folderID = cached
continue
}
temp, err := ac.Mail().CreateMailFolderWithParent(ctx, user, folder, folderID)
if err != nil { if err != nil {
// Should only error if cache malfunctions or incorrect parameters return "", cache, clues.Stack(err)
return "", err
} }
folderID = ptr.Val(temp.GetId()) containerParentID = fid
// Only populate the cache if we actually had to create it. Since we set
// newCache to false in this we'll only try to populate it once per function
// call even if we make a new cache.
if isNewCache {
if err := mfc.Populate(ctx, errs, rootFolderAlias); err != nil {
return "", clues.Wrap(err, "populating folder cache")
}
isNewCache = false
}
// NOOP if the folder is already in the cache.
if err = mfc.AddToCache(ctx, temp); err != nil {
return "", clues.Wrap(err, "adding folder to cache")
}
} }
return folderID, nil // containerParentID now identifies the last created container,
// not its parent.
return containerParentID, cache, nil
} }
// establishContactsRestoreLocation creates Contact Folders in sequence func getOrPopulateContainer(
// and updates the container resolver appropriately. Contact Folders are
// displayed in a flat representation. Therefore, only the root can be populated and all content
// must be restored into the root location.
// @param folders is the list of intended folders from root to leaf (e.g. [root ...])
// @param isNewCache bool representation of whether Populate function needs to be run
func establishContactsRestoreLocation(
ctx context.Context, ctx context.Context,
ac api.Client, ca containerAPI,
folders []string, gcr graph.ContainerResolver,
cfc graph.ContainerResolver, restoreLoc *path.Builder,
user string, userID, containerParentID, containerName string,
isNewCache bool, isNewCache bool,
errs *fault.Bus, errs *fault.Bus,
) (string, error) { ) (string, error) {
cached, ok := cfc.LocationInCache(folders[0]) cached, ok := gcr.LocationInCache(restoreLoc.String())
if ok { if ok {
return cached, nil return cached, nil
} }
ctx = clues.Add(ctx, "is_new_cache", isNewCache) c, err := ca.CreateContainer(ctx, userID, containerName, containerParentID)
temp, err := ac.Contacts().CreateContactFolder(ctx, user, folders[0]) // 409 handling case:
if err != nil { // attempt to fetch the container by name and add that result to the cache.
return "", err // This is rare, but may happen if CreateContainer() POST fails with 5xx:
} // sometimes the backend will create the folder despite the 5xx response,
// leaving our local containerResolver with inconsistent state.
folderID := ptr.Val(temp.GetId())
if isNewCache {
if err := cfc.Populate(ctx, errs, folderID, folders[0]); err != nil {
return "", clues.Wrap(err, "populating contact cache")
}
if err = cfc.AddToCache(ctx, temp); err != nil {
return "", clues.Wrap(err, "adding contact folder to cache")
}
}
return folderID, nil
}
func establishEventsRestoreLocation(
ctx context.Context,
ac api.Client,
folders []string,
ecc graph.ContainerResolver, // eventCalendarCache
user string,
isNewCache bool,
errs *fault.Bus,
) (string, error) {
// Need to prefix with the "Other Calendars" folder so lookup happens properly.
cached, ok := ecc.LocationInCache(folders[0])
if ok {
return cached, nil
}
ctx = clues.Add(ctx, "is_new_cache", isNewCache)
temp, err := ac.Events().CreateCalendar(ctx, user, folders[0])
if err != nil && !graph.IsErrFolderExists(err) {
return "", err
}
// 409 handling: Fetch folder if it exists and add to cache.
// This is rare, but may happen if CreateCalendar() POST fails with 5xx,
// potentially leaving dirty state in graph.
if graph.IsErrFolderExists(err) { if graph.IsErrFolderExists(err) {
temp, err = ac.Events().GetContainerByName(ctx, user, folders[0]) cs := ca.containerSearcher()
if err != nil { if cs != nil {
return "", err cc, e := cs.GetContainerByName(ctx, userID, containerName)
c = cc
err = clues.Stack(err, e)
} }
} }
folderID := ptr.Val(temp.GetId()) if err != nil {
return "", clues.Wrap(err, "creating restore container")
}
folderID := ptr.Val(c.GetId())
if isNewCache { if isNewCache {
if err = ecc.Populate(ctx, errs, folderID, folders[0]); err != nil { if err := gcr.Populate(ctx, errs, folderID, ca.orRootContainer(restoreLoc.HeadElem())); err != nil {
return "", clues.Wrap(err, "populating event cache") return "", clues.Wrap(err, "populating container cache")
} }
}
displayable := api.CalendarDisplayable{Calendarable: temp} if err = gcr.AddToCache(ctx, c); err != nil {
if err = ecc.AddToCache(ctx, displayable); err != nil { return "", clues.Wrap(err, "adding container to cache")
return "", clues.Wrap(err, "adding new calendar to cache")
}
} }
return folderID, nil return folderID, nil
} }
func uploadAttachments(
ctx context.Context,
ap attachmentPoster,
as []models.Attachmentable,
userID, destinationID, itemID string,
errs *fault.Bus,
) error {
el := errs.Local()
for _, a := range as {
if el.Failure() != nil {
return el.Failure()
}
err := uploadAttachment(
ctx,
ap,
userID,
destinationID,
itemID,
a)
if err != nil {
el.AddRecoverable(clues.Wrap(err, "uploading mail attachment").WithClues(ctx))
}
}
return el.Failure()
}

View File

@ -0,0 +1,34 @@
package testdata
import (
"context"
"testing"
"github.com/alcionai/clues"
"github.com/stretchr/testify/require"
"github.com/alcionai/corso/src/internal/connector/exchange"
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
func PopulateContainerCache(
t *testing.T,
ctx context.Context, //revive:disable-line:context-as-argument
ac api.Client,
category path.CategoryType,
resourceOwnerID string,
errs *fault.Bus,
) graph.ContainerResolver {
handler, ok := exchange.BackupHandlers(ac)[category]
require.Truef(t, ok, "container resolver registered for category %s", category)
root, cc := handler.NewContainerCache(resourceOwnerID)
err := cc.Populate(ctx, errs, root)
require.NoError(t, err, clues.ToCore(err))
return cc
}

View File

@ -56,14 +56,7 @@ func CloneMessageableFields(orig, message models.Messageable) models.Messageable
func toMessage(orig models.Messageable) models.Messageable { func toMessage(orig models.Messageable) models.Messageable {
message := models.NewMessage() message := models.NewMessage()
temp := CloneMessageableFields(orig, message) return CloneMessageableFields(orig, message)
aMessage, ok := temp.(*models.Message)
if !ok {
return nil
}
return aMessage
} }
// ToEventSimplified transforms an event to simplified restore format // ToEventSimplified transforms an event to simplified restore format

View File

@ -64,7 +64,7 @@ type ContainerResolver interface {
// @param ctx is necessary param for Graph API tracing // @param ctx is necessary param for Graph API tracing
// @param baseFolderID represents the M365ID base that the resolver will // @param baseFolderID represents the M365ID base that the resolver will
// conclude its search. Default input is "". // conclude its search. Default input is "".
Populate(ctx context.Context, errs *fault.Bus, baseFolderID string, baseContainerPather ...string) error Populate(ctx context.Context, errs *fault.Bus, baseFolderID string, baseContainerPath ...string) error
// PathInCache performs a look up of a path representation // PathInCache performs a look up of a path representation
// and returns the m365ID of directory iff the pathString // and returns the m365ID of directory iff the pathString

View File

@ -142,6 +142,8 @@ type limiterConsumptionKey string
const limiterConsumptionCtxKey limiterConsumptionKey = "corsoGraphRateLimiterConsumption" const limiterConsumptionCtxKey limiterConsumptionKey = "corsoGraphRateLimiterConsumption"
const ( const (
// https://learn.microsoft.com/en-us/sharepoint/dev/general-development
// /how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online#application-throttling
defaultLC = 1 defaultLC = 1
driveDefaultLC = 2 driveDefaultLC = 2
// limit consumption rate for single-item GETs requests, // limit consumption rate for single-item GETs requests,

View File

@ -7,7 +7,15 @@ import (
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
) )
const AttachmentChunkSize = 4 * 1024 * 1024 const (
AttachmentChunkSize = 4 * 1024 * 1024
// Upper limit on the number of concurrent uploads as we
// create buffer pools for each upload. This is not the actual
// number of uploads, but the max that can be specified. This is
// added as a safeguard in case we misconfigure the values.
maxConccurrentUploads = 20
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// item response AdditionalData // item response AdditionalData
@ -44,6 +52,8 @@ type parallelism struct {
collectionBuffer int collectionBuffer int
// sets the parallelism of item population within a collection. // sets the parallelism of item population within a collection.
item int item int
// sets the parallelism of concurrent uploads within a collection
itemUpload int
} }
func (p parallelism) CollectionBufferSize() int { func (p parallelism) CollectionBufferSize() int {
@ -88,6 +98,18 @@ func (p parallelism) Item() int {
return p.item return p.item
} }
func (p parallelism) ItemUpload() int {
if p.itemUpload == 0 {
return 1
}
if p.itemUpload > maxConccurrentUploads {
return maxConccurrentUploads
}
return p.itemUpload
}
// returns low <= v <= high // returns low <= v <= high
// if high < low, returns low <= v // if high < low, returns low <= v
func isWithin(low, high, v int) bool { func isWithin(low, high, v int) bool {
@ -102,6 +124,7 @@ var sp = map[path.ServiceType]parallelism{
path.OneDriveService: { path.OneDriveService: {
collectionBuffer: 5, collectionBuffer: 5,
item: 4, item: 4,
itemUpload: 7,
}, },
// sharepoint libraries are considered "onedrive" parallelism. // sharepoint libraries are considered "onedrive" parallelism.
// this only controls lists/pages. // this only controls lists/pages.

View File

@ -139,6 +139,7 @@ func IsErrTimeout(err error) bool {
} }
return errors.Is(err, ErrTimeout) || return errors.Is(err, ErrTimeout) ||
errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, http.ErrHandlerTimeout) || errors.Is(err, http.ErrHandlerTimeout) ||
os.IsTimeout(err) os.IsTimeout(err)

View File

@ -13,7 +13,6 @@ import (
msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core" msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core"
"github.com/alcionai/corso/src/internal/common/idname" "github.com/alcionai/corso/src/internal/common/idname"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
) )
@ -41,7 +40,7 @@ func AllMetadataFileNames() []string {
type QueryParams struct { type QueryParams struct {
Category path.CategoryType Category path.CategoryType
ResourceOwner idname.Provider ResourceOwner idname.Provider
Credentials account.M365Config TenantID string
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -439,12 +439,11 @@ func (oc *Collection) populateItems(ctx context.Context, errs *fault.Bus) {
queuedPath = "/" + oc.driveName + queuedPath queuedPath = "/" + oc.driveName + queuedPath
} }
folderProgress, colCloser := observe.ProgressWithCount( folderProgress := observe.ProgressWithCount(
ctx, ctx,
observe.ItemQueueMsg, observe.ItemQueueMsg,
path.NewElements(queuedPath), path.NewElements(queuedPath),
int64(len(oc.driveItems))) int64(len(oc.driveItems)))
defer colCloser()
defer close(folderProgress) defer close(folderProgress)
semaphoreCh := make(chan struct{}, graph.Parallelism(path.OneDriveService).Item()) semaphoreCh := make(chan struct{}, graph.Parallelism(path.OneDriveService).Item())
@ -535,13 +534,12 @@ func (oc *Collection) populateItems(ctx context.Context, errs *fault.Bus) {
} }
// display/log the item download // display/log the item download
progReader, closer := observe.ItemProgress( progReader, _ := observe.ItemProgress(
ctx, ctx,
itemData, itemData,
observe.ItemBackupMsg, observe.ItemBackupMsg,
clues.Hide(itemName+dataSuffix), clues.Hide(itemName+dataSuffix),
itemSize) itemSize)
go closer()
return progReader, nil return progReader, nil
}) })
@ -554,13 +552,12 @@ func (oc *Collection) populateItems(ctx context.Context, errs *fault.Bus) {
} }
metaReader := lazy.NewLazyReadCloser(func() (io.ReadCloser, error) { metaReader := lazy.NewLazyReadCloser(func() (io.ReadCloser, error) {
progReader, closer := observe.ItemProgress( progReader, _ := observe.ItemProgress(
ctx, ctx,
itemMeta, itemMeta,
observe.ItemBackupMsg, observe.ItemBackupMsg,
clues.Hide(itemName+metaSuffix), clues.Hide(itemName+metaSuffix),
int64(itemMetaSize)) int64(itemMetaSize))
go closer()
return progReader, nil return progReader, nil
}) })

View File

@ -283,8 +283,7 @@ func (c *Collections) Get(
driveTombstones[driveID] = struct{}{} driveTombstones[driveID] = struct{}{}
} }
driveComplete, closer := observe.MessageWithCompletion(ctx, observe.Bulletf("files")) driveComplete := observe.MessageWithCompletion(ctx, observe.Bulletf("files"))
defer closer()
defer close(driveComplete) defer close(driveComplete)
// Enumerate drives for the specified resourceOwner // Enumerate drives for the specified resourceOwner

View File

@ -346,27 +346,6 @@ func sharePointItemInfo(di models.DriveItemable, itemSize int64) *details.ShareP
} }
} }
// driveItemWriter is used to initialize and return an io.Writer to upload data for the specified item
// It does so by creating an upload session and using that URL to initialize an `itemWriter`
// TODO: @vkamra verify if var session is the desired input
func driveItemWriter(
ctx context.Context,
gs graph.Servicer,
driveID, itemID string,
itemSize int64,
) (io.Writer, error) {
ctx = clues.Add(ctx, "upload_item_id", itemID)
r, err := api.PostDriveItem(ctx, gs, driveID, itemID)
if err != nil {
return nil, clues.Stack(err)
}
iw := graph.NewLargeItemWriter(itemID, ptr.Val(r.GetUploadUrl()), itemSize)
return iw, nil
}
// constructWebURL helper function for recreating the webURL // constructWebURL helper function for recreating the webURL
// for the originating SharePoint site. Uses additional data map // for the originating SharePoint site. Uses additional data map
// from a models.DriveItemable that possesses a downloadURL within the map. // from a models.DriveItemable that possesses a downloadURL within the map.

View File

@ -189,9 +189,13 @@ func (suite *ItemIntegrationSuite) TestItemWriter() {
// Initialize a 100KB mockDataProvider // Initialize a 100KB mockDataProvider
td, writeSize := mockDataReader(int64(100 * 1024)) td, writeSize := mockDataReader(int64(100 * 1024))
w, err := driveItemWriter(ctx, srv, test.driveID, ptr.Val(newItem.GetId()), writeSize) itemID := ptr.Val(newItem.GetId())
r, err := api.PostDriveItem(ctx, srv, test.driveID, itemID)
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
w := graph.NewLargeItemWriter(itemID, ptr.Val(r.GetUploadUrl()), writeSize)
// Using a 32 KB buffer for the copy allows us to validate the // Using a 32 KB buffer for the copy allows us to validate the
// multi-part upload. `io.CopyBuffer` will only write 32 KB at // multi-part upload. `io.CopyBuffer` will only write 32 KB at
// a time // a time

View File

@ -3,10 +3,13 @@ package onedrive
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"runtime/trace" "runtime/trace"
"sort" "sort"
"strings" "strings"
"sync"
"sync/atomic"
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/pkg/errors" "github.com/pkg/errors"
@ -28,10 +31,10 @@ import (
"github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api"
) )
// copyBufferSize is used for chunked upload const (
// Microsoft recommends 5-10MB buffers // Maximum number of retries for upload failures
// https://docs.microsoft.com/en-us/graph/api/driveitem-createuploadsession?view=graph-rest-1.0#best-practices maxUploadRetries = 3
const copyBufferSize = 5 * 1024 * 1024 )
type restoreCaches struct { type restoreCaches struct {
Folders *folderCache Folders *folderCache
@ -59,6 +62,7 @@ func RestoreCollections(
opts control.Options, opts control.Options,
dcs []data.RestoreCollection, dcs []data.RestoreCollection,
deets *details.Builder, deets *details.Builder,
pool *sync.Pool,
errs *fault.Bus, errs *fault.Bus,
) (*support.ConnectorOperationStatus, error) { ) (*support.ConnectorOperationStatus, error) {
var ( var (
@ -104,6 +108,7 @@ func RestoreCollections(
dest.ContainerName, dest.ContainerName,
deets, deets,
opts.RestorePermissions, opts.RestorePermissions,
pool,
errs) errs)
if err != nil { if err != nil {
el.AddRecoverable(err) el.AddRecoverable(err)
@ -142,13 +147,18 @@ func RestoreCollection(
restoreContainerName string, restoreContainerName string,
deets *details.Builder, deets *details.Builder,
restorePerms bool, restorePerms bool,
pool *sync.Pool,
errs *fault.Bus, errs *fault.Bus,
) (support.CollectionMetrics, error) { ) (support.CollectionMetrics, error) {
var ( var (
metrics = support.CollectionMetrics{} metrics = support.CollectionMetrics{}
copyBuffer = make([]byte, copyBufferSize) directory = dc.FullPath()
directory = dc.FullPath() el = errs.Local()
el = errs.Local() metricsObjects int64
metricsBytes int64
metricsSuccess int64
wg sync.WaitGroup
complete bool
) )
ctx, end := diagnostics.Span(ctx, "gc:drive:restoreCollection", diagnostics.Label("path", directory)) ctx, end := diagnostics.Span(ctx, "gc:drive:restoreCollection", diagnostics.Label("path", directory))
@ -212,8 +222,30 @@ func RestoreCollection(
caches.ParentDirToMeta[dc.FullPath().String()] = colMeta caches.ParentDirToMeta[dc.FullPath().String()] = colMeta
items := dc.Items(ctx, errs) items := dc.Items(ctx, errs)
semaphoreCh := make(chan struct{}, graph.Parallelism(path.OneDriveService).ItemUpload())
defer close(semaphoreCh)
deetsLock := sync.Mutex{}
updateDeets := func(
ctx context.Context,
repoRef path.Path,
locationRef *path.Builder,
updated bool,
info details.ItemInfo,
) {
deetsLock.Lock()
defer deetsLock.Unlock()
err = deets.Add(repoRef, locationRef, updated, info)
if err != nil {
// Not critical enough to need to stop restore operation.
logger.CtxErr(ctx, err).Infow("adding restored item to details")
}
}
for { for {
if el.Failure() != nil { if el.Failure() != nil || complete {
break break
} }
@ -223,62 +255,76 @@ func RestoreCollection(
case itemData, ok := <-items: case itemData, ok := <-items:
if !ok { if !ok {
return metrics, nil // We've processed all items in this collection, exit the loop
complete = true
break
} }
ictx := clues.Add(ctx, "restore_item_id", itemData.UUID()) wg.Add(1)
semaphoreCh <- struct{}{}
itemPath, err := dc.FullPath().AppendItem(itemData.UUID()) go func(ctx context.Context, itemData data.Stream) {
if err != nil { defer wg.Done()
el.AddRecoverable(clues.Wrap(err, "appending item to full path").WithClues(ictx)) defer func() { <-semaphoreCh }()
continue
}
itemInfo, skipped, err := restoreItem( copyBufferPtr := pool.Get().(*[]byte)
ictx, defer pool.Put(copyBufferPtr)
creds,
dc,
backupVersion,
source,
service,
drivePath,
restoreFolderID,
copyBuffer,
caches,
restorePerms,
itemData,
itemPath)
// skipped items don't get counted, but they can error copyBuffer := *copyBufferPtr
if !skipped {
metrics.Objects++
metrics.Bytes += int64(len(copyBuffer))
}
if err != nil { ictx := clues.Add(ctx, "restore_item_id", itemData.UUID())
el.AddRecoverable(clues.Wrap(err, "restoring item"))
continue
}
if skipped { itemPath, err := dc.FullPath().AppendItem(itemData.UUID())
logger.Ctx(ictx).With("item_path", itemPath).Debug("did not restore item") if err != nil {
continue el.AddRecoverable(clues.Wrap(err, "appending item to full path").WithClues(ictx))
} return
}
err = deets.Add( itemInfo, skipped, err := restoreItem(
itemPath, ictx,
&path.Builder{}, // TODO: implement locationRef creds,
true, dc,
itemInfo) backupVersion,
if err != nil { source,
// Not critical enough to need to stop restore operation. service,
logger.CtxErr(ictx, err).Infow("adding restored item to details") drivePath,
} restoreFolderID,
copyBuffer,
caches,
restorePerms,
itemData,
itemPath)
metrics.Successes++ // skipped items don't get counted, but they can error
if !skipped {
atomic.AddInt64(&metricsObjects, 1)
atomic.AddInt64(&metricsBytes, int64(len(copyBuffer)))
}
if err != nil {
el.AddRecoverable(clues.Wrap(err, "restoring item"))
return
}
if skipped {
logger.Ctx(ictx).With("item_path", itemPath).Debug("did not restore item")
return
}
// TODO: implement locationRef
updateDeets(ictx, itemPath, &path.Builder{}, true, itemInfo)
atomic.AddInt64(&metricsSuccess, 1)
}(ctx, itemData)
} }
} }
wg.Wait()
metrics.Objects = int(metricsObjects)
metrics.Bytes = metricsBytes
metrics.Successes = int(metricsSuccess)
return metrics, el.Failure() return metrics, el.Failure()
} }
@ -308,6 +354,7 @@ func restoreItem(
source, source,
service, service,
drivePath, drivePath,
dc,
restoreFolderID, restoreFolderID,
copyBuffer, copyBuffer,
itemData) itemData)
@ -399,6 +446,7 @@ func restoreV0File(
source driveSource, source driveSource,
service graph.Servicer, service graph.Servicer,
drivePath *path.DrivePath, drivePath *path.DrivePath,
fetcher fileFetcher,
restoreFolderID string, restoreFolderID string,
copyBuffer []byte, copyBuffer []byte,
itemData data.Stream, itemData data.Stream,
@ -406,6 +454,7 @@ func restoreV0File(
_, itemInfo, err := restoreData( _, itemInfo, err := restoreData(
ctx, ctx,
service, service,
fetcher,
itemData.UUID(), itemData.UUID(),
itemData, itemData,
drivePath.DriveID, drivePath.DriveID,
@ -442,6 +491,7 @@ func restoreV1File(
itemID, itemInfo, err := restoreData( itemID, itemInfo, err := restoreData(
ctx, ctx,
service, service,
fetcher,
trimmedName, trimmedName,
itemData, itemData,
drivePath.DriveID, drivePath.DriveID,
@ -525,6 +575,7 @@ func restoreV6File(
itemID, itemInfo, err := restoreData( itemID, itemInfo, err := restoreData(
ctx, ctx,
service, service,
fetcher,
meta.FileName, meta.FileName,
itemData, itemData,
drivePath.DriveID, drivePath.DriveID,
@ -673,6 +724,7 @@ func createRestoreFolders(
func restoreData( func restoreData(
ctx context.Context, ctx context.Context,
service graph.Servicer, service graph.Servicer,
fetcher fileFetcher,
name string, name string,
itemData data.Stream, itemData data.Stream,
driveID, parentFolderID string, driveID, parentFolderID string,
@ -696,26 +748,65 @@ func restoreData(
return "", details.ItemInfo{}, err return "", details.ItemInfo{}, err
} }
// Get a drive item writer itemID := ptr.Val(newItem.GetId())
w, err := driveItemWriter(ctx, service, driveID, ptr.Val(newItem.GetId()), ss.Size()) ctx = clues.Add(ctx, "upload_item_id", itemID)
r, err := api.PostDriveItem(ctx, service, driveID, itemID)
if err != nil { if err != nil {
return "", details.ItemInfo{}, err return "", details.ItemInfo{}, clues.Wrap(err, "get upload session")
} }
iReader := itemData.ToReader() var written int64
progReader, closer := observe.ItemProgress(
ctx,
iReader,
observe.ItemRestoreMsg,
clues.Hide(name),
ss.Size())
go closer() // This is just to retry file upload, the uploadSession creation is
// not retried here We need extra logic to retry file upload as we
// have to pull the file again from kopia If we fail a file upload,
// we restart from scratch and try to upload again. Graph does not
// show "register" any partial file uploads and so if we fail an
// upload the file size will be 0.
for i := 0; i <= maxUploadRetries; i++ {
// Initialize and return an io.Writer to upload data for the
// specified item It does so by creating an upload session and
// using that URL to initialize an `itemWriter`
// TODO: @vkamra verify if var session is the desired input
w := graph.NewLargeItemWriter(itemID, ptr.Val(r.GetUploadUrl()), ss.Size())
pname := name
iReader := itemData.ToReader()
if i > 0 {
pname = fmt.Sprintf("%s (retry %d)", name, i)
// If it is not the first try, we have to pull the file
// again from kopia. Ideally we could just seek the stream
// but we don't have a Seeker available here.
itemData, err := fetcher.Fetch(ctx, itemData.UUID())
if err != nil {
return "", details.ItemInfo{}, clues.Wrap(err, "get data file")
}
iReader = itemData.ToReader()
}
progReader, abort := observe.ItemProgress(
ctx,
iReader,
observe.ItemRestoreMsg,
clues.Hide(pname),
ss.Size())
// Upload the stream data
written, err = io.CopyBuffer(w, progReader, copyBuffer)
if err == nil {
break
}
// clear out the bar if err
abort()
}
// Upload the stream data
written, err := io.CopyBuffer(w, progReader, copyBuffer)
if err != nil { if err != nil {
return "", details.ItemInfo{}, graph.Wrap(ctx, err, "writing item bytes") return "", details.ItemInfo{}, clues.Wrap(err, "uploading file")
} }
dii := details.ItemInfo{} dii := details.ItemInfo{}

View File

@ -183,11 +183,10 @@ func (sc *Collection) runPopulate(ctx context.Context, errs *fault.Bus) (support
) )
// TODO: Insert correct ID for CollectionProgress // TODO: Insert correct ID for CollectionProgress
colProgress, closer := observe.CollectionProgress( colProgress := observe.CollectionProgress(
ctx, ctx,
sc.fullPath.Category().String(), sc.fullPath.Category().String(),
sc.fullPath.Folders()) sc.fullPath.Folders())
go closer()
defer func() { defer func() {
close(colProgress) close(colProgress)

View File

@ -61,10 +61,9 @@ func DataCollections(
break break
} }
foldersComplete, closer := observe.MessageWithCompletion( foldersComplete := observe.MessageWithCompletion(
ctx, ctx,
observe.Bulletf("%s", scope.Category().PathType())) observe.Bulletf("%s", scope.Category().PathType()))
defer closer()
defer close(foldersComplete) defer close(foldersComplete)
var spcs []data.BackupCollection var spcs []data.BackupCollection

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"runtime/trace" "runtime/trace"
"sync"
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models" "github.com/microsoftgraph/msgraph-sdk-go/models"
@ -48,6 +49,7 @@ func RestoreCollections(
opts control.Options, opts control.Options,
dcs []data.RestoreCollection, dcs []data.RestoreCollection,
deets *details.Builder, deets *details.Builder,
pool *sync.Pool,
errs *fault.Bus, errs *fault.Bus,
) (*support.ConnectorOperationStatus, error) { ) (*support.ConnectorOperationStatus, error) {
var ( var (
@ -90,6 +92,7 @@ func RestoreCollections(
dest.ContainerName, dest.ContainerName,
deets, deets,
opts.RestorePermissions, opts.RestorePermissions,
pool,
errs) errs)
case path.ListsCategory: case path.ListsCategory:

View File

@ -13,8 +13,40 @@ import (
"github.com/alcionai/corso/src/internal/operations/inject" "github.com/alcionai/corso/src/internal/operations/inject"
"github.com/alcionai/corso/src/pkg/backup" "github.com/alcionai/corso/src/pkg/backup"
"github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path"
) )
const (
// Kopia does not do comparisons properly for empty tags right now so add some
// placeholder value to them.
defaultTagValue = "0"
// Kopia CLI prefixes all user tags with "tag:"[1]. Maintaining this will
// ensure we don't accidentally take reserved tags and that tags can be
// displayed with kopia CLI.
// (permalinks)
// [1] https://github.com/kopia/kopia/blob/05e729a7858a6e86cb48ba29fb53cb6045efce2b/cli/command_snapshot_create.go#L169
userTagPrefix = "tag:"
)
type Reason struct {
ResourceOwner string
Service path.ServiceType
Category path.CategoryType
}
func (r Reason) TagKeys() []string {
return []string{
r.ResourceOwner,
serviceCatString(r.Service, r.Category),
}
}
// Key is the concatenation of the ResourceOwner, Service, and Category.
func (r Reason) Key() string {
return r.ResourceOwner + r.Service.String() + r.Category.String()
}
type backupBases struct { type backupBases struct {
backups []BackupEntry backups []BackupEntry
mergeBases []ManifestEntry mergeBases []ManifestEntry
@ -26,6 +58,63 @@ type BackupEntry struct {
Reasons []Reason Reasons []Reason
} }
type ManifestEntry struct {
*snapshot.Manifest
// Reason contains the ResourceOwners and Service/Categories that caused this
// snapshot to be selected as a base. We can't reuse OwnersCats here because
// it's possible some ResourceOwners will have a subset of the Categories as
// the reason for selecting a snapshot. For example:
// 1. backup user1 email,contacts -> B1
// 2. backup user1 contacts -> B2 (uses B1 as base)
// 3. backup user1 email,contacts,events (uses B1 for email, B2 for contacts)
Reasons []Reason
}
func (me ManifestEntry) GetTag(key string) (string, bool) {
k, _ := makeTagKV(key)
v, ok := me.Tags[k]
return v, ok
}
type snapshotManager interface {
FindManifests(
ctx context.Context,
tags map[string]string,
) ([]*manifest.EntryMetadata, error)
LoadSnapshot(ctx context.Context, id manifest.ID) (*snapshot.Manifest, error)
}
func serviceCatString(s path.ServiceType, c path.CategoryType) string {
return s.String() + c.String()
}
// MakeTagKV normalizes the provided key to protect it from clobbering
// similarly named tags from non-user input (user inputs are still open
// to collisions amongst eachother).
// Returns the normalized Key plus a default value. If you're embedding a
// key-only tag, the returned default value msut be used instead of an
// empty string.
func makeTagKV(k string) (string, string) {
return userTagPrefix + k, defaultTagValue
}
func normalizeTagKVs(tags map[string]string) map[string]string {
t2 := make(map[string]string, len(tags))
for k, v := range tags {
mk, mv := makeTagKV(k)
if len(v) == 0 {
v = mv
}
t2[mk] = v
}
return t2
}
type baseFinder struct { type baseFinder struct {
sm snapshotManager sm snapshotManager
bg inject.GetBackuper bg inject.GetBackuper

View File

@ -27,11 +27,9 @@ const (
var ( var (
testT1 = time.Now() testT1 = time.Now()
testT2 = testT1.Add(1 * time.Hour) testT2 = testT1.Add(1 * time.Hour)
testT3 = testT2.Add(1 * time.Hour)
testID1 = manifest.ID("snap1") testID1 = manifest.ID("snap1")
testID2 = manifest.ID("snap2") testID2 = manifest.ID("snap2")
testID3 = manifest.ID("snap3")
testBackup1 = "backupID1" testBackup1 = "backupID1"
testBackup2 = "backupID2" testBackup2 = "backupID2"

View File

@ -418,18 +418,6 @@ func (w *conn) LoadSnapshot(
return man, nil return man, nil
} }
func (w *conn) LoadSnapshots(
ctx context.Context,
ids []manifest.ID,
) ([]*snapshot.Manifest, error) {
mans, err := snapshot.LoadSnapshots(ctx, w.Repository, ids)
if err != nil {
return nil, clues.Stack(err).WithClues(ctx)
}
return mans, nil
}
func (w *conn) SnapshotRoot(man *snapshot.Manifest) (fs.Entry, error) { func (w *conn) SnapshotRoot(man *snapshot.Manifest) (fs.Entry, error) {
return snapshotfs.SnapshotRoot(w.Repository, man) return snapshotfs.SnapshotRoot(w.Repository, man)
} }

View File

@ -1,307 +0,0 @@
package kopia
import (
"context"
"sort"
"github.com/alcionai/clues"
"github.com/kopia/kopia/repo/manifest"
"github.com/kopia/kopia/snapshot"
"golang.org/x/exp/maps"
"github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path"
)
const (
// Kopia does not do comparisons properly for empty tags right now so add some
// placeholder value to them.
defaultTagValue = "0"
// Kopia CLI prefixes all user tags with "tag:"[1]. Maintaining this will
// ensure we don't accidentally take reserved tags and that tags can be
// displayed with kopia CLI.
// (permalinks)
// [1] https://github.com/kopia/kopia/blob/05e729a7858a6e86cb48ba29fb53cb6045efce2b/cli/command_snapshot_create.go#L169
userTagPrefix = "tag:"
)
type Reason struct {
ResourceOwner string
Service path.ServiceType
Category path.CategoryType
}
func (r Reason) TagKeys() []string {
return []string{
r.ResourceOwner,
serviceCatString(r.Service, r.Category),
}
}
// Key is the concatenation of the ResourceOwner, Service, and Category.
func (r Reason) Key() string {
return r.ResourceOwner + r.Service.String() + r.Category.String()
}
type ManifestEntry struct {
*snapshot.Manifest
// Reason contains the ResourceOwners and Service/Categories that caused this
// snapshot to be selected as a base. We can't reuse OwnersCats here because
// it's possible some ResourceOwners will have a subset of the Categories as
// the reason for selecting a snapshot. For example:
// 1. backup user1 email,contacts -> B1
// 2. backup user1 contacts -> B2 (uses B1 as base)
// 3. backup user1 email,contacts,events (uses B1 for email, B2 for contacts)
Reasons []Reason
}
func (me ManifestEntry) GetTag(key string) (string, bool) {
k, _ := makeTagKV(key)
v, ok := me.Tags[k]
return v, ok
}
type snapshotManager interface {
FindManifests(
ctx context.Context,
tags map[string]string,
) ([]*manifest.EntryMetadata, error)
LoadSnapshot(ctx context.Context, id manifest.ID) (*snapshot.Manifest, error)
// TODO(ashmrtn): Remove this when we switch to the new BaseFinder.
LoadSnapshots(ctx context.Context, ids []manifest.ID) ([]*snapshot.Manifest, error)
}
func serviceCatString(s path.ServiceType, c path.CategoryType) string {
return s.String() + c.String()
}
// MakeTagKV normalizes the provided key to protect it from clobbering
// similarly named tags from non-user input (user inputs are still open
// to collisions amongst eachother).
// Returns the normalized Key plus a default value. If you're embedding a
// key-only tag, the returned default value msut be used instead of an
// empty string.
func makeTagKV(k string) (string, string) {
return userTagPrefix + k, defaultTagValue
}
// getLastIdx searches for manifests contained in both foundMans and metas
// and returns the most recent complete manifest index and the manifest it
// corresponds to. If no complete manifest is in both lists returns nil, -1.
func getLastIdx(
foundMans map[manifest.ID]*ManifestEntry,
metas []*manifest.EntryMetadata,
) (*ManifestEntry, int) {
// Minor optimization: the current code seems to return the entries from
// earliest timestamp to latest (this is undocumented). Sort in the same
// fashion so that we don't incur a bunch of swaps.
sort.Slice(metas, func(i, j int) bool {
return metas[i].ModTime.Before(metas[j].ModTime)
})
// Search newest to oldest.
for i := len(metas) - 1; i >= 0; i-- {
m := foundMans[metas[i].ID]
if m == nil || len(m.IncompleteReason) > 0 {
continue
}
return m, i
}
return nil, -1
}
// manifestsSinceLastComplete searches through mans and returns the most recent
// complete manifest (if one exists), maybe the most recent incomplete
// manifest, and a bool denoting if a complete manifest was found. If the newest
// incomplete manifest is more recent than the newest complete manifest then
// adds it to the returned list. Otherwise no incomplete manifest is returned.
// Returns nil if there are no complete or incomplete manifests in mans.
func manifestsSinceLastComplete(
ctx context.Context,
mans []*snapshot.Manifest,
) ([]*snapshot.Manifest, bool) {
var (
res []*snapshot.Manifest
foundIncomplete bool
foundComplete bool
)
// Manifests should maintain the sort order of the original IDs that were used
// to fetch the data, but just in case sort oldest to newest.
mans = snapshot.SortByTime(mans, false)
for i := len(mans) - 1; i >= 0; i-- {
m := mans[i]
if len(m.IncompleteReason) > 0 {
if !foundIncomplete {
res = append(res, m)
foundIncomplete = true
logger.Ctx(ctx).Infow("found incomplete snapshot", "snapshot_id", m.ID)
}
continue
}
// Once we find a complete snapshot we're done, even if we haven't
// found an incomplete one yet.
res = append(res, m)
foundComplete = true
logger.Ctx(ctx).Infow("found complete snapshot", "snapshot_id", m.ID)
break
}
return res, foundComplete
}
// fetchPrevManifests returns the most recent, as-of-yet unfound complete and
// (maybe) incomplete manifests in metas. If the most recent incomplete manifest
// is older than the most recent complete manifest no incomplete manifest is
// returned. If only incomplete manifests exists, returns the most recent one.
// Returns no manifests if an error occurs.
func fetchPrevManifests(
ctx context.Context,
sm snapshotManager,
foundMans map[manifest.ID]*ManifestEntry,
reason Reason,
tags map[string]string,
) ([]*snapshot.Manifest, error) {
allTags := map[string]string{}
for _, k := range reason.TagKeys() {
allTags[k] = ""
}
maps.Copy(allTags, tags)
allTags = normalizeTagKVs(allTags)
metas, err := sm.FindManifests(ctx, allTags)
if err != nil {
return nil, clues.Wrap(err, "fetching manifest metas by tag")
}
if len(metas) == 0 {
return nil, nil
}
man, lastCompleteIdx := getLastIdx(foundMans, metas)
// We have a complete cached snapshot and it's the most recent. No need
// to do anything else.
if lastCompleteIdx == len(metas)-1 {
return []*snapshot.Manifest{man.Manifest}, nil
}
// TODO(ashmrtn): Remainder of the function can be simplified if we can inject
// different tags to the snapshot checkpoints than the complete snapshot.
// Fetch all manifests newer than the oldest complete snapshot. A little
// wasteful as we may also re-fetch the most recent incomplete manifest, but
// it reduces the complexity of returning the most recent incomplete manifest
// if it is newer than the most recent complete manifest.
ids := make([]manifest.ID, 0, len(metas)-(lastCompleteIdx+1))
for i := lastCompleteIdx + 1; i < len(metas); i++ {
ids = append(ids, metas[i].ID)
}
mans, err := sm.LoadSnapshots(ctx, ids)
if err != nil {
return nil, clues.Wrap(err, "fetching previous manifests")
}
found, hasCompleted := manifestsSinceLastComplete(ctx, mans)
// If we didn't find another complete manifest then we need to mark the
// previous complete manifest as having this ResourceOwner, Service, Category
// as the reason as well.
if !hasCompleted && man != nil {
found = append(found, man.Manifest)
logger.Ctx(ctx).Infow(
"reusing cached complete snapshot",
"snapshot_id", man.ID)
}
return found, nil
}
// fetchPrevSnapshotManifests returns a set of manifests for complete and maybe
// incomplete snapshots for the given (resource owner, service, category)
// tuples. Up to two manifests can be returned per tuple: one complete and one
// incomplete. An incomplete manifest may be returned if it is newer than the
// newest complete manifest for the tuple. Manifests are deduped such that if
// multiple tuples match the same manifest it will only be returned once.
// External callers can access this via wrapper.FetchPrevSnapshotManifests().
// If tags are provided, manifests must include a superset of the k:v pairs
// specified by those tags. Tags should pass their raw values, and will be
// normalized inside the func using MakeTagKV.
func fetchPrevSnapshotManifests(
ctx context.Context,
sm snapshotManager,
reasons []Reason,
tags map[string]string,
) []*ManifestEntry {
mans := map[manifest.ID]*ManifestEntry{}
// For each serviceCat/resource owner pair that we will be backing up, see if
// there's a previous incomplete snapshot and/or a previous complete snapshot
// we can pass in. Can be expanded to return more than the most recent
// snapshots, but may require more memory at runtime.
for _, reason := range reasons {
ictx := clues.Add(ctx, "service", reason.Service.String(), "category", reason.Category.String())
logger.Ctx(ictx).Info("searching for previous manifests for reason")
found, err := fetchPrevManifests(ictx, sm, mans, reason, tags)
if err != nil {
logger.CtxErr(ictx, err).Info("fetching previous snapshot manifests for service/category/resource owner")
// Snapshot can still complete fine, just not as efficient.
continue
}
// If we found more recent snapshots then add them.
for _, m := range found {
man := mans[m.ID]
if man == nil {
mans[m.ID] = &ManifestEntry{
Manifest: m,
Reasons: []Reason{reason},
}
continue
}
// This manifest has multiple reasons for being chosen. Merge them here.
man.Reasons = append(man.Reasons, reason)
}
}
res := make([]*ManifestEntry, 0, len(mans))
for _, m := range mans {
res = append(res, m)
}
return res
}
func normalizeTagKVs(tags map[string]string) map[string]string {
t2 := make(map[string]string, len(tags))
for k, v := range tags {
mk, mv := makeTagKV(k)
if len(v) == 0 {
v = mv
}
t2[mk] = v
}
return t2
}

View File

@ -1,932 +0,0 @@
package kopia
import (
"context"
"testing"
"time"
"github.com/alcionai/clues"
"github.com/kopia/kopia/fs"
"github.com/kopia/kopia/repo/manifest"
"github.com/kopia/kopia/snapshot"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/path"
)
func newManifestInfo(
id manifest.ID,
modTime time.Time,
incomplete bool,
tags ...string,
) manifestInfo {
incompleteStr := ""
if incomplete {
incompleteStr = "checkpoint"
}
structTags := make(map[string]string, len(tags))
for _, t := range tags {
tk, _ := makeTagKV(t)
structTags[tk] = ""
}
return manifestInfo{
tags: structTags,
metadata: &manifest.EntryMetadata{
ID: id,
ModTime: modTime,
},
man: &snapshot.Manifest{
ID: id,
StartTime: fs.UTCTimestamp(modTime.UnixNano()),
IncompleteReason: incompleteStr,
},
}
}
type mockSnapshotManager struct {
data []manifestInfo
loadCallback func(ids []manifest.ID)
}
func matchesTags(mi manifestInfo, tags map[string]string) bool {
for k := range tags {
if _, ok := mi.tags[k]; !ok {
return false
}
}
return true
}
func (msm *mockSnapshotManager) FindManifests(
ctx context.Context,
tags map[string]string,
) ([]*manifest.EntryMetadata, error) {
if msm == nil {
return nil, assert.AnError
}
res := []*manifest.EntryMetadata{}
for _, mi := range msm.data {
if matchesTags(mi, tags) {
res = append(res, mi.metadata)
}
}
return res, nil
}
func (msm *mockSnapshotManager) LoadSnapshots(
ctx context.Context,
ids []manifest.ID,
) ([]*snapshot.Manifest, error) {
if msm == nil {
return nil, assert.AnError
}
// Allow checking set of IDs passed in.
if msm.loadCallback != nil {
msm.loadCallback(ids)
}
res := []*snapshot.Manifest{}
for _, id := range ids {
for _, mi := range msm.data {
if mi.man.ID == id {
res = append(res, mi.man)
}
}
}
return res, nil
}
func (msm *mockSnapshotManager) LoadSnapshot(
ctx context.Context,
id manifest.ID,
) (*snapshot.Manifest, error) {
return nil, clues.New("not implemented")
}
type SnapshotFetchUnitSuite struct {
tester.Suite
}
func TestSnapshotFetchUnitSuite(t *testing.T) {
suite.Run(t, &SnapshotFetchUnitSuite{Suite: tester.NewUnitSuite(t)})
}
func (suite *SnapshotFetchUnitSuite) TestFetchPrevSnapshots() {
table := []struct {
name string
input []Reason
data []manifestInfo
// Use this to denote which manifests in data should be expected. Allows
// defining data in a table while not repeating things between data and
// expected.
expectedIdxs []int
// Use this to denote the Reasons a manifest is selected. The int maps to
// the index of the manifest in data.
expectedReasons map[int][]Reason
// Expected number of times a manifest should try to be loaded from kopia.
// Used to check that caching is functioning properly.
expectedLoadCounts map[manifest.ID]int
}{
{
name: "AllOneSnapshot",
input: testAllUsersAllCats,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testCompleteMan,
testMail,
testEvents,
testUser1,
testUser2,
testUser3,
),
},
expectedIdxs: []int{0},
expectedReasons: map[int][]Reason{
0: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EventsCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EventsCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EventsCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 1,
},
},
{
name: "SplitByCategory",
input: testAllUsersAllCats,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testCompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
newManifestInfo(
testID2,
testT2,
testCompleteMan,
testEvents,
testUser1,
testUser2,
testUser3,
),
},
expectedIdxs: []int{0, 1},
expectedReasons: map[int][]Reason{
0: {
{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
1: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EventsCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EventsCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EventsCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 1,
testID2: 1,
},
},
{
name: "IncompleteNewerThanComplete",
input: testAllUsersMail,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testCompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
newManifestInfo(
testID2,
testT2,
testIncompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
},
expectedIdxs: []int{0, 1},
expectedReasons: map[int][]Reason{
0: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
1: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 1,
testID2: 3,
},
},
{
name: "IncompleteOlderThanComplete",
input: testAllUsersMail,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testIncompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
newManifestInfo(
testID2,
testT2,
testCompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
},
expectedIdxs: []int{1},
expectedReasons: map[int][]Reason{
1: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 1,
testID2: 1,
},
},
{
name: "OnlyIncomplete",
input: testAllUsersMail,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testIncompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
},
expectedIdxs: []int{0},
expectedReasons: map[int][]Reason{
0: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 3,
},
},
{
name: "NewestComplete",
input: testAllUsersMail,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testCompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
newManifestInfo(
testID2,
testT2,
testCompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
},
expectedIdxs: []int{1},
expectedReasons: map[int][]Reason{
1: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 1,
testID2: 1,
},
},
{
name: "NewestIncomplete",
input: testAllUsersMail,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testIncompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
newManifestInfo(
testID2,
testT2,
testIncompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
},
expectedIdxs: []int{1},
expectedReasons: map[int][]Reason{
1: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 3,
testID2: 3,
},
},
{
name: "SomeCachedSomeNewer",
input: testAllUsersMail,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testCompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
newManifestInfo(
testID2,
testT2,
testCompleteMan,
testMail,
testUser3,
),
},
expectedIdxs: []int{0, 1},
expectedReasons: map[int][]Reason{
0: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
1: {
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 2,
testID2: 1,
},
},
{
name: "SomeCachedSomeNewerDifferentCategories",
input: testAllUsersAllCats,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testCompleteMan,
testMail,
testEvents,
testUser1,
testUser2,
testUser3,
),
newManifestInfo(
testID2,
testT2,
testCompleteMan,
testMail,
testUser3,
),
},
expectedIdxs: []int{0, 1},
expectedReasons: map[int][]Reason{
0: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EventsCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EventsCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EventsCategory,
},
},
1: {
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 2,
testID2: 1,
},
},
{
name: "SomeCachedSomeNewerIncomplete",
input: testAllUsersMail,
data: []manifestInfo{
newManifestInfo(
testID1,
testT1,
testCompleteMan,
testMail,
testUser1,
testUser2,
testUser3,
),
newManifestInfo(
testID2,
testT2,
testIncompleteMan,
testMail,
testUser3,
),
},
expectedIdxs: []int{0, 1},
expectedReasons: map[int][]Reason{
0: {
Reason{
ResourceOwner: testUser1,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser2,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
1: {
Reason{
ResourceOwner: testUser3,
Service: path.ExchangeService,
Category: path.EmailCategory,
},
},
},
expectedLoadCounts: map[manifest.ID]int{
testID1: 1,
testID2: 1,
},
},
{
name: "NoMatches",
input: testAllUsersMail,
data: nil,
expectedIdxs: nil,
// Stop failure for nil-map comparison.
expectedLoadCounts: map[manifest.ID]int{},
},
}
for _, test := range table {
suite.Run(test.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
msm := &mockSnapshotManager{
data: test.data,
}
loadCounts := map[manifest.ID]int{}
msm.loadCallback = func(ids []manifest.ID) {
for _, id := range ids {
loadCounts[id]++
}
}
snaps := fetchPrevSnapshotManifests(ctx, msm, test.input, nil)
// Check the proper snapshot manifests were returned.
expected := make([]*snapshot.Manifest, 0, len(test.expectedIdxs))
for _, i := range test.expectedIdxs {
expected = append(expected, test.data[i].man)
}
got := make([]*snapshot.Manifest, 0, len(snaps))
for _, s := range snaps {
got = append(got, s.Manifest)
}
assert.ElementsMatch(t, expected, got)
// Check the reasons for selecting each manifest are correct.
expectedReasons := make(map[manifest.ID][]Reason, len(test.expectedReasons))
for idx, reason := range test.expectedReasons {
expectedReasons[test.data[idx].man.ID] = reason
}
for _, found := range snaps {
reason, ok := expectedReasons[found.ID]
if !ok {
// Missing or extra snapshots will be reported by earlier checks.
continue
}
assert.ElementsMatch(
t,
reason,
found.Reasons,
"incorrect reasons for snapshot with ID %s",
found.ID,
)
}
// Check number of loads to make sure caching is working properly.
// Need to manually check because we don't know the order the
// user/service/category labels will be iterated over. For some tests this
// could cause more loads than the ideal case.
assert.Len(t, loadCounts, len(test.expectedLoadCounts))
for id, count := range loadCounts {
assert.GreaterOrEqual(t, test.expectedLoadCounts[id], count)
}
})
}
}
func (suite *SnapshotFetchUnitSuite) TestFetchPrevSnapshots_customTags() {
data := []manifestInfo{
newManifestInfo(
testID1,
testT1,
false,
testMail,
testUser1,
"fnords",
"smarf",
),
}
expectLoad1T1 := map[manifest.ID]int{
testID1: 1,
}
table := []struct {
name string
input []Reason
tags map[string]string
// Use this to denote which manifests in data should be expected. Allows
// defining data in a table while not repeating things between data and
// expected.
expectedIdxs []int
// Expected number of times a manifest should try to be loaded from kopia.
// Used to check that caching is functioning properly.
expectedLoadCounts map[manifest.ID]int
}{
{
name: "no tags specified",
tags: nil,
expectedIdxs: []int{0},
expectedLoadCounts: expectLoad1T1,
},
{
name: "all custom tags",
tags: map[string]string{
"fnords": "",
"smarf": "",
},
expectedIdxs: []int{0},
expectedLoadCounts: expectLoad1T1,
},
{
name: "subset of custom tags",
tags: map[string]string{"fnords": ""},
expectedIdxs: []int{0},
expectedLoadCounts: expectLoad1T1,
},
{
name: "custom tag mismatch",
tags: map[string]string{"bojangles": ""},
expectedIdxs: nil,
expectedLoadCounts: nil,
},
}
for _, test := range table {
suite.Run(test.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
msm := &mockSnapshotManager{
data: data,
}
loadCounts := map[manifest.ID]int{}
msm.loadCallback = func(ids []manifest.ID) {
for _, id := range ids {
loadCounts[id]++
}
}
snaps := fetchPrevSnapshotManifests(ctx, msm, testAllUsersAllCats, test.tags)
expected := make([]*snapshot.Manifest, 0, len(test.expectedIdxs))
for _, i := range test.expectedIdxs {
expected = append(expected, data[i].man)
}
got := make([]*snapshot.Manifest, 0, len(snaps))
for _, s := range snaps {
got = append(got, s.Manifest)
}
assert.ElementsMatch(t, expected, got)
// Need to manually check because we don't know the order the
// user/service/category labels will be iterated over. For some tests this
// could cause more loads than the ideal case.
assert.Len(t, loadCounts, len(test.expectedLoadCounts))
for id, count := range loadCounts {
assert.GreaterOrEqual(t, test.expectedLoadCounts[id], count)
}
})
}
}
// mockErrorSnapshotManager returns an error the first time LoadSnapshot and
// FindSnapshot are called. After that it passes the calls through to the
// contained snapshotManager.
type mockErrorSnapshotManager struct {
retFindErr bool
retLoadErr bool
sm snapshotManager
}
func (msm *mockErrorSnapshotManager) FindManifests(
ctx context.Context,
tags map[string]string,
) ([]*manifest.EntryMetadata, error) {
if !msm.retFindErr {
msm.retFindErr = true
return nil, assert.AnError
}
return msm.sm.FindManifests(ctx, tags)
}
func (msm *mockErrorSnapshotManager) LoadSnapshots(
ctx context.Context,
ids []manifest.ID,
) ([]*snapshot.Manifest, error) {
if !msm.retLoadErr {
msm.retLoadErr = true
return nil, assert.AnError
}
return msm.sm.LoadSnapshots(ctx, ids)
}
func (msm *mockErrorSnapshotManager) LoadSnapshot(
ctx context.Context,
id manifest.ID,
) (*snapshot.Manifest, error) {
return nil, clues.New("not implemented")
}
func (suite *SnapshotFetchUnitSuite) TestFetchPrevSnapshots_withErrors() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
input := testAllUsersMail
mockData := []manifestInfo{
newManifestInfo(
testID1,
testT1,
testCompleteMan,
testMail,
testUser1,
),
newManifestInfo(
testID2,
testT2,
testCompleteMan,
testMail,
testUser2,
),
newManifestInfo(
testID3,
testT3,
testCompleteMan,
testMail,
testUser3,
),
}
msm := &mockErrorSnapshotManager{
sm: &mockSnapshotManager{
data: mockData,
},
}
snaps := fetchPrevSnapshotManifests(ctx, msm, input, nil)
// Only 1 snapshot should be chosen because the other two attempts fail.
// However, which one is returned is non-deterministic because maps are used.
assert.Len(t, snaps, 1)
}

View File

@ -596,27 +596,6 @@ func (w Wrapper) DeleteSnapshot(
return nil return nil
} }
// FetchPrevSnapshotManifests returns a set of manifests for complete and maybe
// incomplete snapshots for the given (resource owner, service, category)
// tuples. Up to two manifests can be returned per tuple: one complete and one
// incomplete. An incomplete manifest may be returned if it is newer than the
// newest complete manifest for the tuple. Manifests are deduped such that if
// multiple tuples match the same manifest it will only be returned once.
// If tags are provided, manifests must include a superset of the k:v pairs
// specified by those tags. Tags should pass their raw values, and will be
// normalized inside the func using MakeTagKV.
func (w Wrapper) FetchPrevSnapshotManifests(
ctx context.Context,
reasons []Reason,
tags map[string]string,
) ([]*ManifestEntry, error) {
if w.c == nil {
return nil, clues.Stack(errNotConnected).WithClues(ctx)
}
return fetchPrevSnapshotManifests(ctx, w.c, reasons, tags), nil
}
func (w Wrapper) NewBaseFinder(bg inject.GetBackuper) (*baseFinder, error) { func (w Wrapper) NewBaseFinder(bg inject.GetBackuper) (*baseFinder, error) {
return newBaseFinder(w.c, bg) return newBaseFinder(w.c, bg)
} }

View File

@ -1101,14 +1101,8 @@ func (suite *KopiaSimpleRepoIntegrationSuite) TestBackupExcludeItem() {
subtreePath := subtreePathTmp.ToBuilder().Dir() subtreePath := subtreePathTmp.ToBuilder().Dir()
manifests, err := suite.w.FetchPrevSnapshotManifests( man, err := suite.w.c.LoadSnapshot(suite.ctx, suite.snapshotID)
suite.ctx, require.NoError(suite.T(), err, "getting base snapshot: %v", clues.ToCore(err))
[]Reason{reason},
nil,
)
require.NoError(suite.T(), err, clues.ToCore(err))
require.Len(suite.T(), manifests, 1)
require.Equal(suite.T(), suite.snapshotID, manifests[0].ID)
tags := map[string]string{} tags := map[string]string{}
@ -1206,7 +1200,7 @@ func (suite *KopiaSimpleRepoIntegrationSuite) TestBackupExcludeItem() {
suite.ctx, suite.ctx,
[]IncrementalBase{ []IncrementalBase{
{ {
Manifest: manifests[0].Manifest, Manifest: man,
SubtreePaths: []*path.Builder{ SubtreePaths: []*path.Builder{
subtreePath, subtreePath,
}, },

View File

@ -180,7 +180,7 @@ func Message(ctx context.Context, msgs ...any) {
func MessageWithCompletion( func MessageWithCompletion(
ctx context.Context, ctx context.Context,
msg any, msg any,
) (chan<- struct{}, func()) { ) chan<- struct{} {
var ( var (
plain = plainString(msg) plain = plainString(msg)
loggable = fmt.Sprintf("%v", msg) loggable = fmt.Sprintf("%v", msg)
@ -191,7 +191,8 @@ func MessageWithCompletion(
log.Info(loggable) log.Info(loggable)
if cfg.hidden() { if cfg.hidden() {
return ch, func() { log.Info("done - " + loggable) } defer log.Info("done - " + loggable)
return ch
} }
wg.Add(1) wg.Add(1)
@ -219,11 +220,11 @@ func MessageWithCompletion(
bar.SetTotal(-1, true) bar.SetTotal(-1, true)
}) })
wacb := waitAndCloseBar(bar, func() { go waitAndCloseBar(bar, func() {
log.Info("done - " + loggable) log.Info("done - " + loggable)
}) })()
return ch, wacb return ch
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -247,7 +248,8 @@ func ItemProgress(
log.Debug(header) log.Debug(header)
if cfg.hidden() || rc == nil || totalBytes == 0 { if cfg.hidden() || rc == nil || totalBytes == 0 {
return rc, func() { log.Debug("done - " + header) } defer log.Debug("done - " + header)
return rc, func() {}
} }
wg.Add(1) wg.Add(1)
@ -266,12 +268,17 @@ func ItemProgress(
bar := progress.New(totalBytes, mpb.NopStyle(), barOpts...) bar := progress.New(totalBytes, mpb.NopStyle(), barOpts...)
wacb := waitAndCloseBar(bar, func() { go waitAndCloseBar(bar, func() {
// might be overly chatty, we can remove if needed. // might be overly chatty, we can remove if needed.
log.Debug("done - " + header) log.Debug("done - " + header)
}) })()
return bar.ProxyReader(rc), wacb abort := func() {
bar.SetTotal(-1, true)
bar.Abort(true)
}
return bar.ProxyReader(rc), abort
} }
// ProgressWithCount tracks the display of a bar that tracks the completion // ProgressWithCount tracks the display of a bar that tracks the completion
@ -283,7 +290,7 @@ func ProgressWithCount(
header string, header string,
msg any, msg any,
count int64, count int64,
) (chan<- struct{}, func()) { ) chan<- struct{} {
var ( var (
plain = plainString(msg) plain = plainString(msg)
loggable = fmt.Sprintf("%s %v - %d", header, msg, count) loggable = fmt.Sprintf("%s %v - %d", header, msg, count)
@ -295,7 +302,10 @@ func ProgressWithCount(
if cfg.hidden() { if cfg.hidden() {
go listen(ctx, ch, nop, nop) go listen(ctx, ch, nop, nop)
return ch, func() { log.Info("done - " + loggable) }
defer log.Info("done - " + loggable)
return ch
} }
wg.Add(1) wg.Add(1)
@ -319,11 +329,11 @@ func ProgressWithCount(
func() { bar.Abort(true) }, func() { bar.Abort(true) },
bar.Increment) bar.Increment)
wacb := waitAndCloseBar(bar, func() { go waitAndCloseBar(bar, func() {
log.Info("done - " + loggable) log.Info("done - " + loggable)
}) })()
return ch, wacb return ch
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -365,7 +375,7 @@ func CollectionProgress(
ctx context.Context, ctx context.Context,
category string, category string,
dirName any, dirName any,
) (chan<- struct{}, func()) { ) chan<- struct{} {
var ( var (
counted int counted int
plain = plainString(dirName) plain = plainString(dirName)
@ -388,7 +398,10 @@ func CollectionProgress(
if cfg.hidden() || len(plain) == 0 { if cfg.hidden() || len(plain) == 0 {
go listen(ctx, ch, nop, incCount) go listen(ctx, ch, nop, incCount)
return ch, func() { log.Infow("done - "+message, "count", counted) }
defer log.Infow("done - "+message, "count", counted)
return ch
} }
wg.Add(1) wg.Add(1)
@ -420,18 +433,21 @@ func CollectionProgress(
bar.Increment() bar.Increment()
}) })
wacb := waitAndCloseBar(bar, func() { go waitAndCloseBar(bar, func() {
log.Infow("done - "+message, "count", counted) log.Infow("done - "+message, "count", counted)
}) })()
return ch, wacb return ch
} }
func waitAndCloseBar(bar *mpb.Bar, log func()) func() { func waitAndCloseBar(bar *mpb.Bar, log func()) func() {
return func() { return func() {
bar.Wait() bar.Wait()
wg.Done() wg.Done()
log()
if !bar.Aborted() {
log()
}
} }
} }

View File

@ -51,16 +51,14 @@ func (suite *ObserveProgressUnitSuite) TestItemProgress() {
}() }()
from := make([]byte, 100) from := make([]byte, 100)
prog, closer := ItemProgress( prog, abort := ItemProgress(
ctx, ctx,
io.NopCloser(bytes.NewReader(from)), io.NopCloser(bytes.NewReader(from)),
"folder", "folder",
tst, tst,
100) 100)
require.NotNil(t, prog) require.NotNil(t, prog)
require.NotNil(t, closer) require.NotNil(t, abort)
defer closer()
var i int var i int
@ -105,9 +103,8 @@ func (suite *ObserveProgressUnitSuite) TestCollectionProgress_unblockOnCtxCancel
SeedWriter(context.Background(), nil, nil) SeedWriter(context.Background(), nil, nil)
}() }()
progCh, closer := CollectionProgress(ctx, testcat, testertons) progCh := CollectionProgress(ctx, testcat, testertons)
require.NotNil(t, progCh) require.NotNil(t, progCh)
require.NotNil(t, closer)
defer close(progCh) defer close(progCh)
@ -119,9 +116,6 @@ func (suite *ObserveProgressUnitSuite) TestCollectionProgress_unblockOnCtxCancel
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
cancel() cancel()
}() }()
// blocks, but should resolve due to the ctx cancel
closer()
} }
func (suite *ObserveProgressUnitSuite) TestCollectionProgress_unblockOnChannelClose() { func (suite *ObserveProgressUnitSuite) TestCollectionProgress_unblockOnChannelClose() {
@ -140,9 +134,8 @@ func (suite *ObserveProgressUnitSuite) TestCollectionProgress_unblockOnChannelCl
SeedWriter(context.Background(), nil, nil) SeedWriter(context.Background(), nil, nil)
}() }()
progCh, closer := CollectionProgress(ctx, testcat, testertons) progCh := CollectionProgress(ctx, testcat, testertons)
require.NotNil(t, progCh) require.NotNil(t, progCh)
require.NotNil(t, closer)
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
progCh <- struct{}{} progCh <- struct{}{}
@ -152,9 +145,6 @@ func (suite *ObserveProgressUnitSuite) TestCollectionProgress_unblockOnChannelCl
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
close(progCh) close(progCh)
}() }()
// blocks, but should resolve due to the cancel
closer()
} }
func (suite *ObserveProgressUnitSuite) TestObserveProgress() { func (suite *ObserveProgressUnitSuite) TestObserveProgress() {
@ -197,14 +187,11 @@ func (suite *ObserveProgressUnitSuite) TestObserveProgressWithCompletion() {
message := "Test Message" message := "Test Message"
ch, closer := MessageWithCompletion(ctx, message) ch := MessageWithCompletion(ctx, message)
// Trigger completion // Trigger completion
ch <- struct{}{} ch <- struct{}{}
// Run the closer - this should complete because the bar was compelted above
closer()
Complete() Complete()
require.NotEmpty(t, recorder.String()) require.NotEmpty(t, recorder.String())
@ -229,14 +216,11 @@ func (suite *ObserveProgressUnitSuite) TestObserveProgressWithChannelClosed() {
message := "Test Message" message := "Test Message"
ch, closer := MessageWithCompletion(ctx, message) ch := MessageWithCompletion(ctx, message)
// Close channel without completing // Close channel without completing
close(ch) close(ch)
// Run the closer - this should complete because the channel was closed above
closer()
Complete() Complete()
require.NotEmpty(t, recorder.String()) require.NotEmpty(t, recorder.String())
@ -263,14 +247,11 @@ func (suite *ObserveProgressUnitSuite) TestObserveProgressWithContextCancelled()
message := "Test Message" message := "Test Message"
_, closer := MessageWithCompletion(ctx, message) _ = MessageWithCompletion(ctx, message)
// cancel context // cancel context
cancel() cancel()
// Run the closer - this should complete because the context was closed above
closer()
Complete() Complete()
require.NotEmpty(t, recorder.String()) require.NotEmpty(t, recorder.String())
@ -296,15 +277,12 @@ func (suite *ObserveProgressUnitSuite) TestObserveProgressWithCount() {
message := "Test Message" message := "Test Message"
count := 3 count := 3
ch, closer := ProgressWithCount(ctx, header, message, int64(count)) ch := ProgressWithCount(ctx, header, message, int64(count))
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
ch <- struct{}{} ch <- struct{}{}
} }
// Run the closer - this should complete because the context was closed above
closer()
Complete() Complete()
require.NotEmpty(t, recorder.String()) require.NotEmpty(t, recorder.String())
@ -331,13 +309,10 @@ func (suite *ObserveProgressUnitSuite) TestrogressWithCountChannelClosed() {
message := "Test Message" message := "Test Message"
count := 3 count := 3
ch, closer := ProgressWithCount(ctx, header, message, int64(count)) ch := ProgressWithCount(ctx, header, message, int64(count))
close(ch) close(ch)
// Run the closer - this should complete because the context was closed above
closer()
Complete() Complete()
require.NotEmpty(t, recorder.String()) require.NotEmpty(t, recorder.String())

View File

@ -407,11 +407,10 @@ func produceBackupDataCollections(
ctrlOpts control.Options, ctrlOpts control.Options,
errs *fault.Bus, errs *fault.Bus,
) ([]data.BackupCollection, prefixmatcher.StringSetReader, error) { ) ([]data.BackupCollection, prefixmatcher.StringSetReader, error) {
complete, closer := observe.MessageWithCompletion(ctx, "Discovering items to backup") complete := observe.MessageWithCompletion(ctx, "Discovering items to backup")
defer func() { defer func() {
complete <- struct{}{} complete <- struct{}{}
close(complete) close(complete)
closer()
}() }()
return bp.ProduceBackupCollections( return bp.ProduceBackupCollections(
@ -490,11 +489,10 @@ func consumeBackupCollections(
isIncremental bool, isIncremental bool,
errs *fault.Bus, errs *fault.Bus,
) (*kopia.BackupStats, *details.Builder, kopia.DetailsMergeInfoer, error) { ) (*kopia.BackupStats, *details.Builder, kopia.DetailsMergeInfoer, error) {
complete, closer := observe.MessageWithCompletion(ctx, "Backing up data") complete := observe.MessageWithCompletion(ctx, "Backing up data")
defer func() { defer func() {
complete <- struct{}{} complete <- struct{}{}
close(complete) close(complete)
closer()
}() }()
tags := map[string]string{ tags := map[string]string{

View File

@ -24,6 +24,7 @@ import (
"github.com/alcionai/corso/src/internal/connector" "github.com/alcionai/corso/src/internal/connector"
"github.com/alcionai/corso/src/internal/connector/exchange" "github.com/alcionai/corso/src/internal/connector/exchange"
exchMock "github.com/alcionai/corso/src/internal/connector/exchange/mock" exchMock "github.com/alcionai/corso/src/internal/connector/exchange/mock"
exchTD "github.com/alcionai/corso/src/internal/connector/exchange/testdata"
"github.com/alcionai/corso/src/internal/connector/graph" "github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/internal/connector/mock" "github.com/alcionai/corso/src/internal/connector/mock"
"github.com/alcionai/corso/src/internal/connector/onedrive" "github.com/alcionai/corso/src/internal/connector/onedrive"
@ -226,7 +227,10 @@ func checkBackupIsInManifests(
found bool found bool
) )
mans, err := kw.FetchPrevSnapshotManifests(ctx, reasons, tags) bf, err := kw.NewBaseFinder(bo.store)
require.NoError(t, err, clues.ToCore(err))
mans, err := bf.FindBases(ctx, reasons, tags)
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
for _, man := range mans { for _, man := range mans {
@ -716,7 +720,7 @@ func (suite *BackupOpIntegrationSuite) TestBackup_Run_incrementalExchange() {
testExchangeContinuousBackups(suite, control.Toggles{}) testExchangeContinuousBackups(suite, control.Toggles{})
} }
func (suite *BackupOpIntegrationSuite) TestBackup_Run_nonIncrementalExchange() { func (suite *BackupOpIntegrationSuite) TestBackup_Run_incrementalNonDeltaExchange() {
testExchangeContinuousBackups(suite, control.Toggles{DisableDelta: true}) testExchangeContinuousBackups(suite, control.Toggles{DisableDelta: true})
} }
@ -927,14 +931,7 @@ func testExchangeContinuousBackups(suite *BackupOpIntegrationSuite, toggles cont
// verify test data was populated, and track it for comparisons // verify test data was populated, and track it for comparisons
// TODO: this can be swapped out for InDeets checks if we add itemRefs to folder ents. // TODO: this can be swapped out for InDeets checks if we add itemRefs to folder ents.
for category, gen := range dataset { for category, gen := range dataset {
qp := graph.QueryParams{ cr := exchTD.PopulateContainerCache(t, ctx, ac, category, uidn.ID(), fault.New(true))
Category: category,
ResourceOwner: uidn,
Credentials: m365,
}
cr, err := exchange.PopulateExchangeContainerResolver(ctx, qp, fault.New(true))
require.NoError(t, err, "populating container resolver", category, clues.ToCore(err))
for destName, dest := range gen.dests { for destName, dest := range gen.dests {
id, ok := cr.LocationInCache(dest.locRef) id, ok := cr.LocationInCache(dest.locRef)
@ -1040,19 +1037,12 @@ func testExchangeContinuousBackups(suite *BackupOpIntegrationSuite, toggles cont
version.Backup, version.Backup,
gen.dbf) gen.dbf)
qp := graph.QueryParams{
Category: category,
ResourceOwner: uidn,
Credentials: m365,
}
expectedLocRef := container3 expectedLocRef := container3
if category == path.EmailCategory { if category == path.EmailCategory {
expectedLocRef = path.Builder{}.Append(container3, container3).String() expectedLocRef = path.Builder{}.Append(container3, container3).String()
} }
cr, err := exchange.PopulateExchangeContainerResolver(ctx, qp, fault.New(true)) cr := exchTD.PopulateContainerCache(t, ctx, ac, category, uidn.ID(), fault.New(true))
require.NoError(t, err, "populating container resolver", category, clues.ToCore(err))
id, ok := cr.LocationInCache(expectedLocRef) id, ok := cr.LocationInCache(expectedLocRef)
require.Truef(t, ok, "dir %s found in %s cache", expectedLocRef, category) require.Truef(t, ok, "dir %s found in %s cache", expectedLocRef, category)

View File

@ -236,8 +236,7 @@ func (op *RestoreOperation) do(
observe.Message(ctx, fmt.Sprintf("Discovered %d items in backup %s to restore", len(paths), op.BackupID)) observe.Message(ctx, fmt.Sprintf("Discovered %d items in backup %s to restore", len(paths), op.BackupID))
kopiaComplete, closer := observe.MessageWithCompletion(ctx, "Enumerating items in repository") kopiaComplete := observe.MessageWithCompletion(ctx, "Enumerating items in repository")
defer closer()
defer close(kopiaComplete) defer close(kopiaComplete)
dcs, err := op.kopia.ProduceRestoreCollections(ctx, bup.SnapshotID, paths, opStats.bytesRead, op.Errors) dcs, err := op.kopia.ProduceRestoreCollections(ctx, bup.SnapshotID, paths, opStats.bytesRead, op.Errors)
@ -322,11 +321,10 @@ func consumeRestoreCollections(
dcs []data.RestoreCollection, dcs []data.RestoreCollection,
errs *fault.Bus, errs *fault.Bus,
) (*details.Details, error) { ) (*details.Details, error) {
complete, closer := observe.MessageWithCompletion(ctx, "Restoring data") complete := observe.MessageWithCompletion(ctx, "Restoring data")
defer func() { defer func() {
complete <- struct{}{} complete <- struct{}{}
close(complete) close(complete)
closer()
}() }()
deets, err := rc.ConsumeRestoreCollections( deets, err := rc.ConsumeRestoreCollections(

View File

@ -388,10 +388,17 @@ func (b *Builder) Details() *Details {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
// Write the cached folder entries to details ents := make([]Entry, len(b.d.Entries))
b.d.Entries = append(b.d.Entries, maps.Values(b.knownFolders)...) copy(ents, b.d.Entries)
return &b.d // Write the cached folder entries to details
details := &Details{
DetailsModel{
Entries: append(ents, maps.Values(b.knownFolders)...),
},
}
return details
} }
// -------------------------------------------------------------------------------- // --------------------------------------------------------------------------------

View File

@ -1033,6 +1033,84 @@ func (suite *DetailsUnitSuite) TestBuilder_Add_cleansFileIDSuffixes() {
} }
} }
func (suite *DetailsUnitSuite) TestBuilder_DetailsNoDuplicate() {
var (
t = suite.T()
b = Builder{}
svc = path.OneDriveService
cat = path.FilesCategory
info = ItemInfo{
OneDrive: &OneDriveInfo{
ItemType: OneDriveItem,
ItemName: "in",
DriveName: "dn",
DriveID: "d",
},
}
dataSfx = makeItemPath(t, svc, cat, "t", "u", []string{"d", "r:", "f", "i1" + metadata.DataFileSuffix})
dataSfx2 = makeItemPath(t, svc, cat, "t", "u", []string{"d", "r:", "f", "i2" + metadata.DataFileSuffix})
dirMetaSfx = makeItemPath(t, svc, cat, "t", "u", []string{"d", "r:", "f", "i1" + metadata.DirMetaFileSuffix})
metaSfx = makeItemPath(t, svc, cat, "t", "u", []string{"d", "r:", "f", "i1" + metadata.MetaFileSuffix})
)
// Don't need to generate folders for this entry, we just want the itemRef
loc := &path.Builder{}
err := b.Add(dataSfx, loc, false, info)
require.NoError(t, err, clues.ToCore(err))
err = b.Add(dataSfx2, loc, false, info)
require.NoError(t, err, clues.ToCore(err))
err = b.Add(dirMetaSfx, loc, false, info)
require.NoError(t, err, clues.ToCore(err))
err = b.Add(metaSfx, loc, false, info)
require.NoError(t, err, clues.ToCore(err))
b.knownFolders = map[string]Entry{
"dummy": {
RepoRef: "xyz",
ShortRef: "abcd",
ParentRef: "1234",
LocationRef: "ab",
ItemRef: "cd",
Updated: false,
ItemInfo: info,
},
"dummy2": {
RepoRef: "xyz2",
ShortRef: "abcd2",
ParentRef: "12342",
LocationRef: "ab2",
ItemRef: "cd2",
Updated: false,
ItemInfo: info,
},
"dummy3": {
RepoRef: "xyz3",
ShortRef: "abcd3",
ParentRef: "12343",
LocationRef: "ab3",
ItemRef: "cd3",
Updated: false,
ItemInfo: info,
},
}
// mark the capacity prior to calling details.
// if the entries slice gets modified and grows to a
// 5th space, then the capacity would grow as well.
capCheck := cap(b.d.Entries)
assert.Len(t, b.Details().Entries, 7) // 4 ents + 3 known folders
assert.Len(t, b.Details().Entries, 7) // possible reason for err: knownFolders got added twice
assert.Len(t, b.d.Entries, 4) // len should not have grown
assert.Equal(t, capCheck, cap(b.d.Entries)) // capacity should not have grown
}
func makeItemPath( func makeItemPath(
t *testing.T, t *testing.T,
service path.ServiceType, service path.ServiceType,

View File

@ -240,6 +240,15 @@ func (pb Builder) Dir() *Builder {
} }
} }
// HeadElem returns the first element in the Builder.
func (pb Builder) HeadElem() string {
if len(pb.elements) == 0 {
return ""
}
return pb.elements[0]
}
// LastElem returns the last element in the Builder. // LastElem returns the last element in the Builder.
func (pb Builder) LastElem() string { func (pb Builder) LastElem() string {
if len(pb.elements) == 0 { if len(pb.elements) == 0 {

View File

@ -203,8 +203,7 @@ func Connect(
// their output getting clobbered (#1720) // their output getting clobbered (#1720)
defer observe.Complete() defer observe.Complete()
complete, closer := observe.MessageWithCompletion(ctx, "Connecting to repository") complete := observe.MessageWithCompletion(ctx, "Connecting to repository")
defer closer()
defer close(complete) defer close(complete)
kopiaRef := kopia.NewConn(s) kopiaRef := kopia.NewConn(s)
@ -630,11 +629,10 @@ func connectToM365(
sel selectors.Selector, sel selectors.Selector,
acct account.Account, acct account.Account,
) (*connector.GraphConnector, error) { ) (*connector.GraphConnector, error) {
complete, closer := observe.MessageWithCompletion(ctx, "Connecting to M365") complete := observe.MessageWithCompletion(ctx, "Connecting to M365")
defer func() { defer func() {
complete <- struct{}{} complete <- struct{}{}
close(complete) close(complete)
closer()
}() }()
// retrieve data from the producer // retrieve data from the producer

View File

@ -34,12 +34,13 @@ type Contacts struct {
// containers // containers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CreateContactFolder makes a contact folder with the displayName of folderName. // CreateContainer makes a contact folder with the displayName of folderName.
// If successful, returns the created folder object. // If successful, returns the created folder object.
func (c Contacts) CreateContactFolder( func (c Contacts) CreateContainer(
ctx context.Context, ctx context.Context,
userID, containerName string, userID, containerName string,
) (models.ContactFolderable, error) { _ string, // parentContainerID needed for iface, doesn't apply to contacts
) (graph.Container, error) {
body := models.NewContactFolder() body := models.NewContactFolder()
body.SetDisplayName(ptr.To(containerName)) body.SetDisplayName(ptr.To(containerName))

View File

@ -38,16 +38,17 @@ type Events struct {
// containers // containers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CreateCalendar makes an event Calendar with the name in the user's M365 exchange account // CreateContainer makes an event Calendar with the name in the user's M365 exchange account
// Reference: https://docs.microsoft.com/en-us/graph/api/user-post-calendars?view=graph-rest-1.0&tabs=go // Reference: https://docs.microsoft.com/en-us/graph/api/user-post-calendars?view=graph-rest-1.0&tabs=go
func (c Events) CreateCalendar( func (c Events) CreateContainer(
ctx context.Context, ctx context.Context,
userID, containerName string, userID, containerName string,
) (models.Calendarable, error) { _ string, // parentContainerID needed for iface, doesn't apply to contacts
) (graph.Container, error) {
body := models.NewCalendar() body := models.NewCalendar()
body.SetName(&containerName) body.SetName(&containerName)
mdl, err := c.Stable. container, err := c.Stable.
Client(). Client().
Users(). Users().
ByUserId(userID). ByUserId(userID).
@ -57,7 +58,7 @@ func (c Events) CreateCalendar(
return nil, graph.Wrap(ctx, err, "creating calendar") return nil, graph.Wrap(ctx, err, "creating calendar")
} }
return mdl, nil return CalendarDisplayable{Calendarable: container}, nil
} }
// DeleteContainer removes a calendar from user's M365 account // DeleteContainer removes a calendar from user's M365 account
@ -130,7 +131,7 @@ func (c Events) GetContainerByID(
func (c Events) GetContainerByName( func (c Events) GetContainerByName(
ctx context.Context, ctx context.Context,
userID, containerName string, userID, containerName string,
) (models.Calendarable, error) { ) (graph.Container, error) {
filter := fmt.Sprintf("name eq '%s'", containerName) filter := fmt.Sprintf("name eq '%s'", containerName)
options := &users.ItemCalendarsRequestBuilderGetRequestConfiguration{ options := &users.ItemCalendarsRequestBuilderGetRequestConfiguration{
QueryParameters: &users.ItemCalendarsRequestBuilderGetQueryParameters{ QueryParameters: &users.ItemCalendarsRequestBuilderGetQueryParameters{
@ -167,7 +168,7 @@ func (c Events) GetContainerByName(
return nil, err return nil, err
} }
return cal, nil return graph.CalendarDisplayable{Calendarable: cal}, nil
} }
func (c Events) PatchCalendar( func (c Events) PatchCalendar(

View File

@ -64,10 +64,10 @@ func (c Mail) CreateMailFolder(
return mdl, nil return mdl, nil
} }
func (c Mail) CreateMailFolderWithParent( func (c Mail) CreateContainer(
ctx context.Context, ctx context.Context,
userID, containerName, parentContainerID string, userID, containerName, parentContainerID string,
) (models.MailFolderable, error) { ) (graph.Container, error) {
isHidden := false isHidden := false
body := models.NewMailFolder() body := models.NewMailFolder()
body.SetDisplayName(&containerName) body.SetDisplayName(&containerName)