Created
April 14, 2025 19:13
-
-
Save SMSAgentSoftware/5306e1138ac4beaa0f7681846349d1a5 to your computer and use it in GitHub Desktop.
Uses Microsoft Graph Windows Updates API to build a 'catalog' of Quality and Feature updates, Windows editions, servicing periods and known issues.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
## ########################################################################################### | |
## Azure Automation Runbook to retrieve Windows Update Catalog entries from Microsoft Graph ## | |
############################################################################################## | |
#region ------------------------------------- Permissions ------------------------------------- | |
# This runbook requires the following permissions: | |
# Delegated permissions: | |
# - WindowsUpdates.ReadWrite.All | |
# - Member of the'Intune Administrator' or 'Windows Update Deployment Administrator' Entra role | |
# Application permissions: | |
# - WindowsUpdates.ReadWrite.All | |
#endregion ------------------------------------------------------------------------------------ | |
#region ------------------------------------- Parameters -------------------------------------- | |
$ProgressPreference = 'SilentlyContinue' | |
#endregion ------------------------------------------------------------------------------------ | |
#region ------------------------------------- Functions --------------------------------------- | |
Function script:Invoke-WebRequestPro { | |
Param ($URL,$Headers,$Method) | |
try | |
{ | |
$WebRequest = Invoke-WebRequest -Uri $URL -Method $Method -Headers $Headers -UseBasicParsing | |
} | |
catch | |
{ | |
$Response = $_ | |
$WebRequest = [PSCustomObject]@{ | |
Message = $response.Exception.Message | |
StatusCode = $response.Exception.Response.StatusCode | |
StatusDescription = $response.Exception.Response.StatusDescription | |
} | |
} | |
Return $WebRequest | |
} | |
# Function to get all entries in the catalog | |
Function Get-WUCatalogEntries { | |
[CmdletBinding()] | |
param( | |
[Parameter(Mandatory=$true)] | |
[ValidateSet('Quality','Feature','Driver')] | |
[string]$filter | |
) | |
switch ($filter) { | |
Quality { $filterstring = 'microsoft.graph.windowsUpdates.qualityUpdateCatalogEntry' } | |
Feature { $filterstring = 'microsoft.graph.windowsUpdates.featureUpdateCatalogEntry' } | |
# Driver { $filterstring = 'microsoft.graph.windowsUpdates.driverUpdateCatalogEntry' } # doesn't work yet | |
} | |
$URL = "https://graph.microsoft.com/beta/admin/windows/updates/catalog/entries?`$filter=isof('$filterstring')" | |
$headers = @{'Authorization'="Bearer " + $GraphToken} | |
$GraphRequest = Invoke-WebRequestPro -URL $URL -Headers $headers -Method GET | |
return $GraphRequest | |
} | |
# Function to get a specific entry in the catalog | |
Function Get-WUCatalogEntry { | |
[CmdletBinding()] | |
param( | |
[Parameter(Mandatory=$true)] | |
[string]$id | |
) | |
$URL = "https://graph.microsoft.com/beta/admin/windows/updates/products/FindByCatalogId(catalogID='$id')?expand=revisions(`$expand=knowledgeBaseArticle),knownIssues(`$expand=originatingKnowledgeBaseArticle,resolvingKnowledgeBaseArticle)" | |
$headers = @{'Authorization'="Bearer " + $GraphToken} | |
$GraphRequest = Invoke-WebRequestPro -URL $URL -Headers $headers -Method GET | |
return $GraphRequest | |
} | |
# Function to get Windows Product Editions | |
Function Get-WindowsProductEditions { | |
$URL = "https://graph.microsoft.com/beta/admin/windows/updates/products?expand=editions" | |
$headers = @{'Authorization'="Bearer " + $GraphToken} | |
$GraphRequest = Invoke-WebRequestPro -URL $URL -Headers $headers -Method GET | |
return $GraphRequest | |
} | |
#endregion ------------------------------------------------------------------------------------ | |
#region ------------------------------------- Authentication ---------------------------------- | |
# For testing | |
$script:GraphToken = Get-EntraAccessToken # https://gist.github.com/SMSAgentSoftware/e0737d683d4301767362c2a9587fd09e | |
# Managed identity authentication | |
#$null = Connect-AzAccount -Identity | |
#$script:GraphToken = (Get-AzAccessToken -ResourceTypeName MSGraph -AsSecureString -ErrorAction Stop).Token | ConvertFrom-SecureString -AsPlainText | |
#endregion ------------------------------------------------------------------------------------ | |
#region ------------------------------------- Quality Updates --------------------------------- | |
Write-Output "Retrieving Quality Update Catalog Entries..." | |
$CatalogEntries = Get-WUCatalogEntries -filter Quality | |
if ($CatalogEntries.StatusCode -ne 200) { | |
Write-Error "Error retrieving catalog: $($CatalogEntries.StatusCode) $($CatalogEntries.StatusDescription)" | |
return | |
} | |
$Catalog = ($CatalogEntries.Content | ConvertFrom-Json).value | |
if ($Catalog.Count -eq 0) { | |
Write-Error "No catalog entries found." | |
return | |
} | |
# List containers | |
$CatalogList = [System.Collections.Generic.List[PSCustomObject]]::new() | |
$RevisionList = [System.Collections.Generic.List[PSCustomObject]]::new() | |
$KnownIssueList = [System.Collections.Generic.List[PSCustomObject]]::new() | |
# Process each catalog entry | |
Write-Output "Processing Quality Update Catalog Entries..." | |
foreach ($entry in $Catalog) { | |
# Add to the CatalogList | |
$CatalogList.Add($entry) | |
if ($entry.qualityUpdateCadence -ne "unknownFutureValue") # items with an 'unknownFutureValue' have no revisions | |
{ | |
# Get the individual catalog entry | |
$UpdateResponse = Get-WUCatalogEntry -id $entry.Id | |
if ($UpdateResponse.StatusCode -ne 200) { | |
Write-Error "Error retrieving catalog entry for '$($entry.displayName)': $($UpdateResponse.StatusCode) $($UpdateResponse.StatusDescription)" | |
continue | |
} | |
$Update = ($UpdateResponse.Content | ConvertFrom-Json).value | |
# Process each update | |
foreach ($item in $Update) | |
{ | |
if ($item.name -notmatch "Server") | |
{ | |
# Extract the revisions | |
[array]$revisions = $item.revisions | |
foreach ($revision in $revisions) | |
{ | |
$RevisionObject = [PSCustomObject]@{ | |
catalogUpdateId = $entry.id | |
revisionId = $item.id | |
revisionName = $item.name | |
revisionGroupName = $item.groupName | |
fullBuildNumber = $revision.id | |
displayName = $revision.displayName | |
releaseDateTime = $revision.releaseDateTime | |
isHotPatchUpdate = $revision.isHotPatchUpdate | |
version = $revision.version | |
product = $revision.product | |
buildNumber = $revision.osBuild.buildNumber | |
updateBuildRevision = $revision.osBuild.updateBuildRevision | |
knowledgeBaseArticleId = $revision.knowledgeBaseArticle.id | |
knowledgeBaseArticleUrl = $revision.knowledgeBaseArticle.Url | |
} | |
$RevisionList.Add($RevisionObject) | |
} | |
# Extract the known issues | |
[array]$knownIssues = $item.knownIssues | |
foreach ($knownIssue in $knownIssues) | |
{ | |
$KnownIssueObject = [PSCustomObject]@{ | |
revisionId = $item.id | |
revisionName = $item.name | |
revisionGroupName = $item.groupName | |
id = $knownIssue.id | |
status = $knownIssue.status | |
webViewUrl = $knownIssue.webViewUrl | |
description = $knownIssue.description | |
startDateTime = $knownIssue.startDateTime | |
title = $knownIssue.title | |
resolvedDateTime = $knownIssue.resolvedDateTime | |
lastUpdatedDateTime = $knownIssue.lastUpdatedDateTime | |
safeguardHoldIds = ($knownIssue.safeguardHoldIds -join ",") | |
latestDetail = ($knownIssue.knownIssueHistories | Sort createdDateTime -Descending | Select -first 1).body.content | |
originatingKnowledgeBaseArticleId = $knownIssue.originatingKnowledgeBaseArticle.id | |
resolvingKnowledgeBaseArticleId = $knownIssue.resolvingKnowledgeBaseArticle.id | |
originatingKnowledgeBaseArticleUrl = $knownIssue.originatingKnowledgeBaseArticle.url | |
resolvingKnowledgeBaseArticleUrl = $knownIssue.resolvingKnowledgeBaseArticle.url | |
} | |
$KnownIssueList.Add($KnownIssueObject) | |
} | |
} | |
} | |
} | |
} | |
#endregion ------------------------------------------------------------------------------------ | |
#region ------------------------------------- Feature Updates --------------------------------- | |
Write-Output "Retrieving Feature Update Catalog Entries..." | |
$Editions = Get-WindowsProductEditions | |
if ($Editions.StatusCode -ne 200) { | |
Write-Error "Error retrieving Windows product editions: $($Editions.StatusCode) $($Editions.StatusDescription)" | |
return | |
} | |
$FilteredEditions = ($Editions.Content | ConvertFrom-Json).value | where {$_.groupName -notmatch "Server" -and $_.groupName -ne "Previous versions"} | |
# List containers | |
$EditionsList = [System.Collections.Generic.List[PSCustomObject]]::new() | |
$ServicingPeriodsList = [System.Collections.Generic.List[PSCustomObject]]::new() | |
Write-Output "Processing Feature Update Catalog Entries..." | |
# Process each feature update | |
foreach ($item in $FilteredEditions) | |
{ | |
# Extract the editions | |
[array]$editions = $item.editions | |
foreach ($edition in ($editions | where {$_.name -notmatch "Server"})) | |
{ | |
$EditionObject = [PSCustomObject]@{ | |
revisionId = $item.id | |
revisionName = $item.name | |
revisionGroupName = $item.groupName | |
id = $edition.id | |
name = $edition.name | |
releasedName = $edition.releasedName | |
deviceFamily = $edition.deviceFamily | |
isInService = $edition.isInService | |
generalAvailabilityDateTime = $edition.generalAvailabilityDateTime | |
endOfServiceDateTime = $edition.endOfServiceDateTime | |
} | |
$EditionsList.Add($EditionObject) | |
# Extract the servicing periods into a separate list | |
[array]$servicingPeriods = $edition.servicingPeriods | |
foreach ($servicingPeriod in $servicingPeriods) | |
{ | |
$ServicingPeriodsObject = [PSCustomObject]@{ | |
revisionId = $item.id | |
revisionName = $item.name | |
revisionGroupName = $item.groupName | |
id = $edition.id | |
name = $edition.name | |
releasedName = $edition.releasedName | |
deviceFamily = $edition.deviceFamily | |
isInService = $edition.isInService | |
generalAvailabilityDateTime = $edition.generalAvailabilityDateTime | |
endOfServiceDateTime = $edition.endOfServiceDateTime | |
servicingPeriodName = $servicingPeriod.name | |
servicingPeriodStartDateTime = $servicingPeriod.startDateTime | |
servicingPeriodEndDateTime = $servicingPeriod.endDateTime | |
} | |
$ServicingPeriodsList.Add($ServicingPeriodsObject) | |
} | |
} | |
} | |
#endregion ------------------------------------------------------------------------------------ | |
#region ------------------------------------- Output Tables ----------------------------------- | |
# Prepare datatables. This is optional and makes it easier to import into SQL server database | |
$CatalogTable = [System.Data.DataTable]::new() | |
$RevisionTable = [System.Data.DataTable]::new() | |
$KnownIssueTable = [System.Data.DataTable]::new() | |
$EditionsTable = [System.Data.DataTable]::new() | |
$ServicingPeriodsTable = [System.Data.DataTable]::new() | |
# Catalog list | |
$CatalogList = $CatalogList | Select id,displayName,releaseDateTime,isExpeditable,qualityUpdateClassification,shortName,qualityUpdateCadence | |
$CatalogList | | |
Get-Member -MemberType NoteProperty | | |
ForEach-Object { | |
if ($_.Name -in ("releaseDateTime")) | |
{ | |
[void]$CatalogTable.Columns.Add($_.Name,[DateTime]) | |
} | |
else | |
{ | |
[void]$CatalogTable.Columns.Add($_.Name,[System.String]) | |
} | |
} | |
foreach ($item in $CatalogList) { | |
$row = $CatalogTable.NewRow() | |
foreach ($col in $CatalogTable.Columns) | |
{ | |
$entry = $item.$($col.ColumnName) | |
if ($null -eq $entry) | |
{ | |
$row[$col.ColumnName] = [System.DBNull]::Value | |
} | |
else | |
{ | |
$row[$col.ColumnName] = $entry | |
} | |
} | |
[void]$CatalogTable.Rows.Add($row) | |
} | |
# Revision list | |
$RevisionList | | |
Get-Member -MemberType NoteProperty | | |
ForEach-Object { | |
if ($_.Name -in ("releaseDateTime")) | |
{ | |
[void]$RevisionTable.Columns.Add($_.Name,[DateTime]) | |
} | |
else | |
{ | |
[void]$RevisionTable.Columns.Add($_.Name,[System.String]) | |
} | |
} | |
foreach ($item in $RevisionList) { | |
$row = $RevisionTable.NewRow() | |
foreach ($col in $RevisionTable.Columns) | |
{ | |
$entry = $item.$($col.ColumnName) | |
if ($null -eq $entry) | |
{ | |
$row[$col.ColumnName] = [System.DBNull]::Value | |
} | |
else | |
{ | |
$row[$col.ColumnName] = $entry | |
} | |
} | |
[void]$RevisionTable.Rows.Add($row) | |
} | |
# Known issue list | |
$KnownIssueList | | |
Get-Member -MemberType NoteProperty | | |
ForEach-Object { | |
if ($_.Name -in ("startDateTime","resolvedDateTime","lastUpdatedDateTime")) | |
{ | |
[void]$KnownIssueTable.Columns.Add($_.Name,[DateTime]) | |
} | |
else | |
{ | |
[void]$KnownIssueTable.Columns.Add($_.Name,[System.String]) | |
} | |
} | |
foreach ($item in $KnownIssueList) { | |
$row = $KnownIssueTable.NewRow() | |
foreach ($col in $KnownIssueTable.Columns) | |
{ | |
$entry = $item.$($col.ColumnName) | |
if ($null -eq $entry) | |
{ | |
$row[$col.ColumnName] = [System.DBNull]::Value | |
} | |
else | |
{ | |
$row[$col.ColumnName] = $entry | |
} | |
} | |
[void]$KnownIssueTable.Rows.Add($row) | |
} | |
# Editions list | |
$EditionsList | | |
Get-Member -MemberType NoteProperty | | |
ForEach-Object { | |
if ($_.Name -in ("generalAvailabilityDateTime","endOfServiceDateTime")) | |
{ | |
[void]$EditionsTable.Columns.Add($_.Name,[DateTime]) | |
} | |
else | |
{ | |
[void]$EditionsTable.Columns.Add($_.Name,[System.String]) | |
} | |
} | |
foreach ($item in $EditionsList) { | |
$row = $EditionsTable.NewRow() | |
foreach ($col in $EditionsTable.Columns) | |
{ | |
$entry = $item.$($col.ColumnName) | |
if ($null -eq $entry) | |
{ | |
$row[$col.ColumnName] = [System.DBNull]::Value | |
} | |
else | |
{ | |
$row[$col.ColumnName] = $entry | |
} | |
} | |
[void]$EditionsTable.Rows.Add($row) | |
} | |
# Servicing periods list | |
$ServicingPeriodsList | | |
Get-Member -MemberType NoteProperty | | |
ForEach-Object { | |
if ($_.Name -in ("generalAvailabilityDateTime","endOfServiceDateTime","servicingPeriodStartDateTime","servicingPeriodEndDateTime")) | |
{ | |
[void]$ServicingPeriodsTable.Columns.Add($_.Name,[DateTime]) | |
} | |
else | |
{ | |
[void]$ServicingPeriodsTable.Columns.Add($_.Name,[System.String]) | |
} | |
} | |
foreach ($item in $ServicingPeriodsList) { | |
$row = $ServicingPeriodsTable.NewRow() | |
foreach ($col in $ServicingPeriodsTable.Columns) | |
{ | |
$entry = $item.$($col.ColumnName) | |
if ($null -eq $entry) | |
{ | |
$row[$col.ColumnName] = [System.DBNull]::Value | |
} | |
else | |
{ | |
$row[$col.ColumnName] = $entry | |
} | |
} | |
[void]$ServicingPeriodsTable.Rows.Add($row) | |
} | |
#endregion ------------------------------------------------------------------------------------ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment