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. Partial exports emit a warning and retain status, requested resources and errorDetails in Snapshot-{jobId}.diagnostics.json beside the exported files. The existing snapshot JSON/CSV shapes and returned snapshot-file paths are unchanged. 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; formula-like text cells are prefixed with an apostrophe for spreadsheet safety HTML — self-contained, searchable and filterable report of all configuration items (grouped by workload), with expandable per-item configuration JSON, column chooser and CSV/JSON downloads 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 snapshot file path(s) written (the diagnostics sidecar is announced separately). #> [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 -ReadOnly } # Resolve the job object from input $job = $null if ($Snapshot -is [string] -or $Snapshot -is [guid]) { $job = Get-UTCMSnapshot -SnapshotId ([string]$Snapshot) -IncludeDetails } 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 = Get-UTCMSnapshot -SnapshotId $job.id -IncludeDetails _WriteLog ("Export poll: job $($job.id) status = $($job.status)") 'Gray' } $jobFields = if ($job -is [System.Collections.IDictionary]) { @($job.Keys) } else { @($job.PSObject.Properties.Name) } if ($job.status -eq 'partiallySuccessful' -and ('errorDetails' -notin $jobFields -or 'resources' -notin $jobFields -or 'resourceLocation' -notin $jobFields)) { $job = Get-UTCMSnapshot -SnapshotId $job.id -IncludeDetails } $diagnostic = Get-UTCMSnapshotDiagnostic -Snapshot $job # If job failed, optionally write error-details file, then throw if ($job.status -eq 'failed') { if ($WriteErrorFileOnFailure) { _WriteErrorJson -job $job -errPath $ErrorPath } throw ("Snapshot job '{0}' failed. Errors: {1}" -f $job.id, $diagnostic.errorSummary) } # Validate exportable statuses if ($job.status -notin @('succeeded','partiallySuccessful')) { if ($WriteErrorFileOnFailure) { _WriteErrorJson -job $job -errPath $ErrorPath } throw ("Snapshot job '{0}' is not exportable. Status: '{1}'. {2}" -f $job.id, $job.status, $diagnostic.errorSummary) } # Ensure resourceLocation exists $resourceLocation = [string](Get-UTCMObjectField $job @('resourceLocation')) if ([string]::IsNullOrWhiteSpace($resourceLocation)) { if ($WriteErrorFileOnFailure) { _WriteErrorJson -job $job -errPath $ErrorPath } throw "Snapshot job '$($job.id)' has no resourceLocation. Unable to export." } $downloadUri = Resolve-UTCMGraphUri -Uri $resourceLocation $diagnosticsPath = Join-Path -Path $Path -ChildPath ("Snapshot-{0}.diagnostics.json" -f $job.id) if ($diagnostic.status -eq 'partiallySuccessful' -and (Test-Path -LiteralPath $diagnosticsPath) -and -not $Overwrite) { throw "Diagnostics file '$diagnosticsPath' already exists. Use -Overwrite to replace it." } # Download only from the connected Graph endpoint, using Graph authentication. $tmp = $null $outPaths = @() try { if (-not $PSCmdlet.ShouldProcess("Snapshot job $($job.id)", "Download and export UTCM snapshot")) { return } $tmp = [System.IO.Path]::GetTempFileName() _EnsureDirectory $Path if ($diagnostic.status -eq 'partiallySuccessful') { Export-UTCMSnapshotDiagnostic -Diagnostic @($diagnostic) -Path $diagnosticsPath -Overwrite:$Overwrite | Out-Null Write-Warning ("Snapshot job '{0}' is partiallySuccessful; this export is incomplete. {1} Diagnostics: {2}" -f $job.id, $diagnostic.errorSummary, $diagnosticsPath) } Invoke-GraphRequestWithRetry -Method GET -Uri $downloadUri -OutputFilePath $tmp # 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 -NoEnumerate -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) { ConvertTo-Json -InputObject $json -Depth 99 | Out-File -LiteralPath $jsonPath -Encoding UTF8 } elseif (@($items).Count -eq 0) { '[]' | 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") if (@($items).Count -eq 0) { '"Id","DisplayName","Type","Workload","Data"' | Out-File -LiteralPath $csvPath -Encoding UTF8 } else { $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 = ConvertTo-UTCMCsvCell (_GetItemId $_) DisplayName = ConvertTo-UTCMCsvCell $_.displayName Type = ConvertTo-UTCMCsvCell $typeVal Workload = ConvertTo-UTCMCsvCell $wl Data = ConvertTo-UTCMCsvCell $dataStr } } | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8 } _WriteLog ("CSV -> $csvPath") 'Green' $outPaths += $csvPath } # HTML (shared report foundation: self-contained, searchable, filterable, paged, accessible) if ('HTML' -in $Format) { $htmlPath = Join-Path -Path $Path -ChildPath ("$baseName.html") $html = Format-UTCMSnapshotReportHtml -Items $items ` -SnapshotName ([string](Get-UTCMObjectField $job @('displayName'))) -SnapshotId ([string]$job.id) ` -Created (Get-UTCMObjectField $job @('createdDateTime')) -Status ([string]$job.status) ` -SnapshotDiagnostic $diagnostic ` -GeneratedUtc ((Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') + ' UTC') $fullHtmlPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($htmlPath) [System.IO.File]::WriteAllText($fullHtmlPath, $html, (New-Object System.Text.UTF8Encoding($false))) _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 ($tmp -and (Test-Path -LiteralPath $tmp)) { Remove-Item -LiteralPath $tmp -Force -Confirm:$false -WhatIf:$false -ErrorAction SilentlyContinue } } } |