Files

203 lines
14 KiB
PowerShell

[CmdletBinding()]
param([ValidateSet('Automatic','Interactive')][string]$Mode='Automatic')
$ErrorActionPreference='Stop'
$repo=Split-Path -Parent $PSScriptRoot
. (Join-Path $repo 'scripts\Project.Common.ps1')
Assert-PowerShell51
. (Join-Path $repo 'scripts\Build.Cancellation.ps1')
function Assert-CancelTest { param([bool]$Condition,[string]$Message) if (-not $Condition) { throw $Message } }
function Assert-CancelFailure {
param([scriptblock]$Action,[string]$Pattern)
$failed=$false
try { & $Action | Out-Null } catch { $failed=$true; if ($_.Exception.Message -notmatch $Pattern) { throw } }
Assert-CancelTest $failed ('Expected failure: '+$Pattern)
}
$fixture=Join-Path $repo ('local\cancellation tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path (Join-Path $fixture 'scripts') -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $repo 'scripts\Build.Resources.ps1') -Destination (Join-Path $fixture 'scripts\Build.Resources.ps1')
$worker=Join-Path $fixture "dummy worker's `$literal.ps1"
$workerText=@'
[CmdletBinding(SupportsShouldProcess=$true)]
param($ConfigPath,[switch]$InternalWorker,$ResourceContextPath,[switch]$SkipBootVerification)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'scripts\Build.Resources.ps1')
$context=Get-PveBuildResources -ContextPath $ResourceContextPath
$request=Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json
$vhd=[IO.Path]::ChangeExtension($context.OutputPath,'.vhdx')
[IO.File]::WriteAllText($vhd,'New incomplete disk; fixture only.')
[IO.File]::WriteAllText($context.OutputPath,'New incomplete output; fixture only.')
$temporary=Join-Path $context.AssetsRoot 'work\owned temporary files'
Register-PveBuildDirectory -ContextPath $ResourceContextPath -Path $temporary
New-Item -ItemType Directory -Path $temporary | Out-Null
[IO.File]::WriteAllText((Join-Path $temporary 'boot-copy.vhdx'),'Boot copy fixture.')
[IO.File]::WriteAllText((Join-Path $temporary 'configdrive.iso'),'Generated ISO fixture.')
[IO.File]::WriteAllText((Join-Path $context.LogDirectory 'diagnostic.log'),'Keep this diagnostic log.')
$context=Get-PveBuildResources -ContextPath $ResourceContextPath
$context.VMs=@([PSCustomObject]@{Name=$request.VmName;Id=$request.VmId;VhdPath=$vhd;ExistedBefore=$false})
Save-PveBuildResources -ContextPath $ResourceContextPath -Context $context
if ($request.Action -eq 'success') {
$context.Completed=$true
Save-PveBuildResources -ContextPath $ResourceContextPath -Context $context
[IO.File]::WriteAllText((Join-Path $context.LogDirectory 'status.json'),'{"Phase":"complete"}')
exit 0
}
if ($request.Action -eq 'error') { throw 'Expected dummy build failure.' }
$native=New-Object Diagnostics.ProcessStartInfo
$native.FileName=Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe'
$native.Arguments='-NoProfile -Command "Start-Sleep -Seconds 120"'
$native.UseShellExecute=$false
$native.CreateNoWindow=$true
$child=[Diagnostics.Process]::Start($native)
[IO.File]::WriteAllText((Join-Path $context.LogDirectory 'child.pid'),[string]$child.Id)
[IO.File]::WriteAllText($request.ReadyPath,'ready')
Write-Host 'CANCEL_TEST_READY - 请按 Ctrl+C'
Start-Sleep -Seconds 120
'@
[IO.File]::WriteAllText($worker,$workerText,[Text.UTF8Encoding]::new($true))
& {
$global:PveCancellationTestState=@{VMs=@{};Events=(New-Object 'System.Collections.Generic.List[string]');Answers=(New-Object 'System.Collections.Generic.Queue[string]');Reads=0;ReadyPath='';Deadline=[DateTime]::UtcNow.AddSeconds(30);Images=@{}}
function Get-VM {
param($Id,$Name,$ErrorAction)
foreach ($vm in $global:PveCancellationTestState.VMs.Values) {
if (($Id -and [string]$vm.Id -eq [string]$Id) -or ($Name -and $vm.Name -eq $Name)) { return $vm }
}
}
function Get-VMHardDiskDrive { param($VM,$ErrorAction) [PSCustomObject]@{Path=$VM.DiskPath} }
function Stop-VM {
param($VM,[switch]$TurnOff,[switch]$Force,$ErrorAction)
$global:PveCancellationTestState.Events.Add('stop:'+ $VM.Name)
$VM.State='Off'
}
function Remove-VM {
param($VM,[switch]$Force,$ErrorAction)
$global:PveCancellationTestState.Events.Add('remove:'+ $VM.Name)
$global:PveCancellationTestState.VMs.Remove([string]$VM.Id)
}
function Get-VHD { param($Path,$ErrorAction) [PSCustomObject]@{Attached=$true;Path=$Path} }
function Get-Disk {
[CmdletBinding()]param([Parameter(ValueFromPipeline=$true)]$InputObject)
process { [PSCustomObject]@{IsBoot=$false;IsSystem=$false} }
}
function Dismount-VHD { param($Path,$ErrorAction) $global:PveCancellationTestState.Events.Add('disk:'+ $Path) }
function Get-DiskImage {
param($ImagePath,$ErrorAction)
[PSCustomObject]@{Attached=($global:PveCancellationTestState.Images.ContainsKey($ImagePath) -and $global:PveCancellationTestState.Images[$ImagePath])}
}
function Dismount-DiskImage { param($ImagePath,$ErrorAction) $global:PveCancellationTestState.Events.Add('iso:'+ $ImagePath);$global:PveCancellationTestState.Images[$ImagePath]=$false }
if ($Mode -eq 'Automatic') {
function Read-Host {
param($Prompt)
$global:PveCancellationTestState.Reads++
if (-not $global:PveCancellationTestState.Answers.Count) { throw 'Unexpected cleanup prompt.' }
$global:PveCancellationTestState.Answers.Dequeue()
}
function Test-PveBuildCancellationKey {
if ([DateTime]::UtcNow -gt $global:PveCancellationTestState.Deadline) { throw 'Dummy worker startup timed out.' }
return [IO.File]::Exists($global:PveCancellationTestState.ReadyPath)
}
}
$checked=0
foreach ($choice in @('default','no','retry','success','error')) {
if ($Mode -eq 'Interactive' -and $choice -notin @('default','no')) { continue }
$case=Join-Path $fixture $choice
$assets=Join-Path $case 'assets'
foreach ($directory in @('iso','output','custom-resources')) { New-Item -ItemType Directory -Path (Join-Path $assets $directory) -Force | Out-Null }
$source=Join-Path $assets 'iso\install.wim'
$iso=Join-Path $assets 'iso\virtio.iso'
$oldImage=Join-Path $assets 'output\existing.qcow2'
$installer=Join-Path $assets 'custom-resources\installer.exe'
foreach ($file in @($source,$iso,$oldImage,$installer)) { [IO.File]::WriteAllText($file,'Existing user file; preserve.') }
$output=Join-Path $assets "output\new disk's `$literal.qcow2"
$ownedId=[guid]::NewGuid().ToString()
$otherId=[guid]::NewGuid().ToString()
$owned=[PSCustomObject]@{Name='owned-test-vm';Id=$ownedId;DiskPath=[IO.Path]::ChangeExtension($output,'.vhdx');State='Running'}
$other=[PSCustomObject]@{Name='existing-user-vm';Id=$otherId;DiskPath=(Join-Path $assets 'output\existing.vhdx');State='Running'}
$global:PveCancellationTestState.VMs=@{$ownedId=$owned;$otherId=$other}
$global:PveCancellationTestState.Events.Clear()
$global:PveCancellationTestState.Reads=0
$global:PveCancellationTestState.Answers.Clear()
$global:PveCancellationTestState.ReadyPath=Join-Path $case 'ready'
$global:PveCancellationTestState.Deadline=[DateTime]::UtcNow.AddSeconds(30)
$global:PveCancellationTestState.Images=@{$iso=$true}
$action='wait'
if ($choice -in @('success','error')) { $action=$choice }
if ($choice -eq 'default') { $global:PveCancellationTestState.Answers.Enqueue('') }
elseif ($choice -eq 'no') { $global:PveCancellationTestState.Answers.Enqueue('N') }
elseif ($choice -eq 'retry') { $global:PveCancellationTestState.Answers.Enqueue('invalid');$global:PveCancellationTestState.Answers.Enqueue('Y') }
$request=Join-Path $case 'request.json'
[IO.File]::WriteAllText($request,(@{Action=$action;VmName=$owned.Name;VmId=$ownedId;ReadyPath=$global:PveCancellationTestState.ReadyPath}|ConvertTo-Json))
$settings=[PSCustomObject]@{ProjectRoot=$case;AssetsRoot=$assets}
$config=[PSCustomObject]@{wim_file_path=$source;virtio_iso_path=$iso;image_path=$output}
if ($Mode -eq 'Interactive') { Write-Host ('INTERACTIVE_CASE='+$choice) }
if ($choice -eq 'error') {
Assert-CancelFailure { Invoke-PveControlledBuild -ScriptPath $worker -ConfigPath $request -Settings $settings -Config $config } '构建失败'
Assert-CancelTest ($global:PveCancellationTestState.Reads -eq 0 -and (Test-Path -LiteralPath $output)) 'Ordinary failure prompted or deleted resources.'
} else {
$result=Invoke-PveControlledBuild -ScriptPath $worker -ConfigPath $request -Settings $settings -Config $config
if ($choice -eq 'success') {
Assert-CancelTest ($result.Phase -eq 'complete' -and $global:PveCancellationTestState.Reads -eq 0 -and (Test-Path -LiteralPath $output)) 'Successful build prompted or removed its output.'
} else {
$keep=($choice -eq 'no')
Assert-CancelTest ($result.Phase -eq 'cancelled' -and $result.Cleanup -eq $(if($keep){'declined'}else{'complete'})) 'Cancellation decision was not recorded.'
Assert-CancelTest ((Test-Path -LiteralPath $output) -eq $keep) 'Incomplete output cleanup does not match the decision.'
Assert-CancelTest ((Test-Path -LiteralPath (Join-Path $assets 'work\owned temporary files')) -eq $keep) 'Temporary directory cleanup does not match the decision.'
Assert-CancelTest (($global:PveCancellationTestState.VMs.ContainsKey($ownedId)) -eq $keep) 'Owned VM cleanup does not match the decision.'
Assert-CancelTest ($other.State -eq 'Running' -and $global:PveCancellationTestState.VMs.ContainsKey($otherId)) 'Existing user VM was modified.'
$log=Get-ChildItem -LiteralPath (Join-Path $assets 'logs\builds') -Directory | Select-Object -First 1
$childId=[int][IO.File]::ReadAllText((Join-Path $log.FullName 'child.pid'))
Assert-CancelTest (-not (Get-Process -Id $childId -ErrorAction SilentlyContinue)) 'Cancelled native child process remains running.'
Assert-CancelTest (Test-Path -LiteralPath (Join-Path $log.FullName 'diagnostic.log')) 'Diagnostic log was removed.'
}
}
foreach ($file in @($source,$iso,$oldImage,$installer)) { Assert-CancelTest ([IO.File]::ReadAllText($file) -eq 'Existing user file; preserve.') 'Existing source or image changed.' }
Assert-CancelTest $global:PveCancellationTestState.Images[$iso] 'A pre-existing ISO mount was detached.'
$checked++
}
# Registration rejects pre-existing files and VMs; metadata cannot widen deletion.
$log=Get-ChildItem -LiteralPath (Join-Path $fixture 'no\assets\logs\builds') -Directory | Select-Object -First 1
$contextPath=Join-Path $log.FullName 'resources.json'
$context=Get-PveBuildResources -ContextPath $contextPath
$existingTemporary=Join-Path $context.LogDirectory 'windows-pe\servicing.iso'
New-Item -ItemType Directory -Path (Split-Path -Parent $existingTemporary) -Force | Out-Null
[IO.File]::WriteAllText($existingTemporary,'Pre-existing file; do not adopt.')
Assert-CancelFailure { Register-PveBuildFile -ContextPath $contextPath -Path $existingTemporary } 'Resource already exists'
$originalOutput=$context.OutputPath
$outside=Join-Path $fixture 'no\assets\output\existing.qcow2'
$context.Files+= [PSCustomObject]@{Path=$outside;Kind='Temporary';ExistedBefore=$false}
Save-PveBuildResources -ContextPath $contextPath -Context $context
Assert-CancelFailure { Remove-PveOwnedBuildResources -ContextPath $contextPath } 'not an owned'
Assert-CancelTest ([IO.File]::ReadAllText($outside) -eq 'Existing user file; preserve.') 'An unowned image was removed.'
$context.Files=@($context.Files|Where-Object Path -ne $outside)
$context.Completed=$true
Save-PveBuildResources -ContextPath $contextPath -Context $context
Assert-CancelFailure { Remove-PveOwnedBuildResources -ContextPath $contextPath } 'completed build'
Assert-CancelTest (Test-Path -LiteralPath $originalOutput) 'Completed output was removed.'
$context.Completed=$false
Save-PveBuildResources -ContextPath $contextPath -Context $context
Assert-CancelFailure { Register-PveBuildVm -ContextPath $contextPath -Name 'existing-user-vm' -VhdPath ([IO.Path]::ChangeExtension($context.OutputPath,'.vhdx')) } 'existing VM'
$vmEntry=$context.VMs[0]
$changedVm=[PSCustomObject]@{Name=$vmEntry.Name;Id=$vmEntry.Id;DiskPath=(Join-Path $fixture 'unowned.vhdx');State='Running'}
$global:PveCancellationTestState.VMs=@{([string]$changedVm.Id)=$changedVm}
$global:PveCancellationTestState.Events.Clear()
Assert-CancelFailure { Remove-PveOwnedBuildResources -ContextPath $contextPath } 'exactly its owned disk'
Assert-CancelTest ($changedVm.State -eq 'Running' -and $global:PveCancellationTestState.Events.Count -eq 0 -and (Test-Path -LiteralPath $originalOutput)) 'A VM with changed ownership was modified.'
$changedVm.DiskPath=$vmEntry.VhdPath
$protectedDirectory=Join-Path $fixture 'protected user directory'
New-Item -ItemType Directory -Path $protectedDirectory | Out-Null
$sentinel=Join-Path $protectedDirectory 'preserve.txt'
[IO.File]::WriteAllText($sentinel,'Keep linked directory contents.')
$ownedTemporary=Join-Path $context.AssetsRoot 'work\owned temporary files'
New-Item -ItemType Junction -Path (Join-Path $ownedTemporary 'linked') -Target $protectedDirectory | Out-Null
Assert-CancelFailure { Remove-PveOwnedBuildResources -ContextPath $contextPath } 'containing links'
Assert-CancelTest ([IO.File]::ReadAllText($sentinel) -eq 'Keep linked directory contents.') 'Cleanup followed a directory junction.'
$context.Directories=@()
$context.VMs=@()
$iso=$context.SourceIsos[0].Path
$context.SourceIsos[0].AttachedBefore=$false
$global:PveCancellationTestState.Images=@{$iso=$true}
Save-PveBuildResources -ContextPath $contextPath -Context $context
Remove-PveOwnedBuildResources -ContextPath $contextPath
Assert-CancelTest (-not $global:PveCancellationTestState.Images[$iso] -and (Test-Path -LiteralPath $iso)) 'An owned ISO mount was not released, or its source was deleted.'
[PSCustomObject]@{CancellationCases=$checked;DefaultYes='passed';Decline='preserved';NativeProcessTree='stopped';OtherVmsAndFiles='preserved';ChangedVmOwnership='rejected';DirectoryLinks='rejected';OwnedIsoMount='detached; source preserved';Logs='preserved';Mode=$Mode;HyperVOperations='mocked';Fixture=$fixture}
}