mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 19:05:38 +02:00
373 lines
17 KiB
PowerShell
373 lines
17 KiB
PowerShell
function Start-GraphBulkExport {
|
|
<#
|
|
.SYNOPSIS
|
|
Bulk-export Intune policy objects to disk.
|
|
|
|
.DESCRIPTION
|
|
Public, UI-independent driver for bulk export. The WPF UI is a thin caller of
|
|
this function; the same function can be invoked from a scheduled task or
|
|
CI/CD pipeline without loading any XAML.
|
|
|
|
Parameter precedence: SettingsFile < ExportSettings instance < explicit params.
|
|
|
|
.PARAMETER ExportSettings
|
|
An [IntuneManagerExportSettings] instance. If omitted, a fresh instance is
|
|
created (which loads defaults from user settings).
|
|
|
|
.PARAMETER SettingsFile
|
|
Path to a JSON file produced by the "Save settings for batch job" button.
|
|
Loaded first; any other explicit parameters override its values.
|
|
|
|
.PARAMETER ExportFolder
|
|
Root folder for the export. Overrides ExportSettings.ExportFolder.
|
|
|
|
.PARAMETER Filter
|
|
Name filter (literal substring, case-insensitive) matched against each
|
|
policy's Name. Empty = no filter.
|
|
|
|
.PARAMETER ExportAssignments
|
|
Include assignments in each exported file.
|
|
|
|
.PARAMETER AddCompanyName
|
|
Append the current tenant's organization display name as an additional folder
|
|
level under ExportFolder.
|
|
|
|
.PARAMETER PolicyType
|
|
Restrict export to these PolicyType IDs. If both PolicyType and PolicyGroup
|
|
are omitted, every group that allows export is processed.
|
|
|
|
.PARAMETER PolicyGroup
|
|
Restrict export to these PolicyGroup IDs (expanded to their member types).
|
|
|
|
.PARAMETER TokenId
|
|
Authentication token id. Defaults to the current default token.
|
|
|
|
.EXAMPLE
|
|
Start-GraphBulkExport -ExportFolder C:\IntuneExport -PolicyGroup DeviceConfiguration
|
|
|
|
.EXAMPLE
|
|
Start-GraphBulkExport -SettingsFile .\nightly-export.json
|
|
|
|
.OUTPUTS
|
|
PSCustomObject with summary statistics (Types, Policies, Failed, Duration).
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[IntuneManagerExportSettings]
|
|
$ExportSettings,
|
|
|
|
[string]
|
|
$SettingsFile,
|
|
|
|
[string]
|
|
$ExportFolder,
|
|
|
|
[string]
|
|
$Filter,
|
|
|
|
[Nullable[bool]]
|
|
$ExportAssignments,
|
|
|
|
[Nullable[bool]]
|
|
$AddCompanyName,
|
|
|
|
[string[]]
|
|
$PolicyType,
|
|
|
|
[string[]]
|
|
$PolicyGroup,
|
|
|
|
[Int]
|
|
$TokenId = (Get-DefaultTokenId)
|
|
)
|
|
|
|
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
|
|
|
# ---- 1. Resolve settings ----
|
|
$fileSettings = $null
|
|
if ($SettingsFile) {
|
|
if (-not (Test-Path -LiteralPath $SettingsFile)) {
|
|
throw "Settings file '$SettingsFile' not found"
|
|
}
|
|
try {
|
|
$fileSettings = [IO.File]::ReadAllText($SettingsFile) | ConvertFrom-Json
|
|
}
|
|
catch {
|
|
throw "Failed to parse settings file '$SettingsFile': $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
if (-not $ExportSettings) {
|
|
$ExportSettings = [IntuneManagerExportSettings]::new()
|
|
}
|
|
|
|
# ExportFullMembershipPrefixes was removed. A value can survive that in a
|
|
# settings file OR in the settings store (global and per-tenant), and none of
|
|
# them can be honoured any more - report them before the export starts rather
|
|
# than silently dropping membership data from the output. Runs whether or not
|
|
# a settings file was given, because a stored value needs no file to exist.
|
|
Clear-BulkExportLegacyMembershipSetting -FileSettings $fileSettings -SettingsFile $SettingsFile -TokenId $TokenId
|
|
|
|
# File overrides defaults; explicit params override file.
|
|
if ($fileSettings) {
|
|
if ($null -ne $fileSettings.ExportFolder) { $ExportSettings.ExportFolder = $fileSettings.ExportFolder }
|
|
if ($null -ne $fileSettings.Filter) { $ExportSettings.Filter = $fileSettings.Filter }
|
|
if ($null -ne $fileSettings.ExportAssignments) { $ExportSettings.ExportAssignments = [bool]$fileSettings.ExportAssignments }
|
|
if ($null -ne $fileSettings.AddCompanyName) { $ExportSettings.AddCompanyName = [bool]$fileSettings.AddCompanyName }
|
|
if ($null -ne $fileSettings.AddObjectType) { $ExportSettings.AddObjectType = [bool]$fileSettings.AddObjectType }
|
|
if ($null -ne $fileSettings.ExportNestedGroupLevels) {
|
|
$n = 0
|
|
if([int]::TryParse([string]$fileSettings.ExportNestedGroupLevels, [ref]$n) -and $n -ge 1) {
|
|
$ExportSettings.ExportNestedGroupLevels = $n
|
|
}
|
|
}
|
|
if (-not $PolicyType -and $fileSettings.PolicyType) { $PolicyType = @($fileSettings.PolicyType) }
|
|
if (-not $PolicyGroup -and $fileSettings.PolicyGroup) { $PolicyGroup = @($fileSettings.PolicyGroup) }
|
|
}
|
|
|
|
if ($PSBoundParameters.ContainsKey('ExportFolder')) { $ExportSettings.ExportFolder = $ExportFolder }
|
|
if ($PSBoundParameters.ContainsKey('Filter')) { $ExportSettings.Filter = $Filter }
|
|
if ($PSBoundParameters.ContainsKey('ExportAssignments')) { $ExportSettings.ExportAssignments = [bool]$ExportAssignments }
|
|
if ($PSBoundParameters.ContainsKey('AddCompanyName')) { $ExportSettings.AddCompanyName = [bool]$AddCompanyName }
|
|
|
|
if (-not $ExportSettings.ExportFolder) {
|
|
throw "ExportFolder is required (set on ExportSettings, -ExportFolder, or SettingsFile)"
|
|
}
|
|
|
|
# ---- 2. Determine target policy types ----
|
|
$selection = Resolve-IntuneTargetSelectors -PolicyType $PolicyType -PolicyGroup $PolicyGroup -Caller 'Start-GraphBulkExport'
|
|
$unknownSelectors = $selection.Unknown # ids from -PolicyType/-PolicyGroup that matched nothing
|
|
$targetTypes = [System.Collections.Generic.List[object]]::new()
|
|
$targetTypes.AddRange([object[]]$selection.Types)
|
|
|
|
if (-not $PolicyType -and -not $PolicyGroup) {
|
|
foreach ($grp in $script:IntuneGroups) {
|
|
if (-not $grp.Title) { continue }
|
|
if ($grp.ShowButtons -is [Object[]] -and $grp.ShowButtons -notcontains "Export") { continue }
|
|
foreach ($pt in $grp.PolicyTypes) { [void]$targetTypes.Add($pt) }
|
|
}
|
|
}
|
|
|
|
# De-duplicate (a type can belong to >1 group)
|
|
$targetTypes = @($targetTypes | Sort-Object -Property Id -Unique)
|
|
|
|
if ($targetTypes.Count -eq 0) {
|
|
Write-Log "Bulk export: no policy types selected" 2
|
|
return [PSCustomObject]@{ Types = 0; Policies = 0; Failed = 0; UnknownSelectors = $unknownSelectors; Duration = $stopwatch.Elapsed }
|
|
}
|
|
|
|
# Pre-compile the name filter once. A literal substring, like 3.x and every
|
|
# other bulk driver: '[Test]' means the text [Test], not a character class.
|
|
$filterRegex = $null
|
|
if ($ExportSettings.Filter) {
|
|
$filterRegex = [Regex]::new([Regex]::Escape($ExportSettings.Filter), 'IgnoreCase')
|
|
}
|
|
|
|
Write-Log "Bulk export: $($targetTypes.Count) policy type(s) → $($ExportSettings.ExportFolder)"
|
|
|
|
# Honour the ClearCacheBeforeExportImport setting (bit 1 = bulk export, on
|
|
# by default) so re-exports always fetch fresh dependency objects.
|
|
Invoke-GraphCacheClearBeforeOperation -Operation BulkExport -TokenId $TokenId
|
|
|
|
# Reset migration-table and AppConfig target-app caches at the start of every
|
|
# bulk export. The caches are designed to dedupe work WITHIN a single export
|
|
# (1 disk write per file at end, 1 Graph call per unique target app), so a
|
|
# stale carry-over from a previous export to a different folder/tenant would
|
|
# produce wrong contents in MigrationTable.json.
|
|
$script:_migFileCache = @{}
|
|
$script:_migFileObjectsIndex = @{}
|
|
$script:_migFileDirty = @{}
|
|
$script:_migFilePathCache = @{}
|
|
$script:_appConfigTargetAppCache = @{}
|
|
$script:_migPendingFetch = [System.Collections.Generic.List[object]]::new()
|
|
|
|
# Tell Export-GraphPolicy.End NOT to flush migration files per type — we batch
|
|
# the writes across the entire bulk export and flush once at the very end.
|
|
# Wrapped in try/finally so the flag always clears even if Phase 3 throws,
|
|
# otherwise the next standalone Export-GraphPolicy call would also skip its flush.
|
|
$script:_bulkExportDeferMigFlush = $true
|
|
$script:_skipDirectGet = $true
|
|
try {
|
|
|
|
# ---- 3. Export ----
|
|
$totalPolicies = 0
|
|
$failedTypes = 0
|
|
|
|
# One pipeline, whatever the concurrency setting:
|
|
# 1. List every type's objects in ONE Get-GraphPolicies call.
|
|
# 2. Batch-GET the full body for every listed object across all types.
|
|
# 2.5 Pre-cache every Entra group the export will reference.
|
|
# 2.6 Pre-walk the nested-group hierarchy when nested export is on.
|
|
# 3. Write each policy to disk type-by-type.
|
|
# Whether the $batch POSTs behind steps 1, 2, 2.5 and 2.6 go out one at a
|
|
# time or concurrently is decided inside Invoke-GraphBatchRequest from the
|
|
# UseParallelBatchAPI setting - it is not this function's concern. The old
|
|
# per-type interleaved loop that ran when that setting was off is gone: it
|
|
# skipped the group prefetch entirely, which cost a measured 22 minutes on a
|
|
# 963-object tenant (one direct GET per assignment group at ~1.6 s each,
|
|
# versus ~2 s for twenty of them in one $batch). See Docs/GraphBatching.md.
|
|
# Phase 1 — list ALL types in a single Get-GraphPolicies call so the listing
|
|
# round-trips ride the parallel-batch dispatcher (chunks of 20, throttle-many
|
|
# POSTs concurrently). Calling Get-GraphPolicies once per type instead would
|
|
# produce N single-item batches dispatched sequentially — for ~50 types that's
|
|
# ~50 round-trips at ~1s each. Combined it's ceil(N/20) rounds run in parallel.
|
|
Write-Status ("Bulk export - listing {0} policy type(s)" -f $targetTypes.Count) -SkipLog -Force
|
|
|
|
$perType = [ordered]@{}
|
|
foreach ($pt in $targetTypes) { $perType[$pt.Id] = [System.Collections.Generic.List[object]]::new() }
|
|
$allTypeIds = @($targetTypes | ForEach-Object { $_.Id })
|
|
|
|
try {
|
|
$allPolicies = @(Get-GraphPolicies -PolicyType $allTypeIds -TokenId $TokenId -IncludeAssignments:$ExportSettings.ExportAssignments -ErrorAction Stop)
|
|
}
|
|
catch {
|
|
Write-LogError "Bulk export: failed to list policies" $_.Exception
|
|
$failedTypes = $targetTypes.Count
|
|
$allPolicies = @()
|
|
}
|
|
|
|
# Bucket each returned policy back into its owning type so Phase 3 can iterate
|
|
# type-by-type (which keeps per-type folder routing + status messages intact).
|
|
# Dedup by policy Id within each bucket — some Graph endpoints have overlapping
|
|
# paging cursors so Get-GraphPolicies' AllPages merge can include the same item
|
|
# twice. Without this we'd write the same file (and run all subclass Get()
|
|
# extras) once per duplicate, multiplying the wall time for nothing.
|
|
$seenByType = @{}
|
|
foreach ($p in $allPolicies) {
|
|
$tid = $null
|
|
if ($p.PolicyType) { $tid = $p.PolicyType.Id }
|
|
if (-not ($tid -and $perType.Contains($tid))) { continue }
|
|
if (-not $seenByType.ContainsKey($tid)) {
|
|
$seenByType[$tid] = [System.Collections.Generic.HashSet[string]]::new()
|
|
}
|
|
$key = "$($p.Id)"
|
|
if (-not $seenByType[$tid].Add($key)) {
|
|
# Already seen this id under this type — skip duplicate.
|
|
continue
|
|
}
|
|
[void]$perType[$tid].Add($p)
|
|
}
|
|
|
|
if ($filterRegex) {
|
|
foreach ($tid in @($perType.Keys)) {
|
|
$filtered = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($p in $perType[$tid]) {
|
|
if ($filterRegex.IsMatch([string]$p.Name)) { [void]$filtered.Add($p) }
|
|
}
|
|
$perType[$tid] = $filtered
|
|
}
|
|
}
|
|
|
|
# Phase 2 — batch-fetch full objects across every type at once
|
|
$needFull = [System.Collections.Generic.List[object]]::new()
|
|
$allPoliciesFlat = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($pt in $targetTypes) {
|
|
$policies = $perType[$pt.Id]
|
|
if (-not $policies) { continue }
|
|
foreach ($p in $policies) {
|
|
[void]$allPoliciesFlat.Add($p)
|
|
if ($p.IsFullObject -eq $false -and $p.Id) { [void]$needFull.Add($p) }
|
|
}
|
|
}
|
|
if ($allPoliciesFlat.Count -gt 0) {
|
|
Write-Status "Bulk export - hydrating policies" -SkipLog -Force
|
|
Invoke-PolicyHydrate -Policies $allPoliciesFlat -TokenId $TokenId
|
|
}
|
|
|
|
# Phase 2.5 — pre-cache every AAD group the export will touch in one parallel
|
|
# batch. Sources: assignment targets (when ExportAssignments is on), plus CA
|
|
# conditions.users.includeGroups/excludeGroups and compliance notificationMessageCCList
|
|
# (always exported, so always worth prefetching). Without this, the per-policy
|
|
# PostExportCommand path does a sequential Graph GET per unknown groupId during
|
|
# Phase 3 — hundreds of single-item round-trips for a CA-heavy tenant.
|
|
if ($allPoliciesFlat.Count -gt 0) {
|
|
Sync-BulkExportMigrationGroups -Policies $allPoliciesFlat -TokenId $TokenId
|
|
}
|
|
|
|
# Phase 2.6 — when nested-group export is on, walk the hierarchy in batches
|
|
# and cache each parent's child-group list (plus any new child bodies) so the
|
|
# per-policy recursion inside Add-GraphMigrationObject is pure cache hits.
|
|
# Skipped when ExportNestedGroupLevels = 1 (no recursion happens then).
|
|
if ($ExportSettings.ExportNestedGroupLevels -gt 1) {
|
|
Sync-BulkExportNestedGroupHierarchy -MaxDepth $ExportSettings.ExportNestedGroupLevels -TokenId $TokenId
|
|
}
|
|
|
|
# Phase 3 — write to disk (sequential I/O; Get() inside Export-GraphPolicy is a no-op
|
|
# for non-override types because Phase 2 already pre-populated them). Status updates
|
|
# per type so the bar doesn't freeze for the duration of the write phase — without
|
|
# these the user sees the last Phase 2 status message for minutes on end.
|
|
$totalToWrite = 0
|
|
foreach ($pt in $targetTypes) { $totalToWrite += $perType[$pt.Id].Count }
|
|
$writeProgress = 0
|
|
$typeIndex = 0
|
|
|
|
# Once, across EVERY type - not per type inside the loop below. Two policies
|
|
# collide when they resolve to the same destination path, and the folder is part
|
|
# of that path: with AddObjectType off every type writes into the export root, so
|
|
# a per-type pass never compared a Compliance policy against a Configuration
|
|
# policy of the same name and one silently overwrote the other.
|
|
Set-BulkExportFilenameCollisionFlag -Policies $allPoliciesFlat `
|
|
-AddObjectType:($ExportSettings.AddObjectType -eq $true)
|
|
|
|
foreach ($pt in $targetTypes) {
|
|
$typeIndex++
|
|
$policies = $perType[$pt.Id]
|
|
if (-not $policies -or $policies.Count -eq 0) {
|
|
Write-Log "Bulk export: no $($pt.Title) objects matched"
|
|
continue
|
|
}
|
|
Write-Status `
|
|
-Text ("Bulk export - {0} ({1} of {2})" -f $pt.Title, $typeIndex, $targetTypes.Count) `
|
|
-Detail ("Writing {0} object(s) · {1} of {2} total" -f $policies.Count, $writeProgress, $totalToWrite) `
|
|
-SkipLog -Force
|
|
try {
|
|
$policies | Export-GraphPolicy -ExportSettings $ExportSettings
|
|
$totalPolicies += $policies.Count
|
|
$writeProgress += $policies.Count
|
|
}
|
|
catch {
|
|
$failedTypes++
|
|
Write-LogError "Bulk export: failed while exporting $($pt.Title)" $_.Exception
|
|
}
|
|
}
|
|
|
|
# Flush every MigrationTable.json that Add-GraphMigrationObject mutated. The
|
|
# in-memory cache batched all the per-policy mutations during Phase 3; this one
|
|
# call writes each touched file to disk exactly once. Wrapped in try so any
|
|
# disk error doesn't mask the export-finished status.
|
|
try { Save-GraphMigrationFilesPending } catch {
|
|
Write-LogError "Bulk export: failed to flush migration table(s)" $_.Exception
|
|
}
|
|
|
|
}
|
|
finally {
|
|
# Clear the per-type-flush guard so future standalone Export-GraphPolicy
|
|
# calls flush normally in their End block. Runs even on Phase 3 exceptions.
|
|
$script:_bulkExportDeferMigFlush = $false
|
|
$script:_skipDirectGet = $false
|
|
}
|
|
|
|
Write-Status $null
|
|
|
|
# ---- 4. Persist user-settings (round-trip the form's "remember last used") ----
|
|
try { $ExportSettings.Save() } catch { }
|
|
# Persist to the SAME SubPath these keys are registered under ("IntuneManager") so
|
|
# Get-SettingValue reads them back - a root-path write here was a dead location
|
|
# (same bug the import side fixed; see IntuneBaseClasses.ps1 ImportSettings.Save).
|
|
Save-SettingStoreValue "IntuneManager" "AddCompanyName" $ExportSettings.AddCompanyName
|
|
Save-SettingStoreValue "IntuneManager" "ExportAssignments" $ExportSettings.ExportAssignments
|
|
Save-SettingStoreValue "IntuneManager" "ExportNestedGroupLevels" $ExportSettings.ExportNestedGroupLevels
|
|
|
|
$stopwatch.Stop()
|
|
$summary = [PSCustomObject]@{
|
|
Types = $targetTypes.Count
|
|
Policies = $totalPolicies
|
|
Failed = $failedTypes
|
|
UnknownSelectors = $unknownSelectors
|
|
Duration = $stopwatch.Elapsed
|
|
}
|
|
Write-Log ("Bulk export finished. Types={0} Policies={1} Failed={2} Duration={3}" -f `
|
|
$summary.Types, $summary.Policies, $summary.Failed, $summary.Duration)
|
|
return $summary
|
|
}
|