public/Update-OSDeployCoreESD.ps1
|
#Requires -PSEdition Core #Requires -Version 7.4 function Update-OSDeployCoreESD { <# .SYNOPSIS Downloads Windows Enterprise ESD files from the latest OSDeploy OS catalog. .DESCRIPTION Locates the most recent XML catalog file in the OSDeploy operatingsystem catalogs directory, resolves the en-US Enterprise ESD entries, and downloads them to C:\ProgramData\OSDeployCore\OSDCloud\OS\<Version> where <Version> is derived from the catalog name (e.g. 'Windows 11 25H2' from '26200.8457-win11-25h2.xml'). On ARM64 Windows only the ARM64 ESD is offered. On AMD64 Windows the AMD64 ESD is offered first, then the ARM64 ESD (each confirmed separately). Each file is skipped silently when it is already present and its SHA256 checksum matches the catalog value. When the latest catalog ESD is not cached but an older version is found in the cache with a verified checksum, the user is informed that a newer version is available and offered the choice to download it or keep the older cached version. For files that need downloading, the URL is first tested for reachability. All pending downloads are then confirmed with the user before any transfer begins. SHA256 verification is performed after every download. A terminating error is thrown when the checksum does not match or when curl.exe fails. .PARAMETER Force Re-downloads each ESD file even when it already exists in the cache with a matching SHA256 checksum. .OUTPUTS System.IO.FileInfo. One FileInfo object per successfully downloaded or already-cached ESD file (up to two objects: x64 and ARM64). .EXAMPLE Update-OSDeployCoreESD Tests reachability, prompts to confirm both files, then downloads them to C:\ProgramData\OSDeployCore\OSDCloud\OS\Windows 11 25H2 (version-specific folder derived from the catalog name). .EXAMPLE Update-OSDeployCoreESD -Force Re-downloads both ESD files regardless of whether they are already cached. .EXAMPLE Update-OSDeployCoreESD -WhatIf Shows which ESD files would be downloaded without performing any downloads. .NOTES Requires curl.exe to be available on the system PATH. Files are sourced from the Microsoft Content Delivery Network via URLs stored in the catalog XML. Dependencies: Module Functions: Test-IsAdministrator, Write-OSDeployBanner, Write-OSDeployCoreProgress Executables: curl.exe .NET Classes: [System.IO.FileInfo], [System.IO.FileNotFoundException], [System.IO.IOException], [System.IO.Path], [System.Management.Automation.ErrorCategory], [System.Management.Automation.ErrorRecord] #> [CmdletBinding(SupportsShouldProcess)] [OutputType([System.IO.FileInfo[]])] param ( [Parameter()] [switch]$Force, [Parameter()] [ValidateSet('amd64', 'arm64')] [System.String] $Architecture ) Write-OSDeployBanner $Error.Clear() Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Start" Write-OSDeployCoreProgress "[$($MyInvocation.MyCommand.Name)] Start" # Requires Run as Administrator if (-not (Test-IsAdministrator)) { Write-Warning "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] This function must be Run as Administrator" return } # ------------------------------------------------------------------------- # Resolve catalog files (latest first) # ------------------------------------------------------------------------- $catalogDir = Join-Path $script:OSDeployModuleBase 'core\operatingsystems' $allXmlFiles = Get-ChildItem -Path $catalogDir -Filter '*.xml' -File | Sort-Object Name -Descending $latestXml = $allXmlFiles | Select-Object -First 1 $olderXmls = @($allXmlFiles | Select-Object -Skip 1) if (-not $latestXml) { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.IO.FileNotFoundException]::new("No OS catalog XML files found in '$catalogDir'."), 'CatalogNotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $catalogDir ) ) } Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Using catalog: $($latestXml.Name)" if ($olderXmls.Count -gt 0) { Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Older catalog(s) available: $($olderXmls.Name -join ', ')" } Write-OSDeployCoreProgress "Importing OSDeploy PSModule OperatingSystem Catalog" Write-OSDeployCoreProgress "$($latestXml.FullName)" # ------------------------------------------------------------------------- # Parse the catalog # ------------------------------------------------------------------------- [xml]$catalog = Get-Content -Path $latestXml.FullName -Raw $allFiles = $catalog.MCT.Catalogs.Catalog.PublishedMedia.Files.File # ------------------------------------------------------------------------- # Resolve target ESD entries # On ARM64 Windows only offer ARM64; on AMD64 offer AMD64 first then ARM64. # ------------------------------------------------------------------------- $isArm64 = $env:PROCESSOR_ARCHITECTURE -eq 'ARM64' $targets = if ($isArm64) { @( [pscustomobject]@{ Architecture = 'ARM64'; Edition = 'Enterprise'; LanguageCode = 'en-us' } ) } else { @( [pscustomobject]@{ Architecture = 'x64'; Edition = 'Enterprise'; LanguageCode = 'en-us' } [pscustomobject]@{ Architecture = 'ARM64'; Edition = 'Enterprise'; LanguageCode = 'en-us' } ) } if ($Architecture) { $archMap = @{ amd64 = 'x64'; arm64 = 'ARM64' } $targets = @($targets | Where-Object { $_.Architecture -eq $archMap[$Architecture] }) Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Architecture filter applied: $Architecture" } $resolvedEntries = foreach ($target in $targets) { $entry = $allFiles | Where-Object { $_.LanguageCode -eq $target.LanguageCode -and $_.Edition -eq $target.Edition -and $_.Architecture -eq $target.Architecture } | Select-Object -First 1 if (-not $entry) { Write-Warning "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] No catalog entry found for $($target.Edition) $($target.Architecture) $($target.LanguageCode). Skipping." continue } $entry } if (-not $resolvedEntries) { Write-Warning "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] No matching ESD entries found in catalog '$($latestXml.Name)'." return } # ------------------------------------------------------------------------- # Derive OS folder name from the catalog filename # e.g. '26200.8457-win11-25h2.xml' → 'Windows 11 25H2' # ------------------------------------------------------------------------- $catalogBase = [System.IO.Path]::GetFileNameWithoutExtension($latestXml.Name) if ($catalogBase -notmatch '^\d+\.\d+-win(\d+)-(.+)$') { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.FormatException]::new("Cannot parse OS version from catalog name '$($latestXml.Name)'. Expected format: '<build>-win<version>-<release>.xml' (e.g. '26200.8457-win11-25h2.xml')."), 'CatalogNameUnrecognized', [System.Management.Automation.ErrorCategory]::InvalidData, $latestXml.Name ) ) } $osFolderName = "Windows $($Matches[1]) $($Matches[2].ToUpper())" Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] OS folder name: $osFolderName" # ------------------------------------------------------------------------- # Prepare download directory # ------------------------------------------------------------------------- $downloadDir = Join-Path $script:OSDeployCorePath 'OSDCloud' 'OS' $osFolderName Write-OSDeployCoreProgressCyan "OSDeployCoreESD files are saved in $downloadDir" if (-not (Test-Path -Path $downloadDir)) { if ($PSCmdlet.ShouldProcess($downloadDir, 'Create download directory')) { New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Created download directory: $downloadDir" } } $normalizeHash = { param([string]$hash) ($hash -replace '\s+', '').ToUpperInvariant() } $results = [System.Collections.Generic.List[System.IO.FileInfo]]::new() $pendingEntries = [System.Collections.Generic.List[object]]::new() # ------------------------------------------------------------------------- # Phase 1: Check cache and test URL reachability # ------------------------------------------------------------------------- foreach ($entry in $resolvedEntries) { $destPath = Join-Path $downloadDir $entry.FileName $expectedSha256 = & $normalizeHash $entry.Sha256 $checkForOlder = $false Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Checking: $($entry.FileName)" Write-OSDeployCoreProgress "Verifying OSDeployCoreESD for Windows 11 $($entry.Edition) $($entry.LanguageCode) $($entry.Architecture)" Write-OSDeployCoreProgress "FileName: $($entry.FileName)" # Return cached file immediately when checksum matches (and -Force is not set) if (-not $Force -and (Test-Path -Path $destPath -PathType Leaf)) { $actualHash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpperInvariant() if ($actualHash -eq $expectedSha256) { Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] '$($entry.FileName)' already cached and SHA256 verified. Skipping." # Write-OSDeployCoreProgress "OSDeployCoreESD is already cached and hash verified: $($entry.FileName)" Write-OSDeployCoreProgress "Hash SHA256: $actualHash" $results.Add((Get-Item -Path $destPath)) continue } Write-Warning "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] '$($entry.FileName)' exists but SHA256 does not match the catalog." Write-OSDeployCoreProgress "SHA256 mismatch: $($entry.FileName)" Write-OSDeployCoreProgress " Expected : $expectedSha256" Write-OSDeployCoreProgress " Actual : $actualHash" $deleteCaption = "SHA256 Mismatch – Delete and Re-download?" $deleteMessage = "File : $destPath`nExpected SHA256 : $expectedSha256`nActual SHA256 : $actualHash`n`nThe cached file does not match the catalog checksum. Delete it to the Recycle Bin and re-download?" if ($PSCmdlet.ShouldContinue($deleteMessage, $deleteCaption) -and $PSCmdlet.ShouldProcess($destPath, 'Delete mismatched cached file (Recycle Bin)')) { Add-Type -AssemblyName Microsoft.VisualBasic [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($destPath, 'OnlyErrorDialogs', 'SendToRecycleBin') Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Sent to Recycle Bin: $destPath" Write-OSDeployCoreProgress "Sent to Recycle Bin: $($entry.FileName)" } else { Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] User declined to delete mismatched file. Skipping: $($entry.FileName)" continue } } else { # Latest ESD is not cached; check for an older cached version below $checkForOlder = -not $Force } # ----------------------------------------------------------------------- # Check for a valid older cached version when the latest ESD is not present # ----------------------------------------------------------------------- if ($checkForOlder -and $olderXmls.Count -gt 0) { $olderCachedFile = $null foreach ($olderXml in $olderXmls) { [xml]$olderCatalogXml = Get-Content -Path $olderXml.FullName -Raw $olderEntry = $olderCatalogXml.MCT.Catalogs.Catalog.PublishedMedia.Files.File | Where-Object { $_.LanguageCode -eq $entry.LanguageCode -and $_.Edition -eq $entry.Edition -and $_.Architecture -eq $entry.Architecture } | Select-Object -First 1 if ($olderEntry -and $olderEntry.FileName -ne $entry.FileName) { $olderDestPath = Join-Path $downloadDir $olderEntry.FileName if (Test-Path -Path $olderDestPath -PathType Leaf) { $olderExpectedSha256 = & $normalizeHash $olderEntry.Sha256 $olderActualHash = (Get-FileHash -Path $olderDestPath -Algorithm SHA256).Hash.ToUpperInvariant() if ($olderActualHash -eq $olderExpectedSha256) { $olderCachedFile = Get-Item -Path $olderDestPath break } } } } if ($olderCachedFile) { $fileSizeMB = [Math]::Round([long]$entry.Size / 1MB, 1) $fileLabel = "$($entry.Architecture) – $($entry.Edition) ($($entry.LanguageCode))" $upgradeCaption = "Newer Version Available – $fileLabel" $upgradeMessage = "Cached : $($olderCachedFile.Name)`nNewer : $($entry.FileName) ($fileSizeMB MB)`n`nA newer version is available from catalog '$($latestXml.Name)'.`nDownload the newer version?" Write-OSDeployCoreProgress "Newer ESD available: $($entry.FileName)" Write-OSDeployCoreProgress " Cached : $($olderCachedFile.Name)" Write-OSDeployCoreProgress " Newer : $($entry.FileName)" if (-not $PSCmdlet.ShouldContinue($upgradeMessage, $upgradeCaption)) { Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] User kept older cached ESD: $($olderCachedFile.Name)" $results.Add($olderCachedFile) continue } Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] User chose to download newer ESD: $($entry.FileName)" } } # Test URL reachability before adding to the prompt queue Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Testing reachability: $($entry.FilePath)" Write-OSDeployCoreProgress "Testing availability of ESD:" Write-OSDeployCoreProgress "$($entry.FilePath)" $null = curl.exe --head --fail --silent --location --max-time 15 $entry.FilePath 2>&1 if ($LASTEXITCODE -ne 0) { Write-Warning "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] '$($entry.FileName)' is not reachable (curl exit $LASTEXITCODE). Skipping." continue } Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Reachable: $($entry.FileName)" Write-OSDeployCoreProgress "ESD is available: $($entry.FileName)" $pendingEntries.Add($entry) } # ------------------------------------------------------------------------- # Phase 2: Prompt for all pending downloads before starting any transfer # ------------------------------------------------------------------------- $confirmedEntries = [System.Collections.Generic.List[object]]::new() foreach ($entry in $pendingEntries) { $fileSizeMB = [Math]::Round([long]$entry.Size / 1MB, 1) $fileLabel = "$($entry.Architecture) – $($entry.Edition) ($($entry.LanguageCode))" $destPath = Join-Path $downloadDir $entry.FileName $confirmCaption = "Download $fileLabel" $confirmMessage = "File : $($entry.FileName)`nSize : $fileSizeMB MB`nDest : $destPath`n`nThis download can take between 5 - 30 minutes depending on your internet connection.`n`nDownload this file?" if ($PSCmdlet.ShouldContinue($confirmMessage, $confirmCaption) -and $PSCmdlet.ShouldProcess($destPath, "Download $($entry.FileName)")) { $confirmedEntries.Add($entry) } else { Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Download declined by user: $($entry.FileName)" } } # ------------------------------------------------------------------------- # Phase 3: Download all confirmed entries # ------------------------------------------------------------------------- if ($confirmedEntries.Count -gt 0) { Write-OSDeployCoreProgress "Downloading $($confirmedEntries.Count) ESD file(s) from Microsoft. This process can take between 5 - 30 minutes depending on your internet connection." } $maxAutomaticAttempts = 3 # Initial attempt + 2 automatic retries $downloadedCount = 0 foreach ($entry in $confirmedEntries) { $destPath = Join-Path $downloadDir $entry.FileName $expectedSha256 = & $normalizeHash $entry.Sha256 Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Downloading: $($entry.FileName)" Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Source: $($entry.FilePath)" Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Destination: $destPath" Write-OSDeployCoreProgress "Downloading $($entry.FileName)" Write-OSDeployCoreProgress "$destPath" $remoteLength = [Int64]0 $remoteAcceptsRanges = $false try { Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Requesting HTTP HEAD for Content-Length and Accept-Ranges: $($entry.FilePath)" $remoteHead = Invoke-WebRequest -Method Head -Uri $entry.FilePath -ErrorAction Stop $remoteLengthHeader = $remoteHead.Headers.'Content-Length' | Select-Object -First 1 if ($remoteLengthHeader) { $remoteLength = [Int64]$remoteLengthHeader } $remoteAcceptsRanges = ($remoteHead.Headers.'Accept-Ranges' | Select-Object -First 1) -eq 'bytes' Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] RemoteLength: $remoteLength" Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] RemoteAcceptsRanges: $remoteAcceptsRanges" } catch { Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] HTTP HEAD failed for '$($entry.FileName)'. Continuing without resume-length validation. $($_.Exception.Message)" } $curlArgs = @( '--location', '--fail', '--retry', '5', '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', '--continue-at', '-', '--output', $destPath, $entry.FilePath ) $attempt = 0 $promptForManualRetry = $false $downloadSucceeded = $false $lastFailureReason = $null $lastCurlExitCode = 0 $actualHash = $null $localLength = [Int64]0 while (-not $downloadSucceeded) { $attempt++ if ($attempt -eq 1) { Write-OSDeployCoreProgress "Downloading $($entry.FileName)" } elseif (-not $promptForManualRetry) { Write-OSDeployCoreProgress "Automatic retry $($attempt - 1) of 2: $($entry.FileName)" } else { Write-OSDeployCoreProgress "Retrying download of $($entry.FileName) ..." } Write-OSDeployCoreProgress "$destPath" curl.exe @curlArgs $lastCurlExitCode = $LASTEXITCODE if ($lastCurlExitCode -ne 0) { $lastFailureReason = 'DownloadFailed' Write-Warning "[$($MyInvocation.MyCommand.Name)] curl.exe failed (exit $lastCurlExitCode) downloading '$($entry.FileName)'." Write-OSDeployCoreProgress "Download failed (curl exit $lastCurlExitCode): $($entry.FileName)" } elseif (-not (Test-Path -Path $destPath -PathType Leaf)) { $lastFailureReason = 'DownloadMissing' Write-Warning "[$($MyInvocation.MyCommand.Name)] Download completed but file is missing: '$($entry.FileName)'." Write-OSDeployCoreProgress "Download failed: output file was not created for $($entry.FileName)" } else { $localLength = (Get-Item -Path $destPath).Length if ($remoteAcceptsRanges -and $remoteLength -gt 0 -and $localLength -lt $remoteLength) { $lastFailureReason = 'DownloadIncomplete' Write-Warning "[$($MyInvocation.MyCommand.Name)] Incomplete download for '$($entry.FileName)' ($localLength of $remoteLength bytes)." Write-OSDeployCoreProgress "Incomplete download detected: $($entry.FileName)" Write-OSDeployCoreProgress " Local : $localLength" Write-OSDeployCoreProgress " Remote : $remoteLength" } else { $actualHash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpperInvariant() if ($actualHash -eq $expectedSha256) { $downloadSucceeded = $true break } $lastFailureReason = 'ChecksumMismatch' Write-Warning "[$($MyInvocation.MyCommand.Name)] SHA256 mismatch for '$($entry.FileName)'." Write-OSDeployCoreProgress "SHA256 mismatch: $($entry.FileName)" Write-OSDeployCoreProgress " Expected : $expectedSha256" Write-OSDeployCoreProgress " Actual : $actualHash" } } if ($lastFailureReason -eq 'ChecksumMismatch' -and (Test-Path -Path $destPath -PathType Leaf)) { if ($PSCmdlet.ShouldProcess($destPath, 'Delete failed download (Recycle Bin)')) { Write-OSDeployCoreProgress "Sending to Recycle Bin: $($entry.FileName)" Add-Type -AssemblyName Microsoft.VisualBasic [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($destPath, 'OnlyErrorDialogs', 'SendToRecycleBin') Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Sent to Recycle Bin: $destPath" } } if (-not $promptForManualRetry -and $attempt -lt $maxAutomaticAttempts) { continue } $promptForManualRetry = $true $retryCaption = "Download Failed – Retry Download?" if ($lastFailureReason -eq 'ChecksumMismatch') { $retryMessage = "File : $destPath`nExpected SHA256 : $expectedSha256`nActual SHA256 : $actualHash`n`nThe downloaded file does not match the catalog checksum. Retry the download?" } elseif ($lastFailureReason -eq 'DownloadIncomplete') { $retryMessage = "File : $destPath`nLocal Bytes : $localLength`nRemote Bytes : $remoteLength`nSource : $($entry.FilePath)`n`nDownload is incomplete. Retry and continue the transfer?" } elseif ($lastFailureReason -eq 'DownloadFailed') { $retryMessage = "File : $destPath`nCurl Exit Code : $lastCurlExitCode`nSource : $($entry.FilePath)`n`nDownload failed. Retry the download?" } else { $retryMessage = "File : $destPath`nSource : $($entry.FilePath)`n`nDownload failed because the output file was not created. Retry the download?" } if (-not $PSCmdlet.ShouldContinue($retryMessage, $retryCaption)) { if (Test-Path -Path $destPath -PathType Leaf -and $PSCmdlet.ShouldProcess($destPath, 'Delete failed download after user declined retry (Recycle Bin)')) { Write-OSDeployCoreProgress "User declined retry. Sending failed download to Recycle Bin: $($entry.FileName)" Add-Type -AssemblyName Microsoft.VisualBasic [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($destPath, 'OnlyErrorDialogs', 'SendToRecycleBin') Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Sent to Recycle Bin after user declined retry: $destPath" } Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] User declined retry for: $($entry.FileName)" break } if (-not $PSCmdlet.ShouldProcess($destPath, "Retry download $($entry.FileName)")) { Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Retry should-process declined for: $($entry.FileName)" break } } if (-not $downloadSucceeded) { continue } Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] SHA256 verified: $($entry.FileName)" Write-OSDeployCoreProgress "SHA256 verified: $($entry.FileName)" $results.Add((Get-Item -Path $destPath)) $downloadedCount++ } Write-Verbose "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Done. $($results.Count) file(s) ready." $cachedCount = $results.Count - $downloadedCount $summaryParts = [System.Collections.Generic.List[string]]::new() if ($downloadedCount -gt 0) { $summaryParts.Add("$downloadedCount file(s) downloaded") } if ($cachedCount -gt 0) { $summaryParts.Add("$cachedCount file(s) already cached") } $summary = if ($summaryParts.Count -gt 0) { $summaryParts -join ', ' } else { 'No ESD files were downloaded or cached' } Write-OSDeployCoreProgressCyan "[$($MyInvocation.MyCommand.Name)] Done. $summary." return $results.ToArray() } |