private/Update-OSDCloudCoreCatalogOS.ps1
|
function Update-OSDCloudCoreCatalogOS { <# .SYNOPSIS Updates the Windows 11 products operating system catalog. .DESCRIPTION Queries the Microsoft Update Metadata Service for the current Windows 11 products catalog, verifies the downloaded CAB size and SHA256 digest, and validates the extracted catalog before changing persistent content. Publishes the validated catalog to the module operating system catalog directory using the build and release timestamp from its ESD filenames, for example 26200.9457.260913-0221.xml. The catalog is also copied to OSDCloud\catalogs\operatingsystems on each writable drive letter that already contains an OSDCloud directory. Existing module catalogs remain in place, and an existing module catalog name is never overwritten with different content. Any prerequisite, download, validation, or module publication failure is reported as a warning and does not produce a terminating error. A failure to update one external drive is reported as a warning and does not prevent other eligible drives from being updated. WhatIf still performs the network request, CAB download, extraction, and validation; it previews only publication and drive synchronization. .PARAMETER MinimumItemCount Specifies the minimum number of ESD records required before a catalog is accepted. The default is 50. Valid values are 1 through 100000. .EXAMPLE Update-OSDCloudCoreCatalogOS Downloads and validates the current Windows 11 products catalog, publishes it to the module, and synchronizes it to eligible writable drives. .EXAMPLE Update-OSDCloudCoreCatalogOS -WhatIf Downloads and validates the current catalog, then previews module publication and drive synchronization without changing either location. .INPUTS None. This function does not accept pipeline input. .OUTPUTS System.Management.Automation.PSCustomObject. Returns the detected build, module catalog path, publication status, synchronized drive catalog paths, item count, and SHA256 hash. .NOTES Requires Windows, Windows PowerShell 5.1 or later, internet access, expand.exe, writable temporary storage, and write access to the module catalog directory when updating. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter(Mandatory = $false)] [ValidateRange(1, 100000)] [System.Int32] $MinimumItemCount = 50 ) $Error.Clear() $ErrorActionPreference = 'Stop' $functionName = $MyInvocation.MyCommand.Name Write-Verbose "[$(Get-Date -format s)] [$functionName] Start" $metadataUri = 'https://fe3.delivery.mp.microsoft.com/UpdateMetadataService/updates/search/v1/bydeviceinfo' $products = 'PN=Windows.Products.Cab.amd64&V=0.0.0.0' $deviceAttributes = 'DUScan=1;OSVersion=10.0.26100.1' $moduleCatalogDirectory = Join-Path $MyInvocation.MyCommand.Module.ModuleBase 'core\operatingsystems' $temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ('osdcloud-catalog-' + [guid]::NewGuid().ToString()) $writeProbePath = $null $stagedModulePath = $null $stagedDrivePaths = @() $getCatalogNameMetadata = { param ( [System.String] $Name ) $nameMatch = [regex]::Match( $Name, '^(?<Identity>(?<Major>\d{5})\.(?<Ubr>0|[1-9]\d*)\.(?<Timestamp>\d{6}-\d{4}))\.xml$', [System.Text.RegularExpressions.RegexOptions]::CultureInvariant ) if (-not $nameMatch.Success) { return } $releaseDateTime = [datetime]::MinValue if (-not [datetime]::TryParseExact( $nameMatch.Groups['Timestamp'].Value, 'yyMMdd-HHmm', [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$releaseDateTime )) { return } $build = $null if (-not [version]::TryParse( "$($nameMatch.Groups['Major'].Value).$($nameMatch.Groups['Ubr'].Value)", [ref]$build )) { return } [pscustomobject]@{ Identity = $nameMatch.Groups['Identity'].Value Build = $build ReleaseDateTime = $releaseDateTime } } try { if (-not (Test-Path -LiteralPath $moduleCatalogDirectory -PathType Container)) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Module catalog directory was not found at '$moduleCatalogDirectory'." return } if (-not $WhatIfPreference) { $writeProbePath = Join-Path $moduleCatalogDirectory ('.write-test-' + [guid]::NewGuid().ToString() + '.tmp') $writeProbe = [System.IO.File]::Open( $writeProbePath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None ) $writeProbe.Dispose() Remove-Item -LiteralPath $writeProbePath -Force -WhatIf:$false $writeProbePath = $null } $null = New-Item -Path $temporaryDirectory -ItemType Directory -WhatIf:$false $body = [ordered]@{ Products = $products DeviceAttributes = $deviceAttributes } | ConvertTo-Json -Compress $response = Invoke-RestMethod ` -Uri $metadataUri ` -Method Post ` -ContentType 'application/json' ` -Headers @{ Accept = '*/*' } ` -Body $body if ($response -is [array]) { if ($response.Count -ne 1) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Microsoft metadata returned $($response.Count) response records; expected one." return } $response = $response[0] } $cabRecords = @($response.FileLocations | Where-Object { $_.FileName -eq 'products.cab' }) if ($cabRecords.Count -ne 1) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Microsoft metadata returned $($cabRecords.Count) products.cab records; expected one." return } $cabRecord = $cabRecords[0] $cabUri = [uri]$cabRecord.Url if (-not $cabUri.IsAbsoluteUri -or $cabUri.Scheme -notin @('http', 'https')) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Microsoft metadata returned an invalid products.cab URL: $($cabRecord.Url)" return } [long]$expectedCabSize = 0 if (-not [long]::TryParse([string]$cabRecord.Size, [ref]$expectedCabSize) -or $expectedCabSize -le 0) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Microsoft metadata returned an invalid products.cab size: $($cabRecord.Size)" return } try { $expectedDigest = [Convert]::FromBase64String([string]$cabRecord.Digest) } catch { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Microsoft metadata returned an invalid products.cab SHA256 digest." return } if ($expectedDigest.Length -ne 32) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Microsoft metadata returned a $($expectedDigest.Length)-byte digest; expected SHA256." return } $cabPath = Join-Path $temporaryDirectory 'products.cab' Invoke-WebRequest -Uri $cabUri -OutFile $cabPath -Headers @{ Accept = '*/*' } $actualCabSize = (Get-Item -LiteralPath $cabPath).Length if ($actualCabSize -ne $expectedCabSize) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Downloaded products.cab size mismatch. Expected $expectedCabSize bytes, got $actualCabSize bytes." return } $hashAlgorithm = [System.Security.Cryptography.SHA256]::Create() $cabStream = [System.IO.File]::OpenRead($cabPath) try { $actualDigest = $hashAlgorithm.ComputeHash($cabStream) } finally { $cabStream.Dispose() $hashAlgorithm.Dispose() } if ([Convert]::ToBase64String($actualDigest) -cne [Convert]::ToBase64String($expectedDigest)) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Downloaded products.cab SHA256 digest does not match Microsoft metadata." return } $extractDirectory = Join-Path $temporaryDirectory 'expanded' $null = New-Item -Path $extractDirectory -ItemType Directory -WhatIf:$false $expandPath = Join-Path $env:SystemRoot 'System32\expand.exe' if (-not (Test-Path -LiteralPath $expandPath -PathType Leaf)) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Windows expand.exe was not found at '$expandPath'." return } $sourceXmlPath = Join-Path $extractDirectory 'products.xml' $expandResult = & $expandPath $cabPath $sourceXmlPath 2>&1 if ($LASTEXITCODE -ne 0) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. expand.exe failed with exit code ${LASTEXITCODE}: $expandResult" return } if (-not (Test-Path -LiteralPath $sourceXmlPath -PathType Leaf)) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. expand.exe did not produce products.xml." return } [xml]$catalog = Get-Content -LiteralPath $sourceXmlPath -Raw $fileNodes = @($catalog.MCT.Catalogs.Catalog.PublishedMedia.Files.File) $esdNodes = @($fileNodes | Where-Object { [string]$_.FileName -like '*.esd' }) if ($esdNodes.Count -lt $MinimumItemCount) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Catalog contains $($esdNodes.Count) ESD records; expected at least $MinimumItemCount." return } $requiredProperties = @('FileName', 'LanguageCode', 'Edition', 'Architecture', 'Size', 'Sha256', 'FilePath') $catalogIdentities = @() foreach ($node in $esdNodes) { foreach ($property in $requiredProperties) { if ([string]::IsNullOrWhiteSpace([string]$node.$property)) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Catalog record '$($node.FileName)' is missing required property '$property'." return } } if ([string]$node.Sha256 -notmatch '^[a-fA-F0-9]{64}$') { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Catalog record '$($node.FileName)' has an invalid SHA256 value." return } [long]$fileSize = 0 if (-not [long]::TryParse([string]$node.Size, [ref]$fileSize) -or $fileSize -le 0) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Catalog record '$($node.FileName)' has an invalid size." return } $identityMatch = [regex]::Match( [string]$node.FileName, '^(?<Identity>\d{5}\.(?:0|[1-9]\d*)\.\d{6}-\d{4})\..+\.esd$', [System.Text.RegularExpressions.RegexOptions]::CultureInvariant ) if (-not $identityMatch.Success) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Catalog record '$($node.FileName)' does not contain a supported build identity." return } $catalogIdentities += $identityMatch.Groups['Identity'].Value } $catalogIdentities = @($catalogIdentities | Sort-Object -Unique) if ($catalogIdentities.Count -ne 1) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Catalog contains $($catalogIdentities.Count) build and release identities; expected one." return } $catalogIdentity = $catalogIdentities[0] $catalogMetadata = & $getCatalogNameMetadata "$catalogIdentity.xml" if (-not $catalogMetadata) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Catalog identity '$catalogIdentity' is not valid." return } $build = $catalogMetadata.Build.ToString() foreach ($architecture in @('x64', 'ARM64')) { $target = @( $esdNodes | Where-Object { [string]$_.LanguageCode -eq 'en-us' -and [string]$_.Edition -eq 'Enterprise' -and [string]$_.Architecture -eq $architecture } ) if ($target.Count -lt 1) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Catalog does not contain an en-us Enterprise $architecture ESD record." return } } $destinationName = "$catalogIdentity.xml" $moduleCatalogPath = Join-Path $moduleCatalogDirectory $destinationName $sourceHash = (Get-FileHash -LiteralPath $sourceXmlPath -Algorithm SHA256).Hash $destinationExists = Test-Path -LiteralPath $moduleCatalogPath -PathType Leaf if ($destinationExists) { $destinationHash = (Get-FileHash -LiteralPath $moduleCatalogPath -Algorithm SHA256).Hash if ($sourceHash -cne $destinationHash) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Catalog '$destinationName' already exists with different content." return } } foreach ($existingCatalog in Get-ChildItem -LiteralPath $moduleCatalogDirectory -Filter '*.xml' -File) { if ($existingCatalog.Name -eq $destinationName) { continue } $existingMetadata = & $getCatalogNameMetadata $existingCatalog.Name if (-not $existingMetadata) { Write-Verbose "[$(Get-Date -format s)] [$functionName] Ignoring unrecognized catalog filename: $($existingCatalog.FullName)" continue } $isNewerBuild = $existingMetadata.Build -gt $catalogMetadata.Build $isNewerRelease = $existingMetadata.Build -eq $catalogMetadata.Build -and $existingMetadata.ReleaseDateTime -gt $catalogMetadata.ReleaseDateTime if ($isNewerBuild -or $isNewerRelease) { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. Existing catalog '$($existingCatalog.Name)' is newer than downloaded catalog $destinationName." return } } $published = $false if (-not $destinationExists -and $PSCmdlet.ShouldProcess($moduleCatalogPath, 'Publish the OS catalog')) { $stagedModulePath = Join-Path $moduleCatalogDirectory ('.' + $destinationName + '.' + [guid]::NewGuid().ToString() + '.tmp') [System.IO.File]::Copy($sourceXmlPath, $stagedModulePath, $false) [System.IO.File]::Move($stagedModulePath, $moduleCatalogPath) $stagedModulePath = $null $published = $true } $driveCatalogPaths = @() $eligibleDrives = @( Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | Where-Object { $_.Root -match '^[A-Z]:\\$' } | Where-Object { Test-Path -LiteralPath (Join-Path $_.Root 'OSDCloud') -PathType Container } ) foreach ($drive in $eligibleDrives) { $driveCatalogDirectory = Join-Path $drive.Root 'OSDCloud\catalogs\operatingsystems' $driveCatalogPath = Join-Path $driveCatalogDirectory $destinationName if (-not $PSCmdlet.ShouldProcess($driveCatalogPath, 'Synchronize the OS catalog')) { continue } $stagedDrivePath = $null try { $null = New-Item -Path $driveCatalogDirectory -ItemType Directory -Force $stagedDrivePath = Join-Path $driveCatalogDirectory ('.' + $destinationName + '.' + [guid]::NewGuid().ToString() + '.tmp') $stagedDrivePaths += $stagedDrivePath [System.IO.File]::Copy($sourceXmlPath, $stagedDrivePath, $false) Move-Item -LiteralPath $stagedDrivePath -Destination $driveCatalogPath -Force $stagedDrivePaths = @($stagedDrivePaths | Where-Object { $_ -ne $stagedDrivePath }) $driveCatalogPaths += $driveCatalogPath } catch { Write-Warning "[$(Get-Date -format s)] [$functionName] Unable to synchronize the operating system catalog to '$driveCatalogPath': $($_.Exception.Message)" } } [pscustomobject]@{ Build = $build ModuleCatalogPath = $moduleCatalogPath Published = $published DriveCatalogPaths = $driveCatalogPaths ItemCount = $esdNodes.Count Sha256 = $sourceHash } } catch { Write-Warning "[$(Get-Date -format s)] [$functionName] Catalog update was not successful. $($_.Exception.Message)" } finally { foreach ($cleanupPath in @($stagedModulePath) + @($stagedDrivePaths) + @($writeProbePath)) { if ($cleanupPath -and (Test-Path -LiteralPath $cleanupPath -PathType Leaf)) { Remove-Item -LiteralPath $cleanupPath -Force -ErrorAction SilentlyContinue -WhatIf:$false } } if (Test-Path -LiteralPath $temporaryDirectory) { Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false } Write-Verbose "[$(Get-Date -format s)] [$functionName] End" } } |