Files
IntuneManagement/Internal/IntuneMigration.ps1
T

609 lines
31 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Intune cross-tenant migration-table feature (export/import of Groups, ScopeTags
# etc. between environments). Moved out of Internal/MSGraph.ps1 (architecture R4 -
# MSGraph.ps1 should hold only generic Graph helpers, not Intune feature logic).
# All module-internal; shares the $script:_migFile* caches set up by Start-GraphBulkExport.
#region Migration Table functions
function Get-MigrationTableInfo
{
param($Path, $TenantId)
$FileName = Get-GraphMigrationTableFromPath $Path
$str = $null
$sameTenant = $false
if($FileName -and [IO.File]::Exists($FileName))
{
$migFileObj = ConvertFrom-Json ([IO.File]::ReadAllText($FileName))
if($migFileObj.TenantId -and $migFileObj.TenantId -eq $TenantId)
{
$sameTenant = $true
$str = "Current tenant. Migration table will not be used"
}
elseif($migFileObj.Organization)
{
$str = "Objects exported from $($migFileObj.Organization) ($($migFileObj.TenantId))"
}
}
if(-not $str)
{
# Hide controls?
$str = "No migration table found"
}
$str, $sameTenant
}
function Add-GraphMigrationInfo
{
# MigrationRoot (optional): the export root directory where MigrationTable.json
# and Groups/ should live. When supplied, takes precedence over the legacy
# ".Parent.FullName" guess in Add-GraphMigrationObject — needed because that
# guess assumed $Folder is always a per-type subfolder one level under the
# export root, which is wrong when AddObjectType=false (policies written
# straight to the export root). Without this, MigrationTable.json + Groups/
# ended up one level too high and the "Parent name of the folder is not
# organization name" warning fired falsely.
#
# MaxGroupDepth (optional): depth of group-membership recursion. 1 (default)
# exports only the directly-assigned group; >1 walks into the group's member
# groups up to MaxGroupDepth levels deep. Cycle-safe via the per-folder dedup
# index in Add-GraphMigrationObject.
param($PolicyObject, $Folder, [string]$MigrationRoot, [int]$MaxGroupDepth = 1)
if(-not $PolicyObject) { return }
foreach($assignment in $PolicyObject.JsonObject.Assignments)
{
foreach($assignmentTarget in $assignment.target)
{
if(-not $assignmentTarget."@odata.type") { continue }
$assignmentTargetType = $assignmentTarget."@odata.type"
if($assignmentTargetType -eq "#microsoft.graph.groupAssignmentTarget" -or
$assignmentTargetType -eq "#microsoft.graph.exclusionGroupAssignmentTarget" -or
$assignmentTargetType -eq "#microsoft.graph.cloudPcManagementGroupAssignmentTarget")
{
Add-GraphMigrationObject $assignmentTarget.groupid "groups" "Group" $Folder $PolicyObject._TokenId -MigrationRoot $MigrationRoot -MaxGroupDepth $MaxGroupDepth
}
elseif($assignmentTargetType -eq "#microsoft.graph.allLicensedUsersAssignmentTarget" -or
$assignmentTargetType -eq "#microsoft.graph.allDevicesAssignmentTarget")
{
# No need to migrate All Users or All Devices
}
else
{
Write-Log "Unsupported migration object: $assignmentTargetType" 3
}
# Assignment filters ride on EVERY target type as a sibling property (incl.
# All Users / All Devices). Capture the filter to the migration table + an
# AssignmentFilters sidecar so cross-tenant import can resolve/create it.
$filterId = [string]$assignmentTarget.deviceAndAppManagementAssignmentFilterId
if($filterId -and $filterId -ne "00000000-0000-0000-0000-000000000000")
{
Add-GraphMigrationObject $filterId "deviceManagement/assignmentFilters" "AssignmentFilter" $Folder $PolicyObject._TokenId -MigrationRoot $MigrationRoot
}
}
}
}
function Add-GraphMigrationObject
{
# MigrationRoot (optional): explicit export root for MigrationTable.json and
# Groups/. Use it when known (Export-GraphPolicy passes $exportFolderRoot).
# Otherwise the fallback is the legacy "$Folder.Parent.FullName" heuristic,
# which is correct when $Folder is a per-type subfolder under the export root
# but wrong when $Folder IS the export root (AddObjectType=false case).
#
# MaxGroupDepth / CurrentGroupDepth: drive nested-group export. When saving a
# Group sidecar, if CurrentGroupDepth < MaxGroupDepth we fetch the group's
# members and recurse for any that are themselves groups (CurrentGroupDepth+1).
# The per-folder dedup index naturally breaks cycles (a group already added
# to MigrationTable.json won't be re-added or re-recursed).
param($ObjectId, $GraphAPI, $ObjectTypeName, $Folder, $TokenId = 0, [string]$MigrationRoot,
[int]$MaxGroupDepth = 1, [int]$CurrentGroupDepth = 1)
if(-not $ObjectId) { return }
# Get-OperationTokenInfo, not Get-TokenInfo: a caller with no token of its own
# passes 0, the codebase's spelling for "the default token", and the list
# accessor answers 0 with EVERY registered token. With two tenants signed in
# $tokeInfo.TenantId was an ARRAY, so the cache key below became
# "AADObjectCache_tenant-a tenant-b" - one partition holding two directories,
# which then leaked one tenant's objects into the other's migration table. A
# token with no TenantId is the same defect spelled differently (every tenant
# sharing "AADObjectCache_"), so it stops here too.
$tokeInfo = Get-OperationTokenInfo ([int]$TokenId)
if(-not $tokeInfo -or -not $tokeInfo.TenantId) { return }
# Pin this operation to the resolved token. 0 is a moving target: it is stored in
# the pending-fetch queue, and Resolve-GraphMigrationObjectsPending drains that
# queue much later - after a Login or a tenant switch has possibly made a
# DIFFERENT token the default. The queued id, the nested /members GET and the
# recursion into child groups all have to name the tenant this export is of, not
# whichever tenant is default when the flush happens.
if([int]$tokeInfo.Id -gt 0) { $TokenId = [int]$tokeInfo.Id }
$cacheTenantId = [string]$tokeInfo.TenantId
# ----- Per-process MigrationTable.json cache + dedup index -----
# This function used to read MigrationTable.json from disk, parse it, append
# via `$Objects += @{...}` (O(n²) array rebuild), then write the entire file
# back to disk — EVERY single call. For a bulk export of ~400 policies with a
# few group assignments each that's ~1200 round-trips through disk + JSON
# serialize. With the cache below, each call is an in-memory hashtable lookup +
# List<object>.Add; Save-GraphMigrationFilesPending writes each touched file to disk
# once at end of bulk export.
if(-not $script:_migFileCache) { $script:_migFileCache = @{} }
if(-not $script:_migFileObjectsIndex) { $script:_migFileObjectsIndex = @{} }
if(-not $script:_migFileDirty) { $script:_migFileDirty = @{} }
# Per-$Folder cache of the resolved MigrationTable.json path. Without this,
# Get-GraphMigrationTableFromPath fires on every call (~1200× during a bulk
# export), each call does Expand-FileName + 2 [IO.File]::Exists + when the
# file doesn't exist yet it Write-Log's "Could not find migration table" —
# which through Write-Log's ObservableCollection.Add triggers UI marshalling.
# Resolve once per folder; on cache hit later we skip the lookup entirely.
if(-not $script:_migFilePathCache) { $script:_migFilePathCache = @{} }
# Key the cache on $Folder (stable across all calls in this bulk export) rather
# than on the resolved migration-file path (which is $null on first call when the
# file doesn't exist yet, then changes — leading to a cache miss on every call).
$cacheKey = $Folder
if($script:_migFileCache.ContainsKey($cacheKey)) {
# Fast path: cache already populated for this folder, skip disk probe + log spam.
$migFileObj = $script:_migFileCache[$cacheKey]
$migrationFile = $script:_migFilePathCache[$cacheKey] # may be $null until first add resolves it
}
else {
# First call for this folder — resolve the path once and seed the caches.
$migrationFile = Get-GraphMigrationTableFromPath $Folder
$script:_migFilePathCache[$cacheKey] = $migrationFile
if($migrationFile -and [IO.File]::Exists($migrationFile)) {
$migFileObj = ConvertFrom-Json ([IO.File]::ReadAllText($migrationFile))
# Normalise Objects to a List so adds are O(1) instead of O(n) array rebuilds.
$list = [System.Collections.Generic.List[object]]::new()
if($migFileObj.Objects) { foreach($o in $migFileObj.Objects) { [void]$list.Add($o) } }
$migFileObj.Objects = $list
$script:_migFileCache[$cacheKey] = $migFileObj
# Build lookup index for O(1) duplicate-check instead of O(n) Where-Object.
$idx = @{}
foreach($o in $list) { $idx["$($o.Id)|$($o.Type)"] = $true }
$script:_migFileObjectsIndex[$cacheKey] = $idx
}
else {
# No existing file — start with an empty migration object. Path will be
# resolved on first object add below.
$migrationFile = $null
$migFileObj = ([PSCustomObject]@{
TenantId = $script:organizationId
Organization = $script:organizationName
Objects = [System.Collections.Generic.List[object]]::new()
})
$script:_migFileCache[$cacheKey] = $migFileObj
$script:_migFileObjectsIndex[$cacheKey] = @{}
}
}
# Check if object is already processed. The cache key is normalised on the resolved
# token's tenant id (see $cacheTenantId above) on BOTH read and write - earlier code
# accidentally wrote to $script:Organization.Id which could diverge from the token's
# tenant id and produce a permanent cache miss.
$graphObj = Get-GraphMigrationObject $ObjectId $cacheTenantId
$AADObjectCache = Get-CacheObject "AADObjectCache_$cacheTenantId" (@{})
if(-not $graphObj -and $AADObjectCache.ContainsKey($ObjectId) -eq $false)
{
# Not in the cache, positively or negatively. This function never calls
# Graph itself any more: it used to issue one direct GET per unknown id
# right here, which on an export with no prefetch (single-policy export,
# a depth bumped at the call site) was a serial round-trip per assignment
# group at ~1.6 s each. The miss is queued instead and
# Resolve-GraphMigrationObjectsPending fetches every queued id at once -
# getByIds for directory objects, $batch for the rest - then replays this
# call as a cache hit. Save-GraphMigrationFilesPending drains the queue,
# and every export path ends with that.
if(-not $script:_migPendingFetch) { $script:_migPendingFetch = [System.Collections.Generic.List[object]]::new() }
[void]$script:_migPendingFetch.Add([PSCustomObject]@{
ObjectId = $ObjectId
GraphAPI = $GraphAPI
ObjectTypeName = $ObjectTypeName
Folder = $Folder
TokenId = $TokenId
MigrationRoot = $MigrationRoot
MaxGroupDepth = $MaxGroupDepth
CurrentGroupDepth = $CurrentGroupDepth
})
return
}
if($graphObj)
{
$objectAdded = $false
# Add object to cache (positive)
if($AADObjectCache -is [Hashtable] -and $AADObjectCache.ContainsKey($ObjectId) -eq $false) { $AADObjectCache.Add($ObjectId, $graphObj) }
$indexKey = "$ObjectId|$ObjectTypeName"
$index = $script:_migFileObjectsIndex[$cacheKey]
if(-not $index.ContainsKey($indexKey)) {
# FIX: was $GraphObject (undefined → wrote {Id=$null, DisplayName=$null} into
# every MigrationTable.json entry). The correct variable is $graphObj.
[void]$migFileObj.Objects.Add([PSCustomObject]@{
Id = $graphObj.Id
DisplayName = $graphObj.displayName
Type = $ObjectTypeName
})
$index[$indexKey] = $true
$objectAdded = $true
# First time we add anything against a still-unresolved migration file path:
# resolve the canonical location now so the flush writes to the right path.
# Cache is keyed on $Folder which is stable across calls in this bulk export,
# so no key migration is needed. Save the resolved path back into the path
# cache so cache-hit calls later in this run see the right file.
if(-not $migrationFile) {
if($MigrationRoot) {
# Caller passed the export root explicitly — most reliable.
$migrationFile = [IO.Path]::Combine($MigrationRoot, "MigrationTable.json")
}
else {
# Legacy heuristic: assume $Folder is a per-type subfolder and the
# export root is one level up. Works for the typical AddObjectType=true
# path but lands the file one level too high when $Folder is already
# the export root.
$folderInfo = [IO.DirectoryInfo]$Folder
$migrationFile = [IO.Path]::Combine($folderInfo.Parent.FullName, "MigrationTable.json")
if($folderInfo.Parent.Name -ne $tokeInfo.TenantName) {
Write-Log "Parent name of the folder is not organization name: $($folderInfo.Parent.Name)" 2
}
}
$script:_migFilePathCache[$cacheKey] = $migrationFile
Write-Log "Create new Migration file: $migrationFile"
}
# Defer the actual disk write until Save-GraphMigrationFilesPending at end of
# bulk export — eliminates ~1200 read+write cycles for a typical run.
# Dirty map: cacheKey ($Folder) -> resolved file path.
$script:_migFileDirty[$cacheKey] = $migrationFile
}
# Sidecar json — write immediately, only once per new entry. Groups go to
# Groups\, assignment filters to AssignmentFilters\ (same name as the type's
# normal export folder, so the Assignments viewer finds them either way).
if($objectAdded -and $ObjectTypeName -in @("Group","AssignmentFilter") -and $migrationFile)
{
$sidecarFolder = if($ObjectTypeName -eq "AssignmentFilter") { "AssignmentFilters" } else { "Groups" }
$grouspPath = Join-Path ([IO.Path]::GetDirectoryName($migrationFile)) $sidecarFolder
# New-Item, not mkdir: on Linux/macOS `mkdir` resolves to the native
# binary, which rejects -Path/-Force and cannot be silenced by
# -ErrorAction, so the folder was never created and every sidecar
# write failed.
if(-not (Test-Path $grouspPath)) { New-Item -ItemType Directory -Path $grouspPath -Force -ErrorAction SilentlyContinue | Out-Null }
$FileName = [IO.Path]::Combine($grouspPath, "$((Remove-InvalidFileNameChars $graphObj.displayName)).json")
# Defensive strip before serialising the group sidecar: any literal
# `members` array or members nav links should never end up in a
# sidecar that could later be re-POSTed to Graph (Graph treats
# `members@odata.bind` on POST /groups as "create with these
# members"). A group body fetched with $expand or a future Graph
# projection change could carry one in without this.
foreach ($m in @('members','members@odata.context','members@odata.associationLink','members@odata.navigationLink','members@odata.bind')) {
if ($graphObj.PSObject.Properties.Name -contains $m) {
$graphObj.PSObject.Properties.Remove($m) | Out-Null
}
}
Save-GraphObjectToFile $graphObj $FileName
# Nested-group recursion. Only walks deeper when the caller opted in
# (MaxGroupDepth > 1) and there's still budget. The dedup index above
# ($script:_migFileObjectsIndex) prevents cycles and duplicate work.
if($MaxGroupDepth -gt 1 -and $CurrentGroupDepth -lt $MaxGroupDepth)
{
try {
# Bulk export pre-walks the hierarchy in batches and stamps each
# parent body with `#NestedChildren = [{id, displayName}, ...]`.
# When that's present we skip the inline /members GET entirely —
# otherwise the recursion fires one un-batched call per parent
# which dominates wall time for tenants with many group
# assignments and depth > 1.
$members = $null
if($graphObj.PSObject.Properties.Name -contains '#NestedChildren') {
$members = @($graphObj.'#NestedChildren')
}
else {
# Fallback for callers that didn't pre-walk the hierarchy
# (single-policy export, depth bumped at call site, etc.).
# Use Graph's type-cast syntax to filter to nested groups
# server-side — skips users / service principals / devices
# without round-tripping them, and doesn't depend on
# @odata.type being present in the response. Smaller payload.
$membersResp = Invoke-MSGraphAPI -Url "groups/$ObjectId/members/microsoft.graph.group?`$select=id,displayName" -TokenId $TokenId -AllPages -ODataMetadata "Minimal"
$members = @()
if($membersResp -and $membersResp.value) { $members = @($membersResp.value) }
elseif($membersResp -is [Array]) { $members = @($membersResp) }
}
foreach($m in $members)
{
if(-not $m -or -not $m.id) { continue }
Add-GraphMigrationObject $m.id "groups" "Group" $Folder $TokenId `
-MigrationRoot $MigrationRoot `
-MaxGroupDepth $MaxGroupDepth `
-CurrentGroupDepth ($CurrentGroupDepth + 1)
}
}
catch {
Write-LogDebug "Nested-group recursion for $ObjectId failed at depth $CurrentGroupDepth/$MaxGroupDepth : $($_.Exception.Message)"
}
}
}
}
else
{
# Negative-result cache: remember that this object id doesn't resolve so the next
# caller doesn't hit Graph again for the same missing reference.
if($AADObjectCache -is [Hashtable] -and $AADObjectCache.ContainsKey($ObjectId) -eq $false) { $AADObjectCache.Add($ObjectId, $null) }
Set-CacheObject "AADObjectCache_$cacheTenantId" $AADObjectCache "TenantCache_$cacheTenantId"
Write-Log "No $ObjectTypeName found with ID $($ObjectId). It might be deleted." 2
}
}
# Resolve every migration object Add-GraphMigrationObject queued as a cache miss,
# in as few Graph calls as possible, then replay the queued calls as cache hits
# so they write their MigrationTable entry and sidecar exactly as before.
#
# Directory objects (groups, users, devices, service principals) go through
# /directoryObjects/getByIds, a thousand ids per POST. Anything else (assignment
# filters, apps) goes through Invoke-GraphBatchRequest, which decides batch versus
# direct from the UseBatchAPI setting. Ids are grouped by token first so a
# cross-tenant run never resolves one tenant's ids against another's directory.
#
# A replayed call can queue more work (nested-group recursion finds children that
# are not cached), so this loops; ten rounds is far beyond any real nesting depth.
# Ids whose fetch failed outright are dropped with a warning rather than retried
# every round - and are NOT negative-cached, because that cache persists and a
# transient failure must not mark a thousand live groups as deleted.
function Resolve-GraphMigrationObjectsPending
{
if(-not $script:_migPendingFetch -or $script:_migPendingFetch.Count -eq 0) { return }
$directoryTypes = @{ groups = 'group'; users = 'user'; devices = 'device'; servicePrincipals = 'servicePrincipal' }
$round = 0
while($script:_migPendingFetch.Count -gt 0 -and $round -lt 10)
{
$round++
$pending = @($script:_migPendingFetch.ToArray())
$script:_migPendingFetch.Clear()
$unresolvable = [System.Collections.Generic.HashSet[string]]::new()
$byToken = @{}
foreach($p in $pending) {
$k = [int]$p.TokenId
if(-not $byToken.ContainsKey($k)) { $byToken[$k] = [System.Collections.Generic.List[object]]::new() }
[void]$byToken[$k].Add($p)
}
# Raw queued token id -> the positive id it resolved to. The replay loop below
# runs over every token at once, outside this loop, so it needs the mapping to
# avoid replaying the raw 0 - which would be re-resolved against whatever is
# default by then, i.e. possibly another tenant than the one just fetched from.
$resolvedToken = @{}
foreach($rawTokenId in @($byToken.Keys))
{
# Defensive: Add-GraphMigrationObject pins the id before it queues anything,
# so a 0 should no longer reach here. If one does - an entry queued by
# something else, or a future caller - resolve it ONCE here and use the
# resolved id for both the fetch and the replay. Fetching one tenant's
# directory and replaying against "whatever is default now" is the defect.
$tokenInfo = Get-OperationTokenInfo $rawTokenId
if(-not $tokenInfo -or -not $tokenInfo.TenantId) { continue }
$tokenId = if([int]$tokenInfo.Id -gt 0) { [int]$tokenInfo.Id } else { [int]$rawTokenId }
$resolvedToken[[int]$rawTokenId] = $tokenId
$cacheKey = "AADObjectCache_$($tokenInfo.TenantId)"
$cacheFile = "TenantCache_$($tokenInfo.TenantId)"
$cache = Get-CacheObject $cacheKey @{}
if($cache -isnot [Hashtable]) { $cache = @{} }
$idsByApi = @{}
foreach($p in $byToken[$rawTokenId]) {
$id = [string]$p.ObjectId
if(-not $id -or $cache.ContainsKey($id)) { continue }
$api = "$($p.GraphAPI)".Trim('/')
if(-not $idsByApi.ContainsKey($api)) { $idsByApi[$api] = [System.Collections.Generic.HashSet[string]]::new() }
[void]$idsByApi[$api].Add($id)
}
foreach($api in @($idsByApi.Keys))
{
$ids = @($idsByApi[$api])
if($ids.Count -eq 0) { continue }
$leaf = ($api -split '/')[-1]
Write-Log "Migration objects: resolving $($ids.Count) queued $leaf id(s) for tenant $($tokenInfo.TenantId)"
Write-Status -Detail ("Resolving {0} {1}" -f $ids.Count, $leaf) -SkipLog -Force
if($directoryTypes.ContainsKey($leaf))
{
for($i = 0; $i -lt $ids.Count; $i += 1000) {
$end = [Math]::Min($i + 999, $ids.Count - 1)
$chunk = @($ids[$i..$end])
$resp = $null
try {
$body = (@{ ids = $chunk; types = @($directoryTypes[$leaf]) } | ConvertTo-Json -Compress)
$resp = Invoke-MSGraphAPI -Url 'directoryObjects/getByIds' -Content $body -HttpMethod POST -TokenId $tokenId -ODataMetadata 'none'
}
catch { Write-LogError "Migration objects: getByIds failed for $($chunk.Count) $leaf id(s)" $_.Exception }
if($null -eq $resp) {
foreach($id in $chunk) { [void]$unresolvable.Add($id) }
continue
}
$seen = [System.Collections.Generic.HashSet[string]]::new()
foreach($o in @($resp.value)) {
if(-not $o -or -not $o.id) { continue }
if($o.PSObject.Properties['@odata.type']) { [void]$o.PSObject.Properties.Remove('@odata.type') }
$cache[[string]$o.id] = $o
[void]$seen.Add([string]$o.id)
}
foreach($id in $chunk) { if(-not $seen.Contains($id)) { $cache[$id] = $null } }
}
}
else
{
$batch = [System.Collections.Generic.List[PSCustomObject]]::new()
$idByReq = @{}
$n = 0
foreach($id in $ids) {
$n++
$reqId = "migobj_$n"
[void]$batch.Add([PSCustomObject]@{ id = $reqId; method = 'GET'; url = "$api/$id"; headers = @{ Accept = 'application/json;odata.metadata=none' } })
$idByReq[$reqId] = $id
}
$results = @(Invoke-GraphBatchRequest -BatchObjects $batch -BatchType "Migration objects ($leaf)" -TokenId $tokenId -SkipWarnings -IncludedFailed)
$answered = [System.Collections.Generic.HashSet[string]]::new()
foreach($r in $results) {
$id = $idByReq["$($r.Id)"]
if(-not $id) { continue }
[void]$answered.Add($id)
if($r.body -and $r.Status -ge 200 -and $r.Status -lt 300) { $cache[$id] = $r.body }
else { $cache[$id] = $null }
}
foreach($id in $ids) { if(-not $answered.Contains($id)) { [void]$unresolvable.Add($id) } }
}
}
Set-CacheObject $cacheKey $cache $cacheFile
}
if($unresolvable.Count -gt 0) {
Write-Log "Migration objects: $($unresolvable.Count) id(s) could not be fetched and are left out of the migration table" 2
}
# Every queued id is now a cache hit, positive or negative: replay.
foreach($p in $pending) {
if($unresolvable.Contains([string]$p.ObjectId)) { continue }
# The token the ids were actually fetched with, not the one that was queued.
$replayToken = if($resolvedToken.ContainsKey([int]$p.TokenId)) { $resolvedToken[[int]$p.TokenId] } else { [int]$p.TokenId }
Add-GraphMigrationObject $p.ObjectId $p.GraphAPI $p.ObjectTypeName $p.Folder $replayToken `
-MigrationRoot $p.MigrationRoot -MaxGroupDepth $p.MaxGroupDepth -CurrentGroupDepth $p.CurrentGroupDepth
}
Write-Status -Detail "" -SkipLog -Force
}
if($script:_migPendingFetch.Count -gt 0) {
Write-Log "Migration objects: $($script:_migPendingFetch.Count) id(s) still queued after $round round(s) - giving up" 2
$script:_migPendingFetch.Clear()
}
}
# Persist every MigrationTable.json that was mutated during this bulk export. Called
# once at the end of Start-GraphBulkExport so the in-memory cache batches all the
# Add-GraphMigrationObject mutations into a single write per file. Drains the
# queued cache misses first - on a fresh export nothing is dirty until they are.
function Save-GraphMigrationFilesPending
{
Resolve-GraphMigrationObjectsPending
if(-not $script:_migFileDirty -or $script:_migFileDirty.Count -eq 0) { return }
$objectsByFile = @{}
foreach($entry in @($script:_migFileDirty.GetEnumerator())) {
$cacheKey = $entry.Key
$filePath = $entry.Value
if(-not $filePath) { continue }
$obj = $script:_migFileCache[$cacheKey]
if(-not $obj) { continue }
$fileKey = [IO.Path]::GetFullPath($filePath).ToLowerInvariant()
if(-not $objectsByFile.ContainsKey($fileKey)) {
$objectsByFile[$fileKey] = [PSCustomObject]@{
FilePath = $filePath
TenantId = $obj.TenantId
Organization = $obj.Organization
Objects = [System.Collections.Generic.List[object]]::new()
Index = @{}
}
}
$target = $objectsByFile[$fileKey]
foreach($migrationObject in @($obj.Objects)) {
if(-not $migrationObject -or -not $migrationObject.Id -or -not $migrationObject.Type) { continue }
$indexKey = "$($migrationObject.Id)|$($migrationObject.Type)"
if($target.Index.ContainsKey($indexKey)) { continue }
[void]$target.Objects.Add($migrationObject)
$target.Index[$indexKey] = $true
}
}
foreach($entry in @($objectsByFile.GetEnumerator())) {
$filePath = $entry.Value.FilePath
$obj = [PSCustomObject]@{
TenantId = $entry.Value.TenantId
Organization = $entry.Value.Organization
Objects = $entry.Value.Objects.ToArray()
}
try {
# Same encoding as the exported policy files - a MigrationTable in a
# different encoding to the objects beside it is the same import
# hazard.
Save-GraphTextToFile (ConvertTo-GraphExportJson $obj -Depth 50) $filePath
Write-LogDebug "Migration file flushed: $filePath"
}
catch {
Write-LogError "Failed to flush Migration File $filePath" $_.Exception
}
}
# Clear dirty set so a subsequent bulk export starts clean (cache itself is kept
# so re-runs in the same process skip the disk-load cost).
$script:_migFileDirty = @{}
}
function Get-GraphMigrationTableFromPath
{
param($Path)
# Migration table must be located in the root of the import path
$path = Expand-FileName $Path
for($i = 0;$i -lt 2;$i++)
{
if($i -gt 0)
{
# Get parent directory
$path = [io.path]::GetDirectoryName($path)
}
$migFileName = Join-Path $path "MigrationTable.json"
try
{
if([IO.File]::Exists($migFileName))
{
return $migFileName
}
}
catch {}
}
# Downgraded from Write-Log (visible / file-logged / UI-marshalled per call) to
# Write-LogDebug — for a fresh bulk export this happens once per per-type folder
# and is informational, not a problem. The original Write-Log fired 1000+ times
# before path caching landed, contributing ~2s of UI marshalling per run.
Write-LogDebug "Could not find migration table for path '$Path'"
}
function Get-GraphMigrationObject
{
param($ObjectId, $TenantId)
$AADObjectCache = Get-CacheObject "AADObjectCache_$($TenantId)" @{}
if($AADObjectCache.ContainsKey($ObjectId)) { return $AADObjectCache[$ObjectId] }
}