Public/Export-UTCMSnapshot.ps1
|
function Export-UTCMSnapshot { <# .SYNOPSIS Download and export a completed UTCM snapshot to disk in JSON, CSV, and/or HTML format. .DESCRIPTION Accepts either a configurationSnapshotJob object (as returned by New-UTCMSnapshot) or a job Id (GUID). If the job isn't completed yet, this function polls until a terminal status is reached. It then downloads from job.resourceLocation and writes the requested formats. Output formats (all enabled by default — use -Format to choose a subset): JSON — configurationItems array (or full payload with -Raw) CSV — flat table with Id, DisplayName, Type, Workload, Data columns HTML — self-contained sortable dashboard of all configuration items Path behaviour: Path is always treated as a DIRECTORY (created if missing). Files are named Snapshot-{jobId}.{ext}. Legacy switches: -SplitByResourceType creates one JSON file per resource instance under <Path>\{workload}\{resourceType}\{name|id}.json (only JSON, ignores -Format). .PARAMETER Snapshot A configurationSnapshotJob object or a job Id (GUID/string). .PARAMETER Path Destination directory. Created if it does not exist. .PARAMETER Format Which formats to write. Default: JSON, CSV, HTML. E.g. -Format JSON,CSV .PARAMETER PollingIntervalSeconds Delay between status polls if the job is not yet complete. (5..300, default 10) .PARAMETER Raw Write the FULL JSON payload rather than just 'configurationItems'. .PARAMETER SplitByResourceType Write one JSON file per resource instance (ignores -Format). .PARAMETER NameFieldOrder Field precedence for per-file names when splitting. Default: displayName, name, id. .PARAMETER Overwrite Overwrite existing files. .PARAMETER WriteErrorFileOnFailure Write an error JSON file when a job has failed. .PARAMETER ErrorPath Path for the error JSON file. .OUTPUTS String[] — the file path(s) written. #> [CmdletBinding(SupportsShouldProcess=$true)] param( [Parameter(Mandatory)] $Snapshot, [Parameter(Mandatory)][string] $Path, [ValidateSet('JSON','CSV','HTML')] [string[]] $Format = @('JSON','CSV','HTML'), [ValidateRange(5,300)][int] $PollingIntervalSeconds = 10, [switch] $Raw, [switch] $SplitByResourceType, [string[]] $NameFieldOrder = @('displayName','name','id'), [switch] $Overwrite, [switch] $WriteErrorFileOnFailure, [string] $ErrorPath ) # --- Local helpers ------------------------------------------------------- function _WriteLog([string]$msg, [string]$color='Gray') { if (Get-Command -Name Write-Log -ErrorAction SilentlyContinue) { Write-Log -Message $msg -Color $color } else { Write-Host ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $msg) -ForegroundColor $color } } function _Sanitize([string]$name) { if ([string]::IsNullOrWhiteSpace($name)) { return $null } $invalidChars = [IO.Path]::GetInvalidFileNameChars() -join '' $charClass = '[{0}]' -f [regex]::Escape($invalidChars) $safe = ($name -replace $charClass, '_').Trim() if ($safe.Length -gt 150) { $safe = $safe.Substring(0,150) } return $safe } function _WorkloadFromResourceType([string]$resourceType) { if ([string]::IsNullOrWhiteSpace($resourceType)) { return 'unknown' } if ($resourceType -match '^microsoft\.([a-z0-9]+)') { return $Matches[1] } return 'unknown' } function _EnsureDirectory([string]$dir) { if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } elseif (-not (Get-Item -LiteralPath $dir).PSIsContainer) { throw "Path '$dir' exists and is not a directory." } } function _WriteErrorJson($job, [string]$errPath) { try { $payload = [pscustomobject]@{ jobId = $job.id status = $job.status errorDetails = $job.errorDetails createdDate = $job.createdDateTime completedDate = $job.completedDateTime } $target = $errPath if ([string]::IsNullOrWhiteSpace($target)) { $target = Join-Path -Path (Split-Path -Parent $Path) -ChildPath ("snapshot-{0}-error.json" -f $job.id) } elseif ((Test-Path -LiteralPath $target) -and (Get-Item -LiteralPath $target).PSIsContainer) { $target = Join-Path -Path $target -ChildPath ("snapshot-{0}-error.json" -f $job.id) } else { $parent = Split-Path -Path $target -Parent if ($parent) { _EnsureDirectory $parent } } $payload | ConvertTo-Json -Depth 10 | Out-File -LiteralPath $target -Encoding UTF8 _WriteLog ("Wrote job failure details to '$target'") 'Yellow' } catch { _WriteLog ("Failed to write failure details: $($_.Exception.Message)") 'Red' } } # ------------------------------------------------------------------------- # Ensure Graph connection if your module provides the helper if (Get-Command -Name Ensure-GraphConnection -ErrorAction SilentlyContinue) { Ensure-GraphConnection } # Resolve the job object from input $job = $null if ($Snapshot -is [string]) { $jobId = $Snapshot $job = Invoke-GraphRequestWithRetry -Method 'GET' -Uri "$($script:SnapshotJobsUri)/$jobId" } else { $job = $Snapshot } # If the job isn't terminal yet, poll until it completes while ($job.status -in @('notStarted','running')) { Start-Sleep -Seconds $PollingIntervalSeconds $job = Invoke-GraphRequestWithRetry -Method 'GET' -Uri "$($script:SnapshotJobsUri)/$($job.id)" _WriteLog ("Export poll: job $($job.id) status = $($job.status)") 'Gray' } # If job failed, optionally write error-details file, then throw if ($job.status -eq 'failed') { if ($WriteErrorFileOnFailure) { _WriteErrorJson -job $job -errPath $ErrorPath } $errors = ($job.PSObject.Properties.Name -contains 'errorDetails') ? ($job.errorDetails -join '; ') : $null $errText = if ($errors) { $errors } else { 'n/a' } throw ("Snapshot job '{0}' failed. Errors: {1}" -f $job.id, $errText) } # Validate exportable statuses if ($job.status -notin @('succeeded','partiallySuccessful')) { $errors = ($job.PSObject.Properties.Name -contains 'errorDetails') ? ($job.errorDetails -join '; ') : $null $errText = if ($errors) { $errors } else { '' } if ($WriteErrorFileOnFailure) { _WriteErrorJson -job $job -errPath $ErrorPath } throw ("Snapshot job '{0}' is not exportable. Status: '{1}'. {2}" -f $job.id, $job.status, $errText) } # Ensure resourceLocation exists if ([string]::IsNullOrWhiteSpace($job.resourceLocation)) { if ($WriteErrorFileOnFailure) { _WriteErrorJson -job $job -errPath $ErrorPath } throw "Snapshot job '$($job.id)' has no resourceLocation. Unable to export." } # Prepare destination directory (Path is always a directory now) _EnsureDirectory $Path # Download to a temp file first (resourceLocation is a Graph API URL that requires auth) $tmp = [System.IO.Path]::GetTempFileName() $outPaths = @() try { if ($PSCmdlet.ShouldProcess($job.resourceLocation, "Download UTCM snapshot")) { Invoke-MgGraphRequest -Method GET -Uri $job.resourceLocation -OutputFilePath $tmp -ErrorAction Stop } # Try to interpret as JSON; if not JSON (e.g., ZIP), write raw file and return. $content = Get-Content -LiteralPath $tmp -Raw -ErrorAction Stop $json = $null $isJson = $false try { $json = $content | ConvertFrom-Json -ErrorAction Stop $isJson = $true } catch { $isJson = $false } if (-not $isJson) { $rawPath = Join-Path -Path $Path -ChildPath ("Snapshot-{0}.bin" -f $job.id) Copy-Item -LiteralPath $tmp -Destination $rawPath -Force _WriteLog ("Exported raw UTCM snapshot to '{0}' (job {1}, status {2})" -f $rawPath, $job.id, $job.status) 'Green' return $rawPath } # Extract items. The UTCM snapshot payload uses 'resources' (with 'resourceType' / # 'properties' fields); older/alternate payloads may expose 'configurationItems' # (with 'type' / 'data' fields). Support both and normalize downstream. $items = $null if ($json.PSObject.Properties.Name -contains 'configurationItems') { $items = $json.configurationItems } elseif ($json.PSObject.Properties.Name -contains 'resources') { $items = $json.resources } elseif ($json -is [System.Collections.IEnumerable]) { $items = $json } else { $items = @($json) } # Local helpers that understand both field shapes function _GetItemId($item) { if ($item.PSObject.Properties.Name -contains 'id' -and $item.id) { return [string]$item.id } if ($item.PSObject.Properties.Name -contains 'resourceInstanceIdentifier' -and $item.resourceInstanceIdentifier) { return [string]$item.resourceInstanceIdentifier } # Prefer a stable Id from properties when available if ($item.PSObject.Properties.Name -contains 'properties' -and $item.properties) { foreach ($p in 'Id','Identity','Guid','ObjectId') { if ($item.properties.PSObject.Properties.Name -contains $p -and $item.properties.$p) { return [string]$item.properties.$p } } } return '' } function _GetItemType($item) { if ($item.PSObject.Properties.Name -contains 'resourceType' -and $item.resourceType) { return [string]$item.resourceType } if ($item.PSObject.Properties.Name -contains 'type' -and $item.type) { return [string]$item.type } return '' } function _GetItemDataObject($item) { if ($item.PSObject.Properties.Name -contains 'properties') { return $item.properties } if ($item.PSObject.Properties.Name -contains 'data') { return $item.data } return $null } # --- SplitByResourceType (JSON only, existing behaviour) --- if ($SplitByResourceType) { foreach ($item in $items) { $rt = $null if ($item.PSObject.Properties.Name -contains 'resourceType') { $rt = [string]$item.resourceType } elseif ($item.PSObject.Properties.Name -contains 'type') { $rt = [string]$item.type } $workload = $null if ($item.PSObject.Properties.Name -contains 'workload') { $workload = [string]$item.workload } if (-not $workload) { $workload = _WorkloadFromResourceType $rt } $safeWorkload = _Sanitize($workload) ?? 'unknown' $safeRt = _Sanitize($rt) ?? 'unknown' $base = $null foreach ($f in $NameFieldOrder) { if ($item.PSObject.Properties.Name -contains $f) { $val = [string]($item.($f)) $cand = _Sanitize($val) if ($cand) { $base = $cand; break } } } if (-not $base -and $item.PSObject.Properties.Name -contains 'id') { $base = _Sanitize([string]$item.id) } if (-not $base) { $base = [guid]::NewGuid().Guid } $subDir = Join-Path -Path $Path -ChildPath (Join-Path $safeWorkload $safeRt) _EnsureDirectory $subDir $filePath = Join-Path -Path $subDir -ChildPath ($base + '.json') if ((Test-Path -LiteralPath $filePath) -and -not $Overwrite) { $filePath = Join-Path -Path $subDir -ChildPath ($base + '-' + ([guid]::NewGuid().ToString('N').Substring(0,6)) + '.json') } $item | ConvertTo-Json -Depth 99 | Out-File -LiteralPath $filePath -Encoding UTF8 $outPaths += $filePath } _WriteLog ("Exported {0} items under '{1}' (job {2}, status {3})" -f $outPaths.Count, $Path, $job.id, $job.status) 'Green' return $outPaths } # --- Multi-format export ----------------------------------------------- $baseName = "Snapshot-{0}" -f $job.id # JSON if ('JSON' -in $Format) { $jsonPath = Join-Path -Path $Path -ChildPath ("$baseName.json") if ($Raw) { $json | ConvertTo-Json -Depth 99 | Out-File -LiteralPath $jsonPath -Encoding UTF8 } else { $items | ConvertTo-Json -Depth 99 | Out-File -LiteralPath $jsonPath -Encoding UTF8 } _WriteLog ("JSON -> $jsonPath") 'Green' $outPaths += $jsonPath } # CSV if ('CSV' -in $Format) { $csvPath = Join-Path -Path $Path -ChildPath ("$baseName.csv") $items | ForEach-Object { $typeVal = _GetItemType $_ $wl = if ($_.PSObject.Properties.Name -contains 'workload' -and $_.workload) { [string]$_.workload } else { _WorkloadFromResourceType $typeVal } $dataObj = _GetItemDataObject $_ $dataStr = if ($null -ne $dataObj) { try { $dataObj | ConvertTo-Json -Depth 99 -Compress } catch { [string]$dataObj } } else { '' } [pscustomobject]@{ Id = _GetItemId $_ DisplayName = $_.displayName Type = $typeVal Workload = $wl Data = $dataStr } } | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8 _WriteLog ("CSV -> $csvPath") 'Green' $outPaths += $csvPath } # HTML if ('HTML' -in $Format) { $htmlPath = Join-Path -Path $Path -ChildPath ("$baseName.html") # Safe HTML encoder $encodeFn = if (Get-Command -Name HtmlEncode -ErrorAction SilentlyContinue) { { param($v) HtmlEncode $v } } else { { param($v) if ($null -eq $v) { '' } else { [System.Net.WebUtility]::HtmlEncode([string]$v) } } } $snapshotName = '' $createdStr = '' try { if ($job.PSObject.Properties.Name -contains 'displayName') { $snapshotName = $job.displayName } if ($job.PSObject.Properties.Name -contains 'createdDateTime') { $createdStr = (Get-Date $job.createdDateTime).ToString('yyyy-MM-dd HH:mm:ss') } } catch {} # Group items by workload for summary $groups = @{} foreach ($item in $items) { $typeVal = _GetItemType $item if (-not $typeVal) { $typeVal = 'unknown' } $wl = if ($item.PSObject.Properties.Name -contains 'workload' -and $item.workload) { [string]$item.workload } else { _WorkloadFromResourceType $typeVal } if (-not $groups.ContainsKey($wl)) { $groups[$wl] = 0 } $groups[$wl]++ } $summarySpans = ($groups.GetEnumerator() | Sort-Object Name | ForEach-Object { "<span style='background:#e0e0e0;padding:4px 8px;border-radius:4px;display:inline-block;margin:2px 4px'>" + (& $encodeFn $_.Name) + ": $($_.Value)</span>" }) -join '' $rowHtml = foreach ($item in $items) { $typeVal = _GetItemType $item $wl = if ($item.PSObject.Properties.Name -contains 'workload' -and $item.workload) { [string]$item.workload } else { _WorkloadFromResourceType $typeVal } $dataObj = _GetItemDataObject $item $dataJson = '' if ($null -ne $dataObj) { try { $dataJson = $dataObj | ConvertTo-Json -Depth 99 } catch { $dataJson = [string]$dataObj } } $rowIdx = [guid]::NewGuid().ToString('N').Substring(0,8) $idVal = _GetItemId $item "<tr> <td>$(& $encodeFn $idVal)</td> <td>$(& $encodeFn $item.displayName)</td> <td>$(& $encodeFn $typeVal)</td> <td>$(& $encodeFn $wl)</td> <td><button class='toggleBtn' onclick=""toggleDetail('d$rowIdx')"">Show</button> <div id='d$rowIdx' class='detail' style='display:none'><pre>$(& $encodeFn $dataJson)</pre></div></td> </tr>" } $html = @" <html> <head> <meta charset="utf-8"> <title>UTCM Snapshot Export</title> <style> body { font-family: Arial, Helvetica, sans-serif; margin: 20px; } h2 { margin-bottom: 6px; } small { color: #666; } table { border-collapse: collapse; width: 100%; margin-top: 10px; } th, td { padding: 8px; border: 1px solid #ccc; text-align: left; vertical-align: top; } th { background: #333; color: #fff; cursor: pointer; } tr:nth-child(even) { background: #f9f9f9; } tr:hover { background: #eef; } .summary { margin-top: 10px; font-size: 13px; } .toggleBtn { font-size: 11px; padding: 2px 8px; cursor: pointer; } .detail pre { background: #f4f4f4; padding: 8px; border-radius: 4px; white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow: auto; font-size: 12px; margin-top: 4px; } </style> <script> function sortTable(n) { var table = document.getElementById("snapTable"); var switching = true, dir = "asc"; while (switching) { switching = false; var rows = table.rows; for (var i = 1; i < (rows.length - 1); i++) { var x = rows[i].getElementsByTagName("TD")[n]; var y = rows[i + 1].getElementsByTagName("TD")[n]; if ((dir == "asc" && x.textContent.toLowerCase() > y.textContent.toLowerCase()) || (dir == "desc" && x.textContent.toLowerCase() < y.textContent.toLowerCase())) { rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); switching = true; break; } } if (!switching && dir == "asc") { dir = "desc"; switching = true; } } } function toggleDetail(id) { var el = document.getElementById(id); var btn = el.parentElement.querySelector('.toggleBtn'); if (el.style.display === 'none') { el.style.display = 'block'; btn.textContent = 'Hide'; } else { el.style.display = 'none'; btn.textContent = 'Show'; } } function expandAll() { document.querySelectorAll('.detail').forEach(function(el){ el.style.display='block'; el.parentElement.querySelector('.toggleBtn').textContent='Hide'; }); } function collapseAll() { document.querySelectorAll('.detail').forEach(function(el){ el.style.display='none'; el.parentElement.querySelector('.toggleBtn').textContent='Show'; }); } </script> </head> <body> <h2>UTCM Snapshot Export</h2> <small> Snapshot: $(& $encodeFn $snapshotName) ($(& $encodeFn $job.id)) | Created: $(& $encodeFn $createdStr) | Status: $(& $encodeFn $job.status) | Items: $($items.Count) | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') </small> <div class="summary">$summarySpans</div> <p style="margin-top:8px"><button onclick="expandAll()">Expand All</button> <button onclick="collapseAll()">Collapse All</button></p> <table id="snapTable"> <thead> <tr> <th onclick="sortTable(0)">ID</th> <th onclick="sortTable(1)">Display Name</th> <th onclick="sortTable(2)">Type</th> <th onclick="sortTable(3)">Workload</th> <th>Settings</th> </tr> </thead> <tbody> $($rowHtml -join "`n") </tbody> </table> </body> </html> "@ $html | Out-File -LiteralPath $htmlPath -Encoding UTF8 _WriteLog ("HTML -> $htmlPath") 'Green' $outPaths += $htmlPath } _WriteLog ("Exported snapshot {0} ({1} format(s)) to '{2}'" -f $job.id, $outPaths.Count, $Path) 'Green' return $outPaths } catch { if ($WriteErrorFileOnFailure) { _WriteErrorJson -job $job -errPath $ErrorPath } throw } finally { if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } } } |