150 lines
12 KiB
PowerShell
150 lines
12 KiB
PowerShell
[CmdletBinding()]
|
|
param([Parameter(Mandatory=$true)][string]$SourcePath,[string]$ConfigPath,[string]$LogDirectory,[string]$ResourceContextPath,[switch]$Offline)
|
|
$ErrorActionPreference='Stop'
|
|
. (Join-Path $PSScriptRoot 'scripts\Project.Common.ps1')
|
|
. (Join-Path $PSScriptRoot 'scripts\Software.Selection.ps1')
|
|
. (Join-Path $PSScriptRoot 'scripts\Build.Resources.ps1')
|
|
Assert-PowerShell51
|
|
Assert-Administrator
|
|
$settings=Get-ProjectSettings
|
|
if(-not$ConfigPath){$ConfigPath=Join-Path $ProjectRoot 'local\image.ini'}
|
|
Import-Module (Join-Path $settings.UpstreamRoot 'Config.psm1') -Force
|
|
$config=Get-WindowsImageConfig -ConfigFilePath $ConfigPath
|
|
$source=[IO.Path]::GetFullPath($SourcePath)
|
|
if([IO.Path]::GetFullPath($config.image_path) -ine $source){throw 'The verification config must describe this exact output image.'}
|
|
if([IO.Path]::GetExtension($source) -ine '.qcow2' -or -not(Test-Path -LiteralPath $source -PathType Leaf)){throw 'Select an existing QCOW2 image.'}
|
|
$software=Get-GuestSoftwareSelection -ResourceRoot $config.custom_resources_path
|
|
$verifyActivation=([bool]$software.KmsServer -and (Test-Path -LiteralPath (Join-Path $config.custom_resources_path 'Activate-Windows.ps1') -PathType Leaf))
|
|
$kmsEndpoint=ConvertTo-PveKmsEndpoint -Address ([string]$software.KmsServer)
|
|
$manifest=Get-Content -Raw -Encoding UTF8 -LiteralPath $settings.QemuManifestPath | ConvertFrom-Json
|
|
$qemu=$manifest.ExecutablePath
|
|
if(-not$manifest.PublishedHashVerified -or (Get-FileHash -Algorithm SHA256 -LiteralPath $qemu).Hash -ne $manifest.ExecutableSha256){throw 'QEMU verification failed.'}
|
|
$runId=(Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N').Substring(0,8)
|
|
$work=Join-Path $settings.AssetsRoot ('work\boot-check-'+$runId)
|
|
if(-not$LogDirectory){$LogDirectory=Join-Path $settings.AssetsRoot ('logs\boot-check-'+$runId)}
|
|
Register-PveBuildDirectory -ContextPath $ResourceContextPath -Path $work
|
|
New-Item -ItemType Directory -Path $work,$LogDirectory -Force|Out-Null
|
|
$bootDisk=Join-Path $work 'boot-copy.vhdx'
|
|
$vmName='PveBuilder-BootCheck-'+$runId
|
|
$ownedVmId=$null
|
|
$complete=$false
|
|
$state=[ordered]@{Phase='preparing';SourcePath=$source;VmName=$vmName;BootDisk=$bootDisk;StartedUtc=[DateTime]::UtcNow.ToString('o');LogDirectory=$LogDirectory;Offline=[bool]$Offline;VerifyOptionalActivation=$verifyActivation}
|
|
function Save-BootState { [IO.File]::WriteAllText((Join-Path $LogDirectory 'status.json'),($state|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false)) }
|
|
function Invoke-BootQemu {
|
|
param([string[]]$Arguments,[string]$Name)
|
|
Invoke-BootNative -Executable $qemu -Arguments $Arguments -Name $Name
|
|
}
|
|
function Invoke-BootNative {
|
|
param([string]$Executable,[string[]]$Arguments,[string]$Name)
|
|
$priorErrorAction=$ErrorActionPreference
|
|
try{$ErrorActionPreference='Continue';$result=@(& $Executable @Arguments 2>&1|ForEach-Object{$_.ToString()});$exitCode=$LASTEXITCODE}
|
|
finally{$ErrorActionPreference=$priorErrorAction}
|
|
[IO.File]::WriteAllText((Join-Path $LogDirectory ($Name+'.txt')),($result -join [Environment]::NewLine),[Text.UTF8Encoding]::new($false))
|
|
if($exitCode -ne 0){throw ("$Name failed with exit $exitCode")}
|
|
}
|
|
try{
|
|
Save-BootState
|
|
$state.SourceSHA256=(Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash
|
|
Invoke-BootQemu -Arguments @('check','-f','qcow2',$source) -Name 'source-check'
|
|
$state.Phase='converting_boot_copy';Save-BootState
|
|
Invoke-BootQemu -Arguments @('convert','-m','1','-f','qcow2','-O','vhdx','-o','subformat=dynamic',$source,$bootDisk) -Name 'convert-boot-copy'
|
|
if((Get-Item -LiteralPath $bootDisk).Attributes -band [IO.FileAttributes]::SparseFile){
|
|
& fsutil.exe sparse setflag $bootDisk 0|Out-Null
|
|
if($LASTEXITCODE -ne 0){throw 'Could not clear the sparse attribute on the temporary boot VHDX.'}
|
|
}
|
|
Invoke-BootQemu -Arguments @('compare','-f','qcow2','-F','vhdx',$source,$bootDisk) -Name 'compare-boot-copy'
|
|
$state.PayloadCompare='passed';Save-BootState
|
|
$metadataRoot=Join-Path $work 'configdrive'
|
|
$metadataDirectory=Join-Path $metadataRoot 'openstack\latest'
|
|
New-Item -ItemType Directory -Path $metadataDirectory -Force|Out-Null
|
|
$password='Test!'+[guid]::NewGuid().ToString('N')
|
|
$metadata=@{uuid=[guid]::NewGuid().ToString();hostname='pve-bootcheck';name='pve-bootcheck';admin_pass=$password}
|
|
[IO.File]::WriteAllText((Join-Path $metadataDirectory 'meta_data.json'),($metadata|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
|
|
$oscdimg=Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe'
|
|
if(-not(Test-Path -LiteralPath $oscdimg -PathType Leaf)){throw 'Install the official Microsoft ADK Deployment Tools (oscdimg) for boot verification.'}
|
|
$configIso=Join-Path $work 'configdrive.iso'
|
|
Invoke-BootNative -Executable $oscdimg -Arguments @('-n','-m','-lconfig-2',$metadataRoot,$configIso) -Name 'create-configdrive'
|
|
$state.Phase='booting';Save-BootState
|
|
Register-PveBuildVm -ContextPath $ResourceContextPath -Name $vmName -VhdPath $bootDisk
|
|
$vm=New-VM -Name $vmName -Generation 2 -MemoryStartupBytes $config.ram_size -SwitchName $config.external_switch -VHDPath $bootDisk
|
|
$ownedVmId=$vm.Id
|
|
Register-PveBuildVm -ContextPath $ResourceContextPath -Name $vmName -VhdPath $bootDisk -Id ([string]$ownedVmId)
|
|
Set-VM -VM $vm -AutomaticCheckpointsEnabled $false -CheckpointType Disabled -AutomaticStartAction Nothing -AutomaticStopAction ShutDown
|
|
Set-VMProcessor -VM $vm -Count $config.cpu_count
|
|
Set-VMMemory -VM $vm -DynamicMemoryEnabled $false
|
|
Add-VMDvdDrive -VM $vm -Path $configIso|Out-Null
|
|
Set-VMFirmware -VM $vm -EnableSecureBoot On -SecureBootTemplate MicrosoftWindows -FirstBootDevice (Get-VMHardDiskDrive -VM $vm|Select-Object -First 1)
|
|
if ($Offline) { Disconnect-VMNetworkAdapter -VMName $vm.Name }
|
|
Start-VM -VM $vm
|
|
$credential=New-Object Management.Automation.PSCredential('Administrator',(ConvertTo-SecureString $password -AsPlainText -Force))
|
|
$deadline=[DateTime]::UtcNow.AddMinutes(30)
|
|
$guestProof=$null
|
|
while(-not$guestProof -and [DateTime]::UtcNow -lt $deadline){
|
|
try{
|
|
$guestProof=Invoke-Command -VMId $ownedVmId -Credential $credential -ErrorAction Stop -ArgumentList @($software.InstallChrome,$software.InstallVSCode,$verifyActivation,$kmsEndpoint.Host,$kmsEndpoint.Port) -ScriptBlock {
|
|
param($InstallChrome,$InstallVSCode,$VerifyActivation,$ExpectedKmsHost,$ExpectedKmsPort)
|
|
$ErrorActionPreference='Stop'
|
|
$administrator=Get-LocalUser|Where-Object {$_.SID.Value.EndsWith('-500')}
|
|
$otherAdmin=Get-LocalUser -Name 'Admin' -ErrorAction SilentlyContinue
|
|
$qga=Get-Service -Name 'qemu-ga'
|
|
$cloudbase=Get-Service -Name 'cloudbase-init'
|
|
$rdp=Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections
|
|
$nla=Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name UserAuthentication
|
|
$proof=[ordered]@{
|
|
Boot='passed';AdministratorEnabled=$administrator.Enabled;ExtraAdminDisabled=(-not$otherAdmin -or -not$otherAdmin.Enabled)
|
|
QemuGuestAgent=($qga.StartType -eq 'Automatic');CloudbaseInit=($null -ne $cloudbase)
|
|
RdpEnabled=($rdp.fDenyTSConnections -eq 0);NlaEnabled=($nla.UserAuthentication -eq 1)
|
|
RdpListening=(@(Get-NetTCPConnection -LocalPort 3389 -State Listen -ErrorAction SilentlyContinue).Count -gt 0)
|
|
FirewallDisabled=(@(Get-NetFirewallProfile|Where-Object {$_.Enabled -ne 'False'}).Count -eq 0)
|
|
WindowsVersion=(Get-CimInstance Win32_OperatingSystem).Version
|
|
}
|
|
foreach($app in @(@{Name='Chrome';Selected=$InstallChrome;Path=(Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe')},@{Name='VSCode';Selected=$InstallVSCode;Path=(Join-Path $env:ProgramFiles 'Microsoft VS Code\Code.exe')})){
|
|
if($app.Selected){$proof[$app.Name+'Version']=(Get-Item -LiteralPath $app.Path).VersionInfo.ProductVersion.Trim()}
|
|
}
|
|
if(-not$proof.AdministratorEnabled -or -not$proof.ExtraAdminDisabled -or -not$proof.QemuGuestAgent -or -not$proof.CloudbaseInit -or -not$proof.RdpEnabled -or -not$proof.RdpListening -or -not$proof.NlaEnabled -or -not$proof.FirewallDisabled){throw 'Guest configuration has not passed all checks.'}
|
|
if ($VerifyActivation) {
|
|
$activationPath=Join-Path $env:ProgramData 'PveImageBuilder\windows-activation.json'
|
|
if (-not (Test-Path -LiteralPath $activationPath -PathType Leaf)) { throw 'Waiting for the optional activation script to finish.' }
|
|
$activation=Get-Content -Raw -Encoding UTF8 -LiteralPath $activationPath | ConvertFrom-Json
|
|
if ($activation.SchemaVersion -ne 1 -or -not $activation.CompletedUtc -or
|
|
$activation.Outcome -notin @('Activated','AlreadyActivated','Skipped','Failed') -or
|
|
$activation.KmsHost -ine $ExpectedKmsHost -or $activation.KmsPort -ne $ExpectedKmsPort) { throw 'Optional activation diagnostics are incomplete or describe a different KMS endpoint.' }
|
|
$cloudbaseLog=Join-Path $env:ProgramFiles 'Cloudbase Solutions\Cloudbase-Init\log\cloudbase-init.log'
|
|
$cloudbaseText=Get-Content -Raw -LiteralPath $cloudbaseLog
|
|
if ($cloudbaseText -notmatch '30-WindowsActivation\.ps1" ended with exit code: 0(?:\s|$)' -or
|
|
$cloudbaseText -notmatch 'Plugins execution done') { throw 'Waiting for Cloudbase-Init LocalScripts to complete without requesting a reboot.' }
|
|
$licensing=Get-CimInstance -ClassName SoftwareLicensingService -ErrorAction Stop
|
|
if ($licensing.KeyManagementServiceMachine -ine $ExpectedKmsHost -or
|
|
$licensing.KeyManagementServicePort -ne $ExpectedKmsPort) { throw 'The selected KMS settings were not reapplied after Sysprep.' }
|
|
$kmsProducts=@(Get-CimInstance -ClassName SoftwareLicensingProduct -Filter "ApplicationID='55c92734-d682-4d71-983e-d6ec3f16059f' AND PartialProductKey IS NOT NULL" -ErrorAction Stop |
|
|
Where-Object { $_.Description -match '\bVOLUME_KMSCLIENT\b' -and -not $_.LicenseIsAddon })
|
|
$proof.WindowsKmsClientInstalled=($kmsProducts.Count -gt 0)
|
|
$proof.KmsSettingsReapplied=$true
|
|
$proof.CloudbaseLocalScriptsCompleted=$true
|
|
# Failed and skipped activation are valid deployment outcomes, including offline boots.
|
|
$proof.WindowsActivation=$activation
|
|
}
|
|
[PSCustomObject]$proof
|
|
}
|
|
}catch{$state.LastProbeError=$_.Exception.Message;Save-BootState;Start-Sleep -Seconds 15}
|
|
}
|
|
if(-not$guestProof){throw 'The independent UEFI boot copy did not pass guest checks within 30 minutes.'}
|
|
[IO.File]::WriteAllText((Join-Path $LogDirectory 'guest-proof.json'),($guestProof|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false))
|
|
try{Invoke-Command -VMId $ownedVmId -Credential $credential -ScriptBlock {Stop-Computer -Force} -ErrorAction Stop|Out-Null}catch{}
|
|
$stopDeadline=[DateTime]::UtcNow.AddMinutes(2)
|
|
while((Get-VM -Id $ownedVmId).State -ne 'Off' -and [DateTime]::UtcNow -lt $stopDeadline){Start-Sleep -Seconds 3}
|
|
if((Get-VM -Id $ownedVmId).State -ne 'Off'){Stop-VM -VM (Get-VM -Id $ownedVmId) -TurnOff -Force}
|
|
if((Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash -ne $state.SourceSHA256){throw 'The original QCOW2 changed during verification.'}
|
|
$state.Phase='complete';$state.Boot='passed';$state.OriginalImageUnchanged=$true;$state.CompletedUtc=[DateTime]::UtcNow.ToString('o')
|
|
$state.Remove('LastProbeError')
|
|
$complete=$true
|
|
Save-BootState
|
|
[PSCustomObject]$state
|
|
}catch{$state.Phase='failed';$state.Error=$_.Exception.Message;Save-BootState;throw}
|
|
finally{
|
|
if($ownedVmId){
|
|
$ownedVm=Get-VM -Id $ownedVmId -ErrorAction SilentlyContinue
|
|
if($ownedVm){if($ownedVm.State -ne 'Off'){Stop-VM -VM $ownedVm -TurnOff -Force};if($complete){Remove-VM -VM $ownedVm -Force}}
|
|
}
|
|
}
|