Private/Get-GuestScript.ps1
|
function Get-GuestScript { <# .SYNOPSIS Return one of the scripts that run inside the guest through Run Command .DESCRIPTION Every in-guest script lives here so that one Pester test can parse all of them and fail on PowerShell 7 syntax: Run Command executes them under Windows PowerShell 5.1. They report through a single marker line (IPU-FACTS=, IPU-STATUS= or RESULT=) that the module-side parsers look for, so any other output the guest produces is harmless. Facts read-only inventory for the preflight SetupPath bring the media disk online and locate setup.exe WimImages list the images in the media's install.wim with edition and installation type Launch register and start the scheduled task that runs Setup (ADR 0001) FeatureUpdateLaunch opt in, find the Windows Server feature update through the Windows Update Agent and start a scheduled task that downloads, installs and reboots (ADR 0006) Status build number, Setup processes, task state and last result LogTail excerpt of the Panther logs after a failure RemoveTask unregister the scheduled task after a final state #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [ValidateSet('Facts', 'SetupPath', 'WimImages', 'Launch', 'FeatureUpdateLaunch', 'Status', 'LogTail', 'RemoveTask')] [string]$Name ) switch ($Name) { 'Facts' { return @' $ErrorActionPreference = 'Stop' $cv = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' $os = Get-CimInstance -ClassName Win32_OperatingSystem $cs = Get-CimInstance -ClassName Win32_ComputerSystem $freeGB = [math]::Round((Get-PSDrive -Name C).Free / 1GB, 2) $pending = $false if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { $pending = $true } if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { $pending = $true } $sessionManager = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -ErrorAction SilentlyContinue if ($sessionManager -and $sessionManager.PSObject.Properties['PendingFileRenameOperations'] -and $sessionManager.PendingFileRenameOperations) { $pending = $true } # Windows activation: ApplicationID 55c92734-... is the Windows OS product; other rows are Office etc. $channel = 'unknown' $licenseStatus = -1 $license = Get-CimInstance -ClassName SoftwareLicensingProduct -Filter "PartialProductKey IS NOT NULL AND ApplicationID = '55c92734-d682-4d71-983e-d6ec3f16059f'" -ErrorAction SilentlyContinue | Select-Object -First 1 if ($license) { $channel = [string]$license.ProductKeyChannel $licenseStatus = [int]$license.LicenseStatus } $language = [string]$os.OSLanguage try { $language = [System.Globalization.CultureInfo]::GetCultureInfo([int]$os.OSLanguage).Name } catch { } $displayVersion = '' if ($cv.PSObject.Properties['DisplayVersion']) { $displayVersion = [string]$cv.DisplayVersion } $facts = [pscustomobject]@{ Build = [int]$cv.CurrentBuildNumber ProductName = [string]$cv.ProductName EditionId = [string]$cv.EditionID InstallationType = [string]$cv.InstallationType DisplayVersion = $displayVersion Architecture = [string]$os.OSArchitecture Language = $language FreeGB = $freeGB PendingReboot = $pending DomainRole = [int]$cs.DomainRole ClusterServicePresent = [bool](Get-Service -Name 'ClusSvc' -ErrorAction SilentlyContinue) ActivationChannel = $channel LicenseStatus = $licenseStatus SetupRunning = [bool](Get-Process -Name 'setuphost', 'setupprep' -ErrorAction SilentlyContinue) } Write-Output ('IPU-FACTS=' + ($facts | ConvertTo-Json -Compress)) '@ } 'SetupPath' { return @' $ErrorActionPreference = 'Stop' # The ARM attach returns before the guest storage stack has enumerated the new SCSI device, so # force a rescan on every attempt. A data disk can also come up offline depending on the SAN policy. Update-HostStorageCache -ErrorAction SilentlyContinue Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | Out-Null $disks = Get-Disk $disks | Where-Object { $_.OperationalStatus -eq 'Offline' } | ForEach-Object { Set-Disk -Number $_.Number -IsOffline $false } $disks | Where-Object { $_.IsReadOnly } | ForEach-Object { Set-Disk -Number $_.Number -IsReadOnly $false } $systemLetter = $env:SystemDrive.Substring(0, 1) $found = $null $listing = @() foreach ($vol in (Get-Volume | Where-Object { $_.DriveLetter })) { if ([string]$vol.DriveLetter -eq $systemLetter) { continue } $root = '{0}:\' -f $vol.DriveLetter # The upgrade media is not laid out like an ISO; setup.exe is not guaranteed at the root. # Depth is capped so an unrelated large data disk cannot make this hang. $match = Get-ChildItem -Path $root -Filter 'setup.exe' -File -Recurse -Depth 4 -ErrorAction SilentlyContinue | Select-Object -First 1 if ($match) { $found = $match.FullName; break } $topLevel = (Get-ChildItem -Path $root -ErrorAction SilentlyContinue | Select-Object -First 20 -ExpandProperty Name) -join ',' $listing += ('{0}[{1}]:{2}' -f $vol.DriveLetter, $vol.FileSystemLabel, $topLevel) } if ($found) { Write-Output ('RESULT=OK;SETUP={0}' -f $found) } else { $diskSummary = ($disks | ForEach-Object { '#{0}:{1}:{2}' -f $_.Number, $_.OperationalStatus, $_.PartitionStyle }) -join ',' Write-Output ('RESULT=NOTFOUND;DISKS={0};LISTING={1}' -f $diskSummary, ($listing -join '|')) } '@ } 'WimImages' { return @' param([string]$SetupPath) $ErrorActionPreference = 'Stop' $root = [System.IO.Path]::GetDirectoryName($SetupPath) $wim = Get-ChildItem -Path $root -Recurse -Depth 2 -Include 'install.wim', 'install.esd' -File -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $wim) { Write-Output ('IPU-IMAGES=' + (@{ WimPath = ''; Images = @() } | ConvertTo-Json -Compress)) exit 0 } $images = @() foreach ($img in (Get-WindowsImage -ImagePath $wim.FullName)) { $detail = Get-WindowsImage -ImagePath $wim.FullName -Index $img.ImageIndex $images += [pscustomobject]@{ Index = [int]$img.ImageIndex Name = [string]$img.ImageName EditionId = [string]$detail.EditionId InstallationType = [string]$detail.InstallationType Version = [string]$detail.Version } } Write-Output ('IPU-IMAGES=' + (@{ WimPath = $wim.FullName; Images = $images } | ConvertTo-Json -Compress -Depth 3)) '@ } 'Launch' { return @' param([string]$SetupPath, [string]$TaskName, [string]$ProductKey, [int]$TargetImageIndex, [string]$InstallFrom, [string]$LogDirectory) $ErrorActionPreference = 'Stop' if (-not (Test-Path -LiteralPath $SetupPath)) { Write-Output 'RESULT=NOTFOUND' exit 0 } # Idempotency: never start a second Setup next to a running one. $existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($existing -and $existing.State -eq 'Running') { Write-Output 'RESULT=ALREADYRUNNING' exit 0 } if (Get-Process -Name 'setuphost', 'setupprep' -ErrorAction SilentlyContinue) { Write-Output 'RESULT=ALREADYRUNNING' exit 0 } New-Item -Path $LogDirectory -ItemType Directory -Force | Out-Null $arguments = '/auto upgrade /quiet /eula accept /compat ignorewarning /dynamicupdate disable /showoobe none /telemetry disable /copylogs "{0}"' -f $LogDirectory if ($ProductKey) { $arguments = '{0} /pkey {1}' -f $arguments, $ProductKey } $workingDirectory = [System.IO.Path]::GetDirectoryName($SetupPath) if ($TargetImageIndex -gt 0) { $installFile = $InstallFrom if (-not $installFile) { $installFile = Join-Path $workingDirectory 'sources\install.wim' } if (-not (Test-Path -LiteralPath $installFile)) { Write-Output ('RESULT=INSTALLWIMNOTFOUND;WIM={0}' -f $installFile) exit 0 } $arguments = '{0} /installfrom "{1}" /imageindex {2}' -f $arguments, $installFile, $TargetImageIndex } $action = New-ScheduledTaskAction -Execute $SetupPath -Argument $arguments -WorkingDirectory $workingDirectory $principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 0 -MultipleInstances IgnoreNew -DontStopOnIdleEnd -Priority 4 Register-ScheduledTask -TaskName $TaskName -Action $action -Principal $principal -Settings $settings ` -Description 'Unattended Windows Server in-place upgrade (azure-vm-inplace-upgrade)' -Force | Out-Null Start-ScheduledTask -TaskName $TaskName Start-Sleep -Seconds 10 $task = Get-ScheduledTask -TaskName $TaskName $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName $setupProcesses = @(Get-Process -Name 'setup', 'setuphost', 'setupprep' -ErrorAction SilentlyContinue) $processNames = ($setupProcesses | Select-Object -ExpandProperty ProcessName) -join ',' $lastResultHex = ('0x{0:X8}' -f [uint32]$taskInfo.LastTaskResult) # A task can register fine while its action dies at once (path with a space, SYSTEM cannot run # the media). Report the state and last result now instead of letting the caller wait for a timeout. $state = 'FAILED' if ($task.State -eq 'Running' -or $setupProcesses.Count -gt 0) { $state = 'STARTED' } Write-Output ('RESULT={0};STATE={1};LASTRESULT={2};HEX={3};PROCESSES={4};ARGS={5}' -f $state, $task.State, $taskInfo.LastTaskResult, $lastResultHex, $processNames, $arguments) '@ } 'FeatureUpdateLaunch' { return @' param([string]$TaskName, [string]$LogDirectory, [string]$RegistryOptIn, [string]$SearchCriteria, [string]$TitlePrefix) $ErrorActionPreference = 'Stop' $existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($existing -and $existing.State -eq 'Running') { Write-Output 'RESULT=ALREADYRUNNING' exit 0 } New-Item -Path $LogDirectory -ItemType Directory -Force | Out-Null # Opt in: without this policy value Windows Update never offers the feature update. New-Item -Path $RegistryOptIn -Force | Out-Null New-ItemProperty -Path $RegistryOptIn -Name 'AllowWindowsServerFeatureUpdate' -PropertyType DWord -Value 1 -Force | Out-Null $session = New-Object -ComObject Microsoft.Update.Session $searcher = $session.CreateUpdateSearcher() $found = $null $offered = @() foreach ($u in $searcher.Search($SearchCriteria).Updates) { $offered += $u.Title if ($u.Title -like ($TitlePrefix + '*')) { $found = $u } } if (-not $found) { Write-Output ('RESULT=NOTOFFERED;OFFERED={0}' -f ($offered -join '|')) exit 0 } $workerPath = Join-Path $LogDirectory 'feature-update-worker.ps1' $worker = @" `$ErrorActionPreference = 'Continue' `$log = '$LogDirectory\feature-update.log' function Write-Log([string]`$m) { ('{0:u} {1}' -f (Get-Date), `$m) | Out-File -FilePath `$log -Append -Encoding utf8 } try { Write-Log 'worker start' `$session = New-Object -ComObject Microsoft.Update.Session `$searcher = `$session.CreateUpdateSearcher() `$update = `$null foreach (`$u in `$searcher.Search("$SearchCriteria").Updates) { if (`$u.Title -like '$TitlePrefix*') { `$update = `$u } } if (-not `$update) { Write-Log 'feature update no longer offered'; exit 1 } Write-Log ('found ' + `$update.Title + ' ' + `$update.Identity.UpdateID) if (-not `$update.EulaAccepted) { `$update.AcceptEula() } `$coll = New-Object -ComObject Microsoft.Update.UpdateColl `$null = `$coll.Add(`$update) if (-not `$update.IsDownloaded) { `$downloader = `$session.CreateUpdateDownloader() `$downloader.Updates = `$coll Write-Log 'download start' `$dr = `$downloader.Download() Write-Log ('download result ' + `$dr.ResultCode + ' hr 0x' + ('{0:X8}' -f `$dr.HResult)) if (`$dr.ResultCode -ne 2) { exit 1 } } `$installer = `$session.CreateUpdateInstaller() `$installer.Updates = `$coll `$installer.AllowSourcePrompts = `$false `$installer.ForceQuiet = `$true Write-Log 'install start' `$ir = `$installer.Install() Write-Log ('install result ' + `$ir.ResultCode + ' hr 0x' + ('{0:X8}' -f `$ir.HResult) + ' reboot ' + `$ir.RebootRequired) if (`$ir.ResultCode -ne 2 -and `$ir.ResultCode -ne 3) { exit 1 } if (`$ir.RebootRequired) { # Install() only stages a feature update. Without Commit() the next restart is an ordinary # one: the guest comes back on the old build with 0x80242014 in the update history. # Commit needs the installer's Updates collection set and takes a few minutes. Write-Log 'commit start' `$installer.Commit(0) Write-Log 'commit done, restarting through the update orchestrator' Start-Process -FilePath "`$env:SystemRoot\System32\UsoClient.exe" -ArgumentList 'RestartDevice' -WindowStyle Hidden -ErrorAction SilentlyContinue Start-Process -FilePath "`$env:SystemRoot\System32\shutdown.exe" -ArgumentList '/r /t 90 /c "Windows Server feature update (azure-vm-inplace-upgrade)"' -WindowStyle Hidden } } catch { Write-Log ('error ' + `$_.Exception.Message); exit 1 } "@ Set-Content -Path $workerPath -Value $worker -Encoding UTF8 $action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument ('-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f $workerPath) $principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 0 -MultipleInstances IgnoreNew -DontStopOnIdleEnd Register-ScheduledTask -TaskName $TaskName -Action $action -Principal $principal -Settings $settings ` -Description 'Unattended Windows Server feature update (azure-vm-inplace-upgrade)' -Force | Out-Null Start-ScheduledTask -TaskName $TaskName Start-Sleep -Seconds 10 $task = Get-ScheduledTask -TaskName $TaskName $state = if ($task.State -eq 'Running') { 'STARTED' } else { 'FAILED' } Write-Output ('RESULT={0};STATE={1};UPDATE={2};SIZEMB={3}' -f $state, $task.State, $found.Title, [int]($found.MaxDownloadSize / 1MB)) '@ } 'Status' { return @' param([string]$TaskName) $ErrorActionPreference = 'Stop' $cv = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' $setupRunning = [bool](Get-Process -Name 'setuphost', 'setupprep' -ErrorAction SilentlyContinue) $taskState = 'None' $taskResult = $null $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($task) { $taskState = [string]$task.State $info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue if ($info) { $taskResult = [uint32]$info.LastTaskResult } } $displayVersion = '' if ($cv.PSObject.Properties['DisplayVersion']) { $displayVersion = [string]$cv.DisplayVersion } $status = [pscustomobject]@{ Build = [int]$cv.CurrentBuildNumber ProductName = [string]$cv.ProductName DisplayVersion = $displayVersion SetupRunning = $setupRunning TaskState = $taskState TaskResult = $taskResult } Write-Output ('IPU-STATUS=' + ($status | ConvertTo-Json -Compress)) '@ } 'LogTail' { return @' param([string]$CopyLogsPath) $ErrorActionPreference = 'SilentlyContinue' # Setup stages its logs under $WINDOWS.~BT from the first second; /copylogs only fills the # target later. Check the staging area first, the copy target as a fallback. $roots = @('C:\$WINDOWS.~BT\Sources\Panther', (Join-Path $CopyLogsPath 'Panther'), $CopyLogsPath) $out = New-Object System.Text.StringBuilder foreach ($root in $roots) { if (-not (Test-Path -LiteralPath $root)) { continue } $null = $out.AppendLine("ROOT=$root") foreach ($logName in 'setuperr.log', 'setupact.log', 'feature-update.log') { $logPath = Join-Path $root $logName if (Test-Path -LiteralPath $logPath) { $tail = Get-Content -LiteralPath $logPath -Tail 25 $null = $out.AppendLine("--- $logName (last 25 lines) ---") $null = $out.AppendLine(($tail -join "`n")) } } # Early hard blocks (edition, pending reboot, incompatible apps) land in the compat XML, # not in setupact.log. $compatFiles = Get-ChildItem -LiteralPath $root -Filter '*.xml' -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'Compat|ScanResult' } foreach ($xml in $compatFiles) { $blocking = Select-String -LiteralPath $xml.FullName -Pattern 'Block|Message=' -ErrorAction SilentlyContinue | Select-Object -First 10 -ExpandProperty Line if ($blocking) { $null = $out.AppendLine("--- $($xml.Name) (blocking entries) ---") $null = $out.AppendLine(($blocking -join "`n")) } } if ($out.Length -gt 0) { break } } $text = $out.ToString() if ([string]::IsNullOrWhiteSpace($text)) { Write-Output 'RESULT=NOLOGS' } else { if ($text.Length -gt 3000) { $text = $text.Substring(0, 3000) + "`n...(truncated)" } Write-Output ('RESULT=OK' + "`n" + $text) } '@ } 'RemoveTask' { return @' param([string]$TaskName) $ErrorActionPreference = 'SilentlyContinue' $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($task) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false Write-Output 'RESULT=REMOVED' } else { Write-Output 'RESULT=NOTFOUND' } '@ } } } |