Initial Windows PVE image builder release

This commit is contained in:
Linvery committed 2026-10-09 03:19:07 -07:00
commit 2861ce551c
66 files changed
+5176

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
* text=auto
*.ps1 text eol=crlf
*.psm1 text eol=crlf
*.ini text eol=crlf
*.py text eol=lf
*.md text eol=lf
*.json text eol=lf
+50
View File
@@ -0,0 +1,50 @@
name: Windows PowerShell checks
on:
push:
pull_request:
jobs:
validate:
runs-on: windows-latest
env:
GIT_CONFIG_COUNT: '1'
GIT_CONFIG_KEY_0: core.longpaths
GIT_CONFIG_VALUE_0: 'true'
defaults:
run:
shell: powershell
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Initialize pinned dependency
run: .\Initialize-Project.ps1 -SkipSubmodules
- name: Test wizard and optional applications
run: .\tests\Test-Wizard.ps1
- name: Test environment checks and preflight
run: .\tests\Test-EnvironmentChecks.ps1
- name: Test build cancellation and resource ownership
run: .\tests\Test-BuildCancellation.ps1
- name: Test VirtIO resource extraction
run: .\tests\Test-VirtioResources.ps1
- name: Test Cloudbase-Init cache and image staging
run: .\tests\Test-CloudbaseInitResources.ps1
- name: Test installer signature policy
run: .\tests\Test-InstallerSignatures.ps1
- name: Test guest installation and reboot resume
run: .\tests\Test-GuestSoftware.ps1
- name: Test noninteractive specialize for client and server
run: .\tests\Test-Specialize.ps1
- name: Test unattended setup and Server 2025 setup keys
run: .\tests\Test-Unattend.ps1
- name: Test matching and manual product key choices
run: .\tests\Test-ProductKeys.ps1
- name: Test KMS server choices and guest configuration
run: .\tests\Test-KmsSettings.ps1
- name: Test optional first-boot Windows activation
run: .\tests\Test-WindowsActivation.ps1
- name: Test boot verification waits for Cloudbase-Init
run: .\tests\Test-BootVerification.ps1
- name: Reject malformed offline registry data
run: .\tests\Test-OfflineRegistry.ps1
- name: Check syntax, sanitization, and module import
run: .\tests\Test-Project.ps1
+28
View File
@@ -0,0 +1,28 @@
# Local paths, transient build passwords, and run contexts
/local/
/data/*
!/data/iso/
!/data/custom-resources/
/data/iso/*
/data/custom-resources/*
!/data/iso/.gitkeep
!/data/custom-resources/.gitkeep
# Machine-specific documentation
/docs/migration.md
/docs/validation.md
/docs/history/
__pycache__/
*.pyc
*.iso
*.qcow2
*.vhdx
*.avhdx
*.vmrs
*.vmgs
*.vmcx
*.msi
*.exe
*.dll
*.log
*.transcript.txt
/config/image.ini
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/windows-imaging-tools"]
path = vendor/windows-imaging-tools
url = https://github.com/cloudbase/windows-imaging-tools.git
+77
View File
@@ -0,0 +1,77 @@
[CmdletBinding(SupportsShouldProcess=$true)]
param([string]$ConfigPath,[switch]$SkipBootVerification,[Parameter(DontShow=$true)][switch]$InternalWorker,[Parameter(DontShow=$true)][string]$ResourceContextPath)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'scripts\Project.Common.ps1')
. (Join-Path $PSScriptRoot 'scripts\Build.Validation.ps1')
Assert-PowerShell51
$settings=Get-ProjectSettings
if(-not$ConfigPath){$ConfigPath=Join-Path $ProjectRoot 'local\image.ini'}
if(-not(Test-Path -LiteralPath $ConfigPath -PathType Leaf)){throw 'Generate a local image config with New-ImageConfig.ps1 first.'}
$priorWhatIf=$WhatIfPreference
try {
$WhatIfPreference=$false
if ($InternalWorker) { . (Join-Path $ProjectRoot 'scripts\Import-ImagingTools.ps1') -Settings $settings -ResourceContextPath $ResourceContextPath }
else { . (Join-Path $ProjectRoot 'scripts\Import-ImagingTools.ps1') -Settings $settings }
$config=Get-WindowsImageConfig -ConfigFilePath $ConfigPath
$inputs=Assert-ImageBuildInputs -Settings $settings -Config $config -CheckHost
$tools=Get-ImageServicingTools
}
finally{$WhatIfPreference=$priorWhatIf}
if(-not$PSCmdlet.ShouldProcess($config.image_path,'Build a Windows image using Hyper-V and Sysprep')){
[PSCustomObject]@{OutputPath=$config.image_path;DiskGiB=([long]$config.disk_size/1GB);Upstream=$settings.UpstreamRoot;HostChecks=$inputs.HostChecks;DismVersion=$tools.DismVersion;BcdbootVersion=$tools.BcdbootVersion}
return
}
Assert-Administrator
if (-not $InternalWorker) {
. (Join-Path $ProjectRoot 'scripts\Build.Cancellation.ps1')
Invoke-PveControlledBuild -ScriptPath $PSCommandPath -ConfigPath $ConfigPath -Settings $settings -Config $config -ServicingIsoPath $inputs.Software.ServicingIsoPath -SkipBootVerification:$SkipBootVerification
return
}
. (Join-Path $ProjectRoot 'scripts\Build.Resources.ps1')
$resources=Get-PveBuildResources -ContextPath $ResourceContextPath
if ($resources.OutputPath -ine [IO.Path]::GetFullPath($config.image_path) -or $resources.AssetsRoot -ine [IO.Path]::GetFullPath($settings.AssetsRoot)) { throw 'Worker configuration differs from its resource owner.' }
$runId=$resources.RunId
$logDirectory=$resources.LogDirectory
$state=[ordered]@{Phase='starting';StartedUtc=[DateTime]::UtcNow.ToString('o');ConfigPath=[IO.Path]::GetFullPath($ConfigPath);OutputPath=$config.image_path;ImageName=$config.image_name;ImageVersion=$inputs.ImageVersion;ServicingTools=$tools;LogDirectory=$logDirectory}
function Save-BuildState {
[IO.File]::WriteAllText((Join-Path $logDirectory 'status.json'),($state|ConvertTo-Json -Depth 7),[Text.UTF8Encoding]::new($false))
}
$transcriptStarted=$false
$priorPath=$env:Path
try{
Save-BuildState
Start-Transcript -Path (Join-Path $logDirectory 'build-transcript.txt')|Out-Null
$transcriptStarted=$true
& (Get-Module WinImageBuilder) {
param($Directory,$ToolPaths,$KeyMode)
$script:PveRunLogDirectory=$Directory
$script:PveServicingTools=$ToolPaths
if ($KeyMode) { $script:PveProductKeyMode=$KeyMode }
} $logDirectory $tools $inputs.Software.ProductKeyMode
$servicingIso=$null
$isoProperty=$inputs.Software.PSObject.Properties['ServicingIsoPath']
if($isoProperty){$servicingIso=$isoProperty.Value}
if(-not$servicingIso -and [IO.Path]::GetExtension($config.wim_file_path) -ieq '.iso'){$servicingIso=$config.wim_file_path}
if(-not$servicingIso){throw 'Provide a matching Windows installer ISO with New-ImageConfig.ps1 -ServicingIsoPath for isolated Windows PE servicing.'}
& (Get-Module WinImageBuilder) {param($IsoPath);$script:PveServicingIsoPath=$IsoPath} $servicingIso
$state.ServicingMode='isolated_windows_pe';Save-BuildState
$state.Phase='building';Save-BuildState
$env:Path=(Split-Path -Parent $tools.DismPath)+';'+(Split-Path -Parent $tools.BcdbootPath)+';'+$priorPath
New-WindowsOnlineImage -ConfigFilePath $ConfigPath
if(-not(Test-Path -LiteralPath $config.image_path -PathType Leaf)){throw 'The builder returned without publishing an image.'}
if(-not$SkipBootVerification){
$state.Phase='verifying_boot';Save-BuildState
$boot=& (Join-Path $ProjectRoot 'Verify-BuiltImage.ps1') -SourcePath $config.image_path -ConfigPath $ConfigPath -LogDirectory (Join-Path $logDirectory 'boot-verification') -ResourceContextPath $ResourceContextPath
$state.BootVerification=$boot.Phase
}else{$state.BootVerification='not_requested'}
$state.Phase='complete';$state.CompletedUtc=[DateTime]::UtcNow.ToString('o')
$state.SHA256=(Get-FileHash -LiteralPath $config.image_path -Algorithm SHA256).Hash
$state.OutputBytes=(Get-Item -LiteralPath $config.image_path).Length
Save-BuildState
$resources=Get-PveBuildResources -ContextPath $ResourceContextPath
$resources.Completed=$true
Save-PveBuildResources -ContextPath $ResourceContextPath -Context $resources
}catch{
$state.Phase='failed';$state.Error=$_.Exception.Message;Save-BuildState
throw ('Build failed. Logs: '+$logDirectory+'. '+$_.Exception.Message)
}finally{$env:Path=$priorPath;if($transcriptStarted){Stop-Transcript|Out-Null}}
+6
View File
@@ -0,0 +1,6 @@
[CmdletBinding()]
param([Parameter(Mandatory=$true)][string]$SourcePath,[Parameter(Mandatory=$true)][string]$OutputPath,[switch]$ReplaceExisting)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'scripts\Project.Common.ps1')
Assert-PowerShell51
& (Join-Path $ProjectRoot 'src\Compress-Qcow2Image.ps1') -SourcePath $SourcePath -OutputPath $OutputPath -ReplaceExisting:$ReplaceExisting
+23
View File
@@ -0,0 +1,23 @@
[CmdletBinding()]
param([string]$AssetsRoot,[string]$GitPath,[switch]$SkipSubmodules)
$ErrorActionPreference='Stop'
$root=$PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
if(-not$GitPath){$git=Get-Command git.exe -ErrorAction SilentlyContinue;if(-not$git){throw 'Git for Windows is required.'};$GitPath=$git.Source}
$manifest=Get-BuildDependencies
if(-not$SkipSubmodules){
& $GitPath -c core.longpaths=true -C $root submodule update --init --recursive
if($LASTEXITCODE -ne 0){throw 'Submodule initialization failed.'}
}
$upstream=Join-Path $root 'vendor\windows-imaging-tools'
if(-not(Test-Path -LiteralPath (Join-Path $upstream 'WinImageBuilder.psm1'))){throw 'Upstream submodule is absent; initialize submodules first.'}
$revision=(& $GitPath -C $upstream rev-parse HEAD)-join ''
if($revision -ne $manifest.UpstreamCommit){throw 'Submodule revision does not match the pinned project version.'}
if(-not$AssetsRoot){$AssetsRoot=Join-Path $root 'data'}
$assets=[IO.Path]::GetFullPath($AssetsRoot)
foreach($name in @('iso','custom-resources','tools','output','logs','work','archive')){New-Item -ItemType Directory -Path (Join-Path $assets $name) -Force|Out-Null}
$local=Join-Path $root 'local'
New-Item -ItemType Directory -Path $local -Force|Out-Null
$settings=[ordered]@{ProjectRoot=$root;AssetsRoot=$assets;UpstreamRoot=$upstream;GitPath=$GitPath;QemuManifestPath=(Join-Path $assets ('tools\qemu-img-'+$manifest.Qemu.Version+'\tool-manifest.json'))}
[IO.File]::WriteAllText((Join-Path $local 'settings.json'),($settings|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
[PSCustomObject]@{ProjectRoot=$root;AssetsRoot=$assets;UpstreamCommit=$revision;Initialized=$true}
+31
View File
@@ -0,0 +1,31 @@
[CmdletBinding()]
param([string]$InstallerPath)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'scripts\Project.Common.ps1')
Assert-PowerShell51
$settings=Get-ProjectSettings
$project=Get-BuildDependencies
$toolRoot=Join-Path $settings.AssetsRoot ('tools\qemu-img-'+$project.Qemu.Version)
New-Item -ItemType Directory -Path $toolRoot -Force|Out-Null
if(-not$InstallerPath){
$InstallerPath=Join-Path $toolRoot 'qemu-installer.exe'
Invoke-WebRequest -Uri $project.Qemu.InstallerUrl -OutFile $InstallerPath -UseBasicParsing
}
if((Get-FileHash -Algorithm SHA512 -LiteralPath $InstallerPath).Hash -ne $project.Qemu.SHA512){throw 'QEMU package hash mismatch.'}
$sevenZipRoot=Join-Path $ProjectRoot 'local\7zip-extract'
New-Item -ItemType Directory -Path $sevenZipRoot -Force|Out-Null
$msi=Join-Path $sevenZipRoot '7zip.msi'
if(-not(Test-Path -LiteralPath $msi)){Invoke-WebRequest -Uri $project.Qemu.SevenZipMsiUrl -OutFile $msi -UseBasicParsing}
if((Get-FileHash -Algorithm SHA256 -LiteralPath $msi).Hash -ne $project.Qemu.SevenZipMsiSHA256){throw '7-Zip package hash mismatch.'}
$unpack=Join-Path $sevenZipRoot 'files'
& (Join-Path $ProjectRoot 'scripts\Expand-ToolMsi.ps1') -Package $msi -Destination $unpack|Out-Null
$bin=Join-Path $toolRoot 'bin'
& (Join-Path $unpack '7z.exe') e $InstallerPath 'qemu-img.exe' '*.dll' 'COPYING*' 'LICENSE*' ('-o'+$bin) -y|Out-Null
if($LASTEXITCODE -ne 0){throw 'QEMU extraction failed.'}
$exe=Join-Path $bin 'qemu-img.exe'
if((Get-FileHash -Algorithm SHA256 -LiteralPath $exe).Hash -ne $project.Qemu.ExecutableSHA256){throw 'Extracted qemu-img hash mismatch.'}
& $exe --version
if($LASTEXITCODE -ne 0){throw 'qemu-img cannot start.'}
$manifest=[ordered]@{Version=$project.Qemu.Version;ExecutablePath=$exe;ExecutableSha256=$project.Qemu.ExecutableSHA256;InstallerSource=$project.Qemu.InstallerUrl;InstallerSha512=$project.Qemu.SHA512;PublishedHashVerified=$true;Extraction='Portable files only; no host software installation.'}
[IO.File]::WriteAllText($settings.QemuManifestPath,($manifest|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
[PSCustomObject]@{Version=$manifest.Version;ExecutablePath=$exe;Manifest=$settings.QemuManifestPath}
+175
View File
@@ -0,0 +1,175 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
+79
View File
@@ -0,0 +1,79 @@
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][Alias('SourcePath')][string]$WimPath,
[string]$OutputPath,
[string]$SwitchName = 'external',
[string]$ImageName = 'Windows 11 Pro for Workstations',
[string]$VirtioIsoPath,
[string]$ServicingIsoPath,
[ValidateRange(1,256)][int]$CpuCount = 4,
[ValidateRange(2,1024)][int]$RamGiB = 8,
[ValidateRange(64,65536)][int]$DiskGiB = 128,
[string]$TimeZone = 'Eastern Standard Time',
[switch]$InstallUpdates,
[switch]$SkipChrome,
[switch]$SkipVSCode,
[string]$ConfigPath,
[AllowEmptyString()][string]$ProductKey='',
[ValidateSet('Legacy','Kms','Custom','None')][string]$ProductKeyMode='Legacy',
[AllowEmptyString()][string]$KmsServer
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'scripts\Project.Common.ps1')
. (Join-Path $PSScriptRoot 'scripts\Configuration.Common.ps1')
$settings = Get-ProjectSettings
if ($PSBoundParameters.ContainsKey('ProductKey') -and $ProductKeyMode -eq 'Legacy') { $ProductKeyMode=$(if($ProductKey){'Custom'}else{'None'}) }
$selectedKey=Resolve-PveProductKey -ImageName $ImageName -Mode $ProductKeyMode -ProductKey $ProductKey
if (-not $PSBoundParameters.ContainsKey('KmsServer')) { $KmsServer=Get-PveDefaultKmsServer }
$KmsServer=(ConvertTo-PveKmsEndpoint -Address $KmsServer).Address
if (-not $OutputPath) { $OutputPath = Join-Path $settings.AssetsRoot ('output\' + (Get-DefaultImageFileName -ImageName $ImageName)) }
if (-not $ConfigPath) { $ConfigPath = Join-Path $ProjectRoot 'local\image.ini' }
if (-not $VirtioIsoPath) { $VirtioIsoPath=Find-VirtioInstallerIso -ProjectRoot $ProjectRoot -AssetsRoot $settings.AssetsRoot;if(-not$VirtioIsoPath){$VirtioIsoPath=Join-Path $settings.AssetsRoot 'iso\virtio-win.iso'} }
$source = [IO.Path]::GetFullPath($WimPath)
if(-not$ServicingIsoPath -and [IO.Path]::GetExtension($source) -ieq '.iso'){$ServicingIsoPath=$source}
if(-not$ServicingIsoPath){$ServicingIsoPath=Find-WindowsInstallerIso -ProjectRoot $ProjectRoot -AssetsRoot $settings.AssetsRoot}
if($ServicingIsoPath){$ServicingIsoPath=[IO.Path]::GetFullPath($ServicingIsoPath);if(-not(Test-Path -LiteralPath $ServicingIsoPath -PathType Leaf) -or [IO.Path]::GetExtension($ServicingIsoPath) -ine '.iso'){throw 'Windows PE servicing requires an existing Windows installer ISO.'}}
$output = [IO.Path]::GetFullPath($OutputPath)
$virtio = [IO.Path]::GetFullPath($VirtioIsoPath)
$config = [IO.Path]::GetFullPath($ConfigPath)
foreach ($entry in @{Source=$source;Output=$output;Virtio=$virtio;Config=$config;ImageName=$ImageName;SwitchName=$SwitchName;TimeZone=$TimeZone}.GetEnumerator()) { Assert-ConfigValue -Value $entry.Value -Name $entry.Key }
if (-not (Test-Path -LiteralPath $source -PathType Leaf) -or [IO.Path]::GetExtension($source).ToLowerInvariant() -notin @('.iso','.wim')) { throw 'Select an existing Windows ISO or install.wim file.' }
if ([IO.Path]::GetExtension($output).ToLowerInvariant() -ne '.qcow2') { throw 'Output must have the .qcow2 extension.' }
if (Test-Path -LiteralPath $output) { throw 'Output already exists; choose a new output path.' }
if (Test-Path -LiteralPath ([IO.Path]::ChangeExtension($output,'.vhdx'))) { throw 'The corresponding temporary VHDX already exists; choose a new output path.' }
if (-not (Test-Path -LiteralPath $virtio -PathType Leaf) -or [IO.Path]::GetExtension($virtio).ToLowerInvariant() -ne '.iso') { throw 'VirtIO ISO is missing or is not an ISO file.' }
$localRoot = [IO.Path]::GetFullPath((Join-Path $ProjectRoot 'local')).TrimEnd('\') + '\'
if (-not $config.StartsWith($localRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Generated configuration must be inside the project local directory.' }
if ($ProjectRoot.Contains(',')) { throw 'The upstream resource parser does not support a comma in the project path.' }
[TimeZoneInfo]::FindSystemTimeZoneById($TimeZone) | Out-Null
$packages = @(Get-SelectedImagePackages -SkipChrome:$SkipChrome -SkipVSCode:$SkipVSCode)
$virtioInstaller = Initialize-VirtioGuestTools -Settings $settings -VirtioIsoPath $virtio
$installerPaths = @{}
foreach ($package in $packages) {
if ($package.Name -eq 'VirtIO') { $installer = $virtioInstaller }
else { $installer = Find-ImagePackageInstaller -Package $package -Directories @((Join-Path $ProjectRoot 'data\custom-resources'),(Join-Path $settings.AssetsRoot 'custom-resources')) }
if (-not $installer) {
Assert-ImagePackage -Path (Join-Path $settings.AssetsRoot ('custom-resources\' + $package.File)) -Package $package
}
$installerPaths[$package.Name] = $installer
}
$runId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0,8)
$staged = Join-Path $ProjectRoot ('local\staged-resources\' + $runId)
New-Item -ItemType Directory -Path $staged -Force | Out-Null
foreach ($package in $packages) { Copy-Item -LiteralPath $installerPaths[$package.Name] -Destination (Join-Path $staged $package.File) }
foreach ($file in Get-ChildItem -LiteralPath (Join-Path $ProjectRoot 'resources') -File) { Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $staged $file.Name) }
Copy-Item -LiteralPath (Join-Path $ProjectRoot 'scripts\Software.Selection.ps1') -Destination (Join-Path $staged 'Software.Selection.ps1')
Copy-Item -LiteralPath (Join-Path $ProjectRoot 'scripts\Package.Validation.ps1') -Destination (Join-Path $staged 'Package.Validation.ps1')
$software = [ordered]@{SchemaVersion=1;InstallChrome=(-not $SkipChrome);InstallVSCode=(-not $SkipVSCode);ServicingIsoPath=$ServicingIsoPath;ProductKeyMode=$ProductKeyMode;KmsServer=$KmsServer;Packages=@($packages | Select-Object Name,File,Validation)}
[IO.File]::WriteAllText((Join-Path $staged 'software-selection.json'), ($software | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false))
$text = [IO.File]::ReadAllText((Join-Path $ProjectRoot 'config\image.example.ini'))
$password = 'Build!' + [guid]::NewGuid().ToString('N')
$text = $text.Replace('__EPHEMERAL_BUILD_PASSWORD__',$password).Replace('__WIM_PATH__',$source).Replace('__OUTPUT_PATH__',$output).Replace('__ASSET_ROOT__',$settings.AssetsRoot).Replace('__STAGED_RESOURCES__',$staged).Replace('__PROJECT_ROOT__',$ProjectRoot)
$keyValue='""'
if ($selectedKey) { $keyValue=$selectedKey }
$values = [ordered]@{image_name=$ImageName;product_key=$keyValue;external_switch=$SwitchName;virtio_iso_path=$virtio;cpu_count=[string]$CpuCount;ram_size=[string]([long]$RamGiB*1GB);disk_size=[string]([long]$DiskGiB*1GB);time_zone=$TimeZone;install_updates=$InstallUpdates.IsPresent.ToString()}
foreach ($key in $values.Keys) { $text = Set-ImageConfigValue -Text $text -Key $key -Value $values[$key] }
New-Item -ItemType Directory -Path ([IO.Path]::GetDirectoryName($config)) -Force | Out-Null
# The upstream parser requires an INI without a BOM.
[IO.File]::WriteAllText($config,$text,[Text.UTF8Encoding]::new($false))
[PSCustomObject]@{ConfigPath=$config;SourcePath=$source;ImageName=$ImageName;KmsServer=$KmsServer;OutputPath=$output;DiskGiB=$DiskGiB;StagedResources=$staged;InstallChrome=(-not $SkipChrome);InstallVSCode=(-not $SkipVSCode);BuildPassword='Generated automatically.'}
+345
View File
@@ -0,0 +1,345 @@
# Windows Imaging Tools Builder
本项目通过中文 PowerShell 向导构建 Windows 11 qcow2 镜像,供 Proxmox VE(PVE)使用。
项目基于 [windows-imaging-tools](https://github.com/cloudbase/windows-imaging-tools)。
## 准备环境
构建主机需要以下组件:
- Windows PowerShell 5.1。
- Git for Windows。
- Hyper-V。
- [Windows ADK](https://learn.microsoft.com/windows-hardware/get-started/adk-install) 的 Deployment Tools。
使用管理员权限运行 Windows PowerShell 5.1。
Deployment Tools 必须包含 `oscdimg.exe`。
如果主机没有 Hyper-V 外部交换机,向导会询问是否创建。
默认系统盘大小为 128 GiB。
输出路径所在磁盘必须有至少 276 GiB 可用空间。
将 Windows 安装 ISO 和 VirtIO ISO 放入项目的 `data/iso/` 目录。
Windows 安装 ISO 必须包含 `sources/install.wim` 和 `sources/boot.wim`。
在向导列出的 Windows 安装源中,输入编号选择安装源。
向导会从 VirtIO ISO 提取 `virtio-win-guest-tools.exe`。
也可以手动输入安装源路径。
如果使用独立 WIM 文件,还需提供匹配的 Windows 安装 ISO。
从以下地址下载资源:
| 资源 | 下载地址 |
|---|---|
| Windows 安装 ISO | [Massgrave Genuine Installation Media](https://massgrave.dev/genuine-installation-media) |
| VirtIO ISO | [Latest ISO](https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/latest-virtio/virtio-win.iso) · [Stable ISO](https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso) |
| Chrome Enterprise x64 MSI | [最新安装包](https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi) · [官方下载页](https://chromeenterprise.google/download/) |
| VS Code x64 系统安装包 | [Latest Stable](https://update.code.visualstudio.com/latest/win32-x64/stable) |
将 Chrome 和 VS Code 安装包放入 `data/custom-resources/`,无需改名。
可以分别选择是否安装 Chrome 和 VS Code。
项目只校验这两个安装包的有效厂商签名,不限制版本。
项目只检查 VirtIO 安装包的文件名是否为 `virtio-win-guest-tools.exe`。
向导会提示选择安装包。
选择预检或构建后,向导会准备 QEMU。
Cloudbase-Init 在首次构建时自动从官网下载,默认保存到 `data/assets/CloudbaseInitSetup_Stable_x64.msi`。
使用其他资源目录时,安装包保存在该目录的 `assets/` 下。
后续构建会验证并复用缓存,无需再次下载;下载失败或安装包无效时不会发布缓存。
也可以提前将安装包放入这个目录,以便离线构建。
模板使用 `__ASSET_ROOT__` 占位符,向导按实际资源目录生成路径,不依赖项目所在盘符或启动命令的当前目录。
构建脚本会自动将缓存复制进镜像的 `UnattendResources/CloudbaseInit.msi`;临时资源清理会保留宿主机上的缓存。
需要更新 Cloudbase-Init 时,删除缓存文件后重新构建即可。
## 开始构建
将项目放在较短的路径。
在管理员 Windows PowerShell 5.1 中运行以下命令:
```powershell
git -c core.longpaths=true clone --recurse-submodules https://github.com/Linvery/windows-imaging-tools-builder.git
Set-Location windows-imaging-tools-builder
# 将 Windows 和 VirtIO ISO 放入 data\iso 后启动向导
.\Start-ImageBuilder.ps1
```
向导启动时会检查 PowerShell 版本、管理员权限、Hyper-V 和 ADK。
如果缺少组件,向导会显示安装提示。
按提示选择安装源、Windows 版本、资源目录、Hyper-V 外部交换机、输出路径和软件。
默认输出文件名为 `系统简称-yyyyMMddHHmm.qcow2`,例如 `WinServer2025-202610090241.qcow2`、`Win10-202610090330.qcow2`、`Win11-202610090330.qcow2`。
时间使用生成配置时构建主机的本地时间,精确到分钟;也可以在向导中自行填写输出路径。
命令行运行 `New-ImageConfig.ps1` 时,省略 `-OutputPath` 也会按所选 Windows 版本使用此命名规则。
按回车可使用当前步骤的默认值。
输入 `Q` 可取消向导。
最后选择以下操作之一:
- **开始构建**:生成配置并构建镜像。
- **预检**:检查构建条件。
- **仅保存配置**:生成配置文件。
“下一步”的默认操作为“开始构建”。
预检会检查主机环境、资源、安装源版本、CPU、内存和磁盘空间,不会启动构建。
默认构建设置如下:
| 设置 | 默认值 |
|---|---|
| 启动方式 | UEFI |
| CPU | 4 核 |
| 内存 | 8 GiB |
| 系统盘 | 128 GiB |
如果主机资源不足,向导会降低 CPU 和内存的默认值。
构建按以下顺序执行:
1. 安装驱动。
2. 安装软件。
3. 运行 Sysprep。
4. 压缩镜像。
5. 检查镜像启动。
项目将成品保存到指定的输出路径。
项目将日志保存到资源目录的 `logs/`。
构建期间按 `Ctrl+C` 可中断构建。
中断后,项目会询问是否清理本轮临时资源。
按回车默认选择“是”。
选择“否”后,项目会保留临时虚拟机和中间文件,供排查问题。
清理操作会保留 ISO、安装包、日志和已有镜像。
镜像启用远程桌面(RDP),并保留网络级别身份验证(NLA)。
镜像关闭 Windows 防火墙。
首次启动前,使用 PVE Cloud-Init 设置 `Administrator` 密码。
账号处理规则见[账号配置](docs/account-bootstrap.md)。
### 选择产品密钥
选择 Windows 版本后,向导提供两个选项:
1. **当前版本对应的 KMS 密钥**。
2. **自行填写/留空**。
向导默认使用[微软公开的 KMS 客户端安装密钥](https://learn.microsoft.com/windows-server/get-started/kms-client-activation-keys)。
选择第二项后,直接按回车可留空。
向导会隐藏自行填写的密钥,配置摘要也不会显示密钥。
产品密钥用于安装 Windows。
激活 Windows 仍需合法授权和可用的激活环境。
### 设置 KMS 地址
选择产品密钥后,向导提供两个 KMS 地址选项:
1. **kms-default.cangshui.net**:默认选项。
2. **自定义输入/留空**。
自定义地址支持域名和 IP 地址。
地址可以包含端口,默认端口为 `1688`。
IPv6 地址包含端口时,使用 `[地址]:端口` 格式。
选择第二项后,直接按回车可留空,项目不会设置 KMS 地址。
项目将 KMS 地址保存到本次配置的资源中。
构建阶段只在虚拟机内设置 KMS 地址,不会请求激活。
部署后首次启动时,Cloudbase-Init 会重新设置 KMS 地址。
随后,Cloudbase-Init 会尝试一次 Windows KMS 激活。
如果未配置 KMS 地址或未安装 Windows KMS 客户端安装密钥,项目不会请求激活。
项目将激活结果保存到 `C:\ProgramData\PveImageBuilder\windows-activation.json`。
未联网、KMS 不可达或激活失败都不会阻断 Cloudbase-Init 初始化。
激活脚本不会要求重启。
通过命令行生成配置时,使用 `New-ImageConfig.ps1 -KmsServer 'kms.example.com:1688'` 指定 KMS 地址。
传入 `-KmsServer ''` 可将 KMS 地址留空。
## 常用命令
运行以下命令可通过向导生成配置:
```powershell
.\Start-ImageBuilder.ps1 -ConfigureOnly
```
每次运行向导都会在 `local/configs/` 保存独立配置,便于分别保留 Windows 11 和 Windows Server 2025 的配置。
将以下示例路径替换为向导显示的配置路径。
第一条命令执行预检。
第二条命令开始构建。
```powershell
.\Build-Image.ps1 -ConfigPath .\local\configs\image-日期-编号.ini -WhatIf
.\Build-Image.ps1 -ConfigPath .\local\configs\image-日期-编号.ini
```
关闭使用源镜像的虚拟机。
运行以下命令可压缩已有的离线镜像:
```powershell
.\Compress-Image.ps1 -SourcePath .\data\output\source.qcow2 -OutputPath .\data\output\compressed.qcow2
```
## 导入 PVE 并配置模板
将构建完成的 qcow2 镜像导入 PVE。
完成虚拟机配置后,将虚拟机转换为模板。
使用模板克隆新虚拟机。
首次启动前,为新虚拟机设置 Cloud-Init 密码和网络。
以下示例使用 Windows 11 镜像。
在 PVE 节点的 Shell 或 SSH 会话中,以 `root` 身份执行 Bash 命令。
按顺序在同一会话中执行以下步骤。
### 1. 准备镜像和参数
使用 SCP 或 SFTP 将 qcow2 镜像上传到 PVE 节点。
以下示例将镜像保存为 `/root/win11-pve.qcow2`。
上传路径只用于暂存源镜像。
PVE 会将导入的系统盘保存到目标存储。
设置以下参数:
```bash
VMID=9000
STORAGE=local-lvm
BRIDGE=vmbr0
IMAGE=/root/win11-pve.qcow2
qm list
pvesm status
qemu-img info "$IMAGE"
```
将 `VMID` 替换为集群中未使用的虚拟机 ID。
将 `STORAGE` 替换为可保存虚拟机磁盘的存储 ID。
目标存储必须有足够空间保存系统盘、EFI 盘和 TPM 状态盘。
将 `BRIDGE` 替换为目标网络桥接名称。
将 `IMAGE` 替换为上传镜像的绝对路径。
确认 `qemu-img info` 输出的格式为 `qcow2`。
### 2. 创建虚拟机并导入系统盘
创建使用 OVMF(UEFI)的虚拟机:
```bash
qm create "$VMID" --name win11-template \
--ostype win11 --machine q35 --bios ovmf \
--sockets 1 --cores 4 --cpu host --memory 8192 --balloon 0 \
--scsihw virtio-scsi-single \
--net0 "virtio,bridge=${BRIDGE}" \
--agent enabled=1 --vga std
qm set "$VMID" --efidisk0 "${STORAGE}:0,efitype=4m,pre-enrolled-keys=1"
qm set "$VMID" --tpmstate0 "${STORAGE}:0,version=v2.0"
qm set "$VMID" --scsi0 "${STORAGE}:0,import-from=${IMAGE},discard=on,iothread=1"
qm set "$VMID" --boot order=scsi0
```
使用 `import-from` 导入 qcow2 镜像后,系统盘连接到 `scsi0`。
系统盘使用 VirtIO SCSI 控制器。
EFI 盘保存 UEFI 变量。
设置 `pre-enrolled-keys=1` 后,EFI 盘包含预置密钥并启用 Secure Boot。
TPM 状态盘提供 TPM 2.0。
`--agent enabled=1` 启用 PVE 与 QEMU Guest Agent 的通信。
项目已在镜像中安装 QEMU Guest Agent。
导入 `local-lvm` 或 ZFS 存储后,系统盘使用存储支持的格式,不必保持 qcow2 文件格式。
如果使用支持 qcow2 的目录存储,可在 `scsi0` 参数中添加 `format=qcow2`。
如果需要跨节点迁移,请使用目标节点支持的 CPU 类型。
### 3. 添加 Cloud-Init 配置盘
**在 OVMF(UEFI)模式下,Cloud-Init 配置盘必须使用 SATA。**
本示例将 Cloud-Init 配置盘连接到 `sata0`。
系统盘继续使用 `scsi0`。
```bash
qm set "$VMID" --sata0 "${STORAGE}:cloudinit"
qm set "$VMID" --citype configdrive2 --ciuser Administrator
qm set "$VMID" --ipconfig0 ip=dhcp
qm cloudinit update "$VMID"
qm config "$VMID"
```
`configdrive2` 为 Cloudbase-Init 提供 ConfigDrive 数据。
Cloudbase-Init 从配置盘读取账号密码和网络设置。
`ipconfig0` 对应 `net0`。
上述配置使用 DHCP。
使用 DHCP 时,目标网络必须提供 DHCP 服务。
检查 `qm config` 输出中的关键设置:
| 设置 | 期望值 |
|---|---|
| `bios` | `ovmf` |
| `machine` | `q35` |
| `ostype` | `win11` |
| `scsihw` | `virtio-scsi-single` |
| `scsi0` | 导入的系统盘 |
| `boot` | `order=scsi0` |
| `sata0` | Cloud-Init 配置盘,包含 `media=cdrom` |
| `citype` | `configdrive2` |
| `ciuser` | `Administrator` |
| `agent` | `enabled=1` |
### 4. 转换为模板
直接将未启动的虚拟机转换为模板。
镜像已在构建阶段完成 Sysprep。
启动模板虚拟机会消耗首次初始化状态。
使用克隆虚拟机验证首次启动。
```bash
qm template "$VMID"
```
### 5. 克隆并配置新虚拟机
将 `NEW_VMID` 替换为集群中未使用的虚拟机 ID。
以下命令创建完整克隆:
```bash
NEW_VMID=100
qm clone "$VMID" "$NEW_VMID" --name win11-100 --full 1 --storage "$STORAGE"
qm set "$NEW_VMID" --ostype win11 --citype configdrive2 --ciuser Administrator
qm set "$NEW_VMID" --ipconfig0 ip=dhcp
read -r -s -p 'Administrator password: ' CI_PASSWORD
printf '\n'
qm set "$NEW_VMID" --cipassword "$CI_PASSWORD"
unset CI_PASSWORD
qm cloudinit update "$NEW_VMID"
```
输入符合 Windows 密码策略的密码,终端不会显示密码。
**必须先将操作系统类型设为 Windows,再写入 Cloud-Init 密码。**
如果操作系统类型不是 Windows,PVE 会将密码转换为哈希值。
Cloudbase-Init 会将哈希值作为登录密码使用。
如果需要静态 IPv4 地址,在首次启动前运行以下命令。
将地址、网关和 DNS 服务器替换为目标网络的值。
```bash
qm set "$NEW_VMID" --ipconfig0 ip=192.168.10.100/24,gw=192.168.10.1
qm set "$NEW_VMID" --nameserver 192.168.10.1
qm cloudinit update "$NEW_VMID"
```
也可以在 PVE 网页界面的“Cloud-Init”页面设置密码和网络。
修改后,点击“重新生成镜像”。
首次启动前,确认克隆虚拟机的 Cloud-Init 配置盘仍连接到 SATA。
启动新虚拟机:
```bash
qm start "$NEW_VMID"
```
等待 Windows 和 Cloudbase-Init 完成初始化。
初始化期间,虚拟机可能因计算机名变更而自动重启。
初始化完成后,使用 `Administrator` 和设置的密码,通过 PVE 控制台或 RDP 登录。
项目的账号插件会验证注入的密码。
密码验证成功后,插件会禁用额外的 `Admin` 账号。
账号处理规则见[账号配置](docs/account-bootstrap.md)。
PVE 命令说明见 [qm 手册](https://pve.proxmox.com/pve-docs/qm.1.html)。
Windows 初始化说明见 [PVE Cloud-Init 文档](https://pve.proxmox.com/wiki/Cloud-Init_Support)。
+15
View File
@@ -0,0 +1,15 @@
[CmdletBinding()]
param([string]$AssetsRoot, [string]$SourcePath, [switch]$ConfigureOnly)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'scripts\Project.Common.ps1')
. (Join-Path $PSScriptRoot 'scripts\Configuration.Common.ps1')
. (Join-Path $PSScriptRoot 'scripts\Builder.Workflow.ps1')
try {
Show-BuilderPreparationGuide
Invoke-ImageBuilderWizard -AssetsRoot $AssetsRoot -SourcePath $SourcePath -ConfigureOnly:$ConfigureOnly
} catch [OperationCanceledException] {
Write-Host $_.Exception.Message
} catch {
Write-Error ('镜像构建已停止:' + $_.Exception.Message)
exit 1
}
+8
View File
@@ -0,0 +1,8 @@
# Third-party components
- [cloudbase/windows-imaging-tools](https://github.com/cloudbase/windows-imaging-tools): Apache-2.0. Referenced as a Git submodule; its license and copyright headers are retained.
- [Cloudbase-Init](https://github.com/cloudbase/cloudbase-init): Apache-2.0. The administrator integration uses its public plugin interfaces.
- [QEMU](https://www.qemu.org/): GPL. Portable Windows tools are obtained from the build provider referenced by qemu.org; their COPYING files accompany the extracted tools.
- [7-Zip](https://www.7-zip.org/): used for archive extraction. See the [upstream license](https://www.7-zip.org/license.txt).
This project's source code is licensed under Apache-2.0; see LICENSE.
+149
View File
@@ -0,0 +1,149 @@
[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}}
}
}
+81
View File
@@ -0,0 +1,81 @@
@{
SourceUrl = 'https://learn.microsoft.com/windows-server/get-started/kms-client-activation-keys'
Entries = @(
@{Names=@('Windows 11 Pro','Windows 10 Pro');Key='W269N-WFGWX-YVC9B-4J6C9-T83GX'}
@{Names=@('Windows 11 Pro N','Windows 10 Pro N');Key='MH37W-N47XK-V7XM9-C7227-GCQG9'}
@{Names=@('Windows 11 Pro for Workstations','Windows 10 Pro for Workstations');Key='NRG8B-VKK3Q-CXVCJ-9G2XF-6Q84J'}
@{Names=@('Windows 11 Pro for Workstations N','Windows 10 Pro for Workstations N');Key='9FNHH-K3HBT-3W4TD-6383H-6XYWF'}
@{Names=@('Windows 11 Pro Education','Windows 10 Pro Education');Key='6TP4R-GNPTD-KYYHQ-7B7DP-J447Y'}
@{Names=@('Windows 11 Pro Education N','Windows 10 Pro Education N');Key='YVWGF-BXNMC-HTQYQ-CPQ99-66QFC'}
@{Names=@('Windows 11 Education','Windows 10 Education');Key='NW6C2-QMPVW-D7KKK-3GKT6-VCFB2'}
@{Names=@('Windows 11 Education N','Windows 10 Education N');Key='2WH4N-8QGBV-H22JP-CT43Q-MDWWJ'}
@{Names=@('Windows 11 Enterprise','Windows 10 Enterprise');Key='NPPR9-FWDCX-D2C8J-H872K-2YT43'}
@{Names=@('Windows 11 Enterprise N','Windows 10 Enterprise N');Key='DPH2V-TTNVB-4X9Q3-TJR4H-KHJW4'}
@{Names=@('Windows 11 Enterprise G','Windows 10 Enterprise G');Key='YYVX9-NTFWV-6MDM3-9PT4T-4M68B'}
@{Names=@('Windows 11 Enterprise G N','Windows 10 Enterprise G N');Key='44RPN-FTY23-9VTTB-MP9BX-T84FV'}
@{Names=@('Windows 11 Enterprise LTSC 2024','Windows 10 Enterprise LTSC 2021','Windows 10 Enterprise LTSC 2019');Key='M7XTQ-FN8P6-TTKYV-9D4CC-J462D'}
@{Names=@('Windows 11 Enterprise N LTSC 2024','Windows 10 Enterprise N LTSC 2021','Windows 10 Enterprise N LTSC 2019');Key='92NFX-8DJQP-P6BBQ-THF9C-7CG2H'}
@{Names=@('Windows IoT Enterprise LTSC 2024','Windows IoT Enterprise LTSC 2021');Key='KBN8V-HFGQ4-MGXVD-347P6-PDQGT'}
@{Names=@('Windows 10 Enterprise LTSB 2016');Key='DCPHK-NFMTC-H88MJ-PFHPY-QJ4BJ'}
@{Names=@('Windows 10 Enterprise N LTSB 2016');Key='QFFDN-GRT3P-VKWWX-X7T3R-8B639'}
@{Names=@('Windows 10 Enterprise LTSB 2015');Key='WNMTR-4C88C-JK8YV-HQ7T2-76DF9'}
@{Names=@('Windows 10 Enterprise N LTSB 2015');Key='2F77B-TNFGY-69QQF-B8YKP-D69TJ'}
@{Names=@('Windows 8.1 Pro');Key='GCRJD-8NW9H-F2CDX-CCM8D-9D6T9'}
@{Names=@('Windows 8.1 Pro N');Key='HMCNV-VVBFX-7HMBH-CTY9B-B4FXY'}
@{Names=@('Windows 8.1 Enterprise');Key='MHF9N-XY6XB-WVXMC-BTDCT-MKKG7'}
@{Names=@('Windows 8.1 Enterprise N');Key='TT4HM-HN7YT-62K67-RGRQJ-JFFXW'}
@{Names=@('Windows 8 Pro');Key='NG4HW-VH26C-733KW-K6F98-J8CK4'}
@{Names=@('Windows 8 Pro N');Key='XCVCF-2NXM9-723PB-MHCB7-2RYQQ'}
@{Names=@('Windows 8 Enterprise');Key='32JNW-9KQ84-P47T8-D8GGY-CWCK7'}
@{Names=@('Windows 8 Enterprise N');Key='JMNMF-RHW7P-DMY6X-RF3DR-X2BQT'}
@{Names=@('Windows 7 Professional');Key='FJ82H-XT6CR-J8D7P-XQJJ2-GPDD4'}
@{Names=@('Windows 7 Professional N');Key='MRPKT-YTG23-K7D7T-X2JMM-QY7MG'}
@{Names=@('Windows 7 Professional E');Key='W82YF-2Q76Y-63HXB-FGJG9-GF7QX'}
@{Names=@('Windows 7 Enterprise');Key='33PXH-7Y6KF-2VJC9-XBBR8-HVTHH'}
@{Names=@('Windows 7 Enterprise N');Key='YDRBP-3D83W-TY26F-D46B2-XCKRJ'}
@{Names=@('Windows 7 Enterprise E');Key='C29WB-22CC8-VJ326-GHFJW-H9DH4'}
@{Names=@('Windows Vista Business');Key='YFKBB-PQJJV-G996G-VWGXY-2V3X8'}
@{Names=@('Windows Vista Business N');Key='HMBQG-8H2RH-C77VX-27R82-VMQBT'}
@{Names=@('Windows Vista Enterprise');Key='VKK3X-68KWM-X2YGT-QR4M6-4BWMV'}
@{Names=@('Windows Vista Enterprise N');Key='VTC42-BM838-43QHV-84HX6-XJXKV'}
@{Names=@('Windows Server 2025 Standard','Windows Server 2025 SERVERSTANDARD','Windows Server 2025 SERVERSTANDARDCORE');Key='TVRH6-WHNXV-R9WG3-9XRFY-MY832'}
@{Names=@('Windows Server 2025 Datacenter','Windows Server 2025 SERVERDATACENTER','Windows Server 2025 SERVERDATACENTERCORE');Key='D764K-2NDRG-47T6Q-P8T8W-YP6DF'}
@{Names=@('Windows Server 2025 Datacenter: Azure Edition');Key='XGN3F-F394H-FD2MY-PP6FD-8MCRC'}
@{Names=@('Windows Server 2022 Standard','Windows Server 2022 SERVERSTANDARD','Windows Server 2022 SERVERSTANDARDCORE');Key='VDYBN-27WPP-V4HQT-9VMD4-VMK7H'}
@{Names=@('Windows Server 2022 Datacenter','Windows Server 2022 SERVERDATACENTER','Windows Server 2022 SERVERDATACENTERCORE');Key='WX4NM-KYWYW-QJJR4-XV3QB-6VM33'}
@{Names=@('Windows Server 2022 Datacenter: Azure Edition');Key='NTBV8-9K7Q8-V27C6-M2BTV-KHMXV'}
@{Names=@('Windows Server 2019 Standard','Windows Server 2019 SERVERSTANDARD','Windows Server 2019 SERVERSTANDARDCORE');Key='N69G4-B89J2-4G8F4-WWYCC-J464C'}
@{Names=@('Windows Server 2019 Datacenter','Windows Server 2019 SERVERDATACENTER','Windows Server 2019 SERVERDATACENTERCORE');Key='WMDGN-G9PQG-XVVXX-R3X43-63DFG'}
@{Names=@('Windows Server 2019 Essentials','Windows Server 2019 SERVERESSENTIALS','Windows Server 2019 SERVERESSENTIALSCORE');Key='WVDHN-86M7X-466P6-VHXV7-YY726'}
@{Names=@('Windows Server 2016 Standard','Windows Server 2016 SERVERSTANDARD','Windows Server 2016 SERVERSTANDARDCORE');Key='WC2BQ-8NRM3-FDDYY-2BFGV-KHKQY'}
@{Names=@('Windows Server 2016 Datacenter','Windows Server 2016 SERVERDATACENTER','Windows Server 2016 SERVERDATACENTERCORE');Key='CB7KF-BWN84-R7R2Y-793K2-8XDDG'}
@{Names=@('Windows Server 2016 Essentials','Windows Server 2016 SERVERESSENTIALS','Windows Server 2016 SERVERESSENTIALSCORE');Key='JCKRF-N37P4-C2D82-9YXRT-4M63B'}
@{Names=@('Windows Server 2012 R2 Standard','Windows Server 2012 R2 SERVERSTANDARD','Windows Server 2012 R2 SERVERSTANDARDCORE');Key='D2N9P-3P6X9-2R39C-7RTCD-MDVJX'}
@{Names=@('Windows Server 2012 R2 Datacenter','Windows Server 2012 R2 SERVERDATACENTER','Windows Server 2012 R2 SERVERDATACENTERCORE');Key='W3GGN-FT8W3-Y4M27-J84CP-Q3VJ9'}
@{Names=@('Windows Server 2012 R2 Essentials','Windows Server 2012 R2 SERVERESSENTIALS','Windows Server 2012 R2 SERVERESSENTIALSCORE');Key='KNC87-3J2TX-XB4WP-VCPJV-M4FWM'}
@{Names=@('Windows Server 2012');Key='BN3D2-R7TKB-3YPBD-8DRP2-27GG4'}
@{Names=@('Windows Server 2012 N');Key='8N2M2-HWPGY-7PGT9-HGDD8-GVGGY'}
@{Names=@('Windows Server 2012 Single Language');Key='2WN2H-YGCQR-KFX6K-CD6TF-84YXQ'}
@{Names=@('Windows Server 2012 Country Specific');Key='4K36P-JN4VD-GDC6V-KDT89-DYFKP'}
@{Names=@('Windows Server 2012 Standard','Windows Server 2012 SERVERSTANDARD','Windows Server 2012 SERVERSTANDARDCORE');Key='XC9B7-NBPP2-83J2H-RHMBY-92BT4'}
@{Names=@('Windows Server 2012 MultiPoint Standard');Key='HM7DN-YVMH3-46JC3-XYTG7-CYQJJ'}
@{Names=@('Windows Server 2012 MultiPoint Premium');Key='XNH6W-2V9GX-RGJ4K-Y8X6F-QGJ2G'}
@{Names=@('Windows Server 2012 Datacenter','Windows Server 2012 SERVERDATACENTER','Windows Server 2012 SERVERDATACENTERCORE');Key='48HP8-DN98B-MYWDG-T2DCC-8W83P'}
@{Names=@('Windows Server 2012 Essentials','Windows Server 2012 SERVERESSENTIALS','Windows Server 2012 SERVERESSENTIALSCORE');Key='HTDQM-NBMMG-KGYDT-2DTKT-J2MPV'}
@{Names=@('Windows Server 2008 R2 Web');Key='6TPJF-RBVHG-WBW2R-86QPH-6RTM4'}
@{Names=@('Windows Server 2008 R2 HPC edition');Key='TT8MH-CG224-D3D7Q-498W2-9QCTX'}
@{Names=@('Windows Server 2008 R2 Standard','Windows Server 2008 R2 SERVERSTANDARD','Windows Server 2008 R2 SERVERSTANDARDCORE');Key='YC6KT-GKW9T-YTKYR-T4X34-R7VHC'}
@{Names=@('Windows Server 2008 R2 Enterprise');Key='489J6-VHDMP-X63PK-3K798-CPX3Y'}
@{Names=@('Windows Server 2008 R2 Datacenter','Windows Server 2008 R2 SERVERDATACENTER','Windows Server 2008 R2 SERVERDATACENTERCORE');Key='74YFP-3QFB3-KQT8W-PMXWJ-7M648'}
@{Names=@('Windows Server 2008 R2 for Itanium-based Systems');Key='GT63C-RJFQ3-4GMB6-BRFB9-CB83V'}
@{Names=@('Windows Web Server 2008');Key='WYR28-R7TFJ-3X2YQ-YCY4H-M249D'}
@{Names=@('Windows Server 2008 Standard','Windows Server 2008 SERVERSTANDARD','Windows Server 2008 SERVERSTANDARDCORE');Key='TM24T-X9RMF-VWXK6-X8JC9-BFGM2'}
@{Names=@('Windows Server 2008 Standard without Hyper-V');Key='W7VD6-7JFBR-RX26B-YKQ3Y-6FFFJ'}
@{Names=@('Windows Server 2008 Enterprise');Key='YQGMW-MPWTJ-34KDK-48M3W-X4Q6V'}
@{Names=@('Windows Server 2008 Enterprise without Hyper-V');Key='39BXF-X8Q23-P2WWT-38T2F-G3FPG'}
@{Names=@('Windows Server 2008 HPC');Key='RCTX3-KWVHP-BR6TB-RB6DM-6X7HP'}
@{Names=@('Windows Server 2008 Datacenter','Windows Server 2008 SERVERDATACENTER','Windows Server 2008 SERVERDATACENTERCORE');Key='7M67G-PC374-GR742-YH8V4-TCBY3'}
@{Names=@('Windows Server 2008 Datacenter without Hyper-V');Key='22XQ2-VRXRG-P8D42-K34TD-G3QQC'}
@{Names=@('Windows Server 2008 for Itanium-Based Systems');Key='4DWFP-JF3DJ-B7DTH-78FJB-PDRHK'}
)
}
+38
View File
@@ -0,0 +1,38 @@
[DEFAULT]
username=Administrator
groups=Administrators
inject_user_password=true
first_logon_behaviour=no
rename_admin_user=false
verbose=true
debug=false
log_dir=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\
log_file=cloudbase-init.log
default_log_levels=comtypes=INFO,suds=INFO,iso8601=WARN,requests=WARN
logging_serial_port_settings=
mtu_use_dhcp_config=true
ntp_use_dhcp_config=true
local_scripts_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\LocalScripts\
metadata_services=cloudbaseinit.metadata.services.configdrive.ConfigDriveService
allow_reboot=true
stop_service_on_exit=false
check_latest_version=false
[config_drive]
raw_hdd=false
cdrom=true
vfat=false
types=iso
locations=cdrom
+122
View File
@@ -0,0 +1,122 @@
[DEFAULT]
# Windows 安装 ISO 或 install.wim
wim_file_path=__WIM_PATH__
# 必须与 Get-WimFileImagesInfo 显示的名称完全相同
image_name=Windows 11 Pro for Workstations
# 最终输出
image_path=__OUTPUT_PATH__
# KVM 会最终转换为 QCOW2
virtual_disk_format=QCOW2
image_type=KVM
# Windows 11 / Proxmox 使用 UEFI
disk_layout=UEFI
product_key=""
force=False
install_maas_hooks=False
compression_format=""
# PVE 模板建议压缩 qcow2
compress_qcow2=True
# 是否缩小最终系统盘
shrink_image_to_minimum_size=False
zero_unused_volume_sectors=False
# 启用内置 Administrator
enable_administrator_account=True
disable_first_logon_animation=True
# 壁纸设置
enable_custom_wallpaper=False
wallpaper_path=""
wallpaper_solid_color=""
enable_shutdown_without_logon=True
enable_ping_requests=True
enable_ipv6_eui64=False
enable_active_mode=True
# 客户机软件安装和 Sysprep 脚本
custom_scripts_path=__PROJECT_ROOT__\hooks
custom_resources_path=__STAGED_RESOURCES__
[vm]
# 构建阶段的临时 Administrator 密码
administrator_password=__EPHEMERAL_BUILD_PASSWORD__
cpu_count=4
# 8 GiB
ram_size=8589934592
# 128 GiB
disk_size=137438953472
# Hyper-V 外部交换机名称
external_switch=external
# UEFI + Secure Boot
disable_secure_boot=False
[drivers]
# VirtIO ISO
virtio_iso_path=__ASSET_ROOT__\iso\virtio-win.iso
virtio_base_path=""
drivers_path=""
[custom]
# QGA 由 virtio-win-guest-tools 安装
install_qemu_ga=False
# Windows 时区 ID,默认美国纽约时间(自动处理夏令时),可在向导中更改
time_zone=Eastern Standard Time
ntp_servers=""
[updates]
# 是否安装 Windows Update
install_updates=False
# 是否清理更新文件
purge_updates=False
clean_updates_offline=False
clean_updates_online=False
[sysprep]
run_sysprep=True
unattend_xml_path=UnattendTemplate.xml
disable_swap=True
# KVM/VirtIO 模板需要保留驱动
persist_drivers_install=True
[cloudbase_init]
# 使用稳定版 Cloudbase-Init
beta_release=False
serial_logging_port=COM1
# 按客户资源目录展开;构建时自动下载到缓存,后续直接复用
msi_path=__ASSET_ROOT__\assets\CloudbaseInitSetup_Stable_x64.msi
# 使用为 PVE 准备的配置
cloudbase_init_config_path=__PROJECT_ROOT__\config\cloudbase-init.conf
cloudbase_init_unattended_config_path=""
cloudbase_init_use_local_system=False
cloudbase_init_delayed_start=False
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+12
View File
@@ -0,0 +1,12 @@
# PVE 首次启动账号配置
首次启动前,在 PVE 虚拟机中:
1. 将操作系统类型设为 Windows,添加 Cloud-Init 配置盘。
2. 在 Cloud-Init 页面将用户设为 `Administrator`,填写登录密码并生成配置盘。
3. 启动虚拟机,通过控制台或 RDP 使用该密码登录。
4. 在虚拟机选项中启用 QEMU Guest Agent。
Cloudbase-Init 从 ConfigDrive 读取密码。项目的账号插件在密码注入后启用内置 Administrator、清除首次改密标志,并在登录验证成功后禁用额外的 Admin 账号。
可通过 `resources/managed-admin-policy.json` 调整额外 Admin 账号的处理策略。
+95
View File
@@ -0,0 +1,95 @@
$ErrorActionPreference = 'Stop'
$stage = 'ValidateGuest'
try {
if (-not (Test-Path -LiteralPath 'C:\UnattendResources\config.ini')) {
throw 'This hook is intended for the imaging guest, not the build host.'
}
$stage = 'ConfigureQemuGuestAgent'
$service = Get-Service -Name 'qemu-ga' -ErrorAction SilentlyContinue
if (-not $service) {
throw 'QEMU Guest Agent service is missing after virtio-win-guest-tools installation.'
}
Set-Service -Name 'qemu-ga' -StartupType Automatic
Write-Host 'QEMU Guest Agent is supplied by virtio-win-guest-tools; standalone MSI installation is skipped.'
if (Get-Command Write-Log -CommandType Function -ErrorAction SilentlyContinue) {
Write-Log -Stage 'QemuGa' -StageLog 'QEMU Guest Agent provided by VirtIO guest tools; automatic startup configured.'
}
$stage = 'PrepareLocalScripts'
$source = 'C:\UnattendResources\CustomResources\Apply-GuestNetworkSettings.ps1'
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
throw 'Guest network settings script is missing.'
}
$localScripts = Join-Path $env:ProgramFiles 'Cloudbase Solutions\Cloudbase-Init\LocalScripts'
New-Item -ItemType Directory -Path $localScripts -Force | Out-Null
$resourceRoot='C:\UnattendResources\CustomResources'
if (Test-Path -LiteralPath (Join-Path $resourceRoot 'Software.Selection.ps1') -PathType Leaf) {
. (Join-Path $resourceRoot 'Software.Selection.ps1')
$stage = 'ReadSoftwareSelection'
$selection=Get-GuestSoftwareSelection -ResourceRoot $resourceRoot
if ($selection.KmsServer) {
$stage = 'StageKmsSettings'
$endpoint=ConvertTo-PveKmsEndpoint -Address $selection.KmsServer
$kmsSource=Join-Path $resourceRoot 'Apply-KmsSettings.ps1'
$kmsTarget=Join-Path $localScripts '20-KmsSettings.ps1'
if ((Test-Path -LiteralPath $kmsTarget) -and
(Get-FileHash -Algorithm SHA256 -LiteralPath $kmsTarget).Hash -ne
(Get-FileHash -Algorithm SHA256 -LiteralPath $kmsSource).Hash) { throw 'A different KMS LocalScript already exists; refusing to overwrite it.' }
Copy-Item -LiteralPath $kmsSource -Destination $kmsTarget -Force
$kmsSettings=[ordered]@{SchemaVersion=1;Host=$endpoint.Host;Port=$endpoint.Port}
[IO.File]::WriteAllText((Join-Path $localScripts 'kms-settings.json'),($kmsSettings|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
$stage = 'ApplyKmsSettings'
& $kmsTarget
$stage = 'StageWindowsActivation'
$activationSource=Join-Path $resourceRoot 'Activate-Windows.ps1'
$activationTarget=Join-Path $localScripts '30-WindowsActivation.ps1'
if ((Test-Path -LiteralPath $activationTarget) -and
(Get-FileHash -Algorithm SHA256 -LiteralPath $activationTarget).Hash -ne
(Get-FileHash -Algorithm SHA256 -LiteralPath $activationSource).Hash) { throw 'A different Windows activation LocalScript already exists; refusing to overwrite it.' }
# Cloudbase-Init runs this after 20-KmsSettings.ps1 on deployment, not during image construction.
Copy-Item -LiteralPath $activationSource -Destination $activationTarget -Force
}
}
$stage = 'StageGuestNetworkSettings'
$target = Join-Path $localScripts '10-GuestNetworkSettings.ps1'
if (Test-Path -LiteralPath $target) {
if ((Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash -ne
(Get-FileHash -Algorithm SHA256 -LiteralPath $source).Hash) {
throw 'A different guest network LocalScript already exists; refusing to overwrite it.'
}
}
else {
Copy-Item -LiteralPath $source -Destination $target
}
$stage = 'ApplyGuestNetworkSettings'
& $source
$stage = 'InstallManagedAdministrator'
& 'C:\UnattendResources\CustomResources\Install-ManagedAdministrator.ps1'
exit 0
}
catch {
$failure = $_
$failureReport = [ordered]@{
Stage = $stage
Message = $failure.Exception.Message
ExceptionType = $failure.Exception.GetType().FullName
PositionMessage = $failure.InvocationInfo.PositionMessage
ScriptStackTrace = $failure.ScriptStackTrace
TimestampUtc = [DateTime]::UtcNow.ToString('o')
}
try {
$diagnosticDirectory = 'C:\ProgramData\PveImageBuilder'
New-Item -ItemType Directory -Path $diagnosticDirectory -Force | Out-Null
[IO.File]::WriteAllText((Join-Path $diagnosticDirectory 'post-cloudbase-init-error.json'),
($failureReport | ConvertTo-Json -Depth 4), [Text.UTF8Encoding]::new($false))
} catch { Write-Warning ('Could not save guest hook diagnostics: ' + $_.Exception.Message) }
try {
if (Get-Command Write-Log -CommandType Function -ErrorAction SilentlyContinue) {
$location=$failure.InvocationInfo.ScriptName + ':' + $failure.InvocationInfo.ScriptLineNumber
Write-Log -Stage 'PostCloudbaseInitError' -StageLog ($stage + ': ' + $failure.Exception.Message + ' (' + $location + ')')
}
} catch { Write-Warning ('Could not publish guest hook diagnostics: ' + $_.Exception.Message) }
Write-Error $failure -ErrorAction Continue
exit 1
}
+120
View File
@@ -0,0 +1,120 @@
$ErrorActionPreference = "Stop"
function Write-TemplateStage {
param([string]$Stage, [string]$Message)
Write-Host "$Stage - $Message"
if (Get-Command Write-Log -CommandType Function -ErrorAction SilentlyContinue) {
Write-Log -Stage $Stage -StageLog $Message
}
}
function Test-ApplicationFile {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
$value = (Get-Item -LiteralPath $Path).VersionInfo.ProductVersion
return -not [string]::IsNullOrWhiteSpace($value)
}
function Test-VirtioBundleInstalled {
$roots = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$entries = @(Get-ItemProperty -Path $roots -ErrorAction SilentlyContinue)
foreach ($entry in $entries) {
if ($entry.DisplayName -eq 'Virtio-win-guest-tools') {
return $true
}
}
return $false
}
function Invoke-TemplateInstaller {
param(
[string]$Name,
[string]$FilePath,
[string]$Arguments,
[ref]$RebootRequired
)
Write-TemplateStage -Stage ($Name + 'InstallStart') -Message 'Starting silent installation.'
$process = Start-Process -FilePath $FilePath -ArgumentList $Arguments `
-WindowStyle Hidden -PassThru
# Wait for the installer itself; GUI descendants must not block imaging.
$null = $process.Handle
$process.WaitForExit()
if ($process.ExitCode -notin @(0, 3010, 1641)) {
throw "$Name installation failed: ExitCode=$($process.ExitCode). Check the installer log in C:\Windows\Temp."
}
if ($process.ExitCode -in @(3010, 1641)) { $RebootRequired.Value = $true }
}
try {
if (-not (Test-Path -LiteralPath 'C:\UnattendResources\config.ini')) {
throw 'This installation hook is intended for the imaging guest, not the build host.'
}
$resourceRoot = 'C:\UnattendResources\CustomResources'
if (Test-Path -LiteralPath (Join-Path $resourceRoot 'Software.Selection.ps1')) {
. (Join-Path $resourceRoot 'Software.Selection.ps1')
$selection = Get-GuestSoftwareSelection -ResourceRoot $resourceRoot
} else { $selection = [PSCustomObject]@{InstallChrome=$true;InstallVSCode=$true} }
. (Join-Path $resourceRoot 'Package.Validation.ps1')
foreach ($package in @(Get-SelectedImagePackages -SkipChrome:(-not $selection.InstallChrome) -SkipVSCode:(-not $selection.InstallVSCode))) {
Assert-ImagePackage -Path (Join-Path $resourceRoot $package.File) -Package $package
}
$rebootRequired = $false
$chrome = "$env:ProgramFiles\Google\Chrome\Application\chrome.exe"
if ($selection.InstallChrome -and -not (Test-ApplicationFile -Path $chrome)) {
$msi = Join-Path $resourceRoot 'chrome-enterprise64.msi'
$arguments = "/i `"$msi`" ALLUSERS=1 /qn /norestart /L*v `"C:\Windows\Temp\chrome-install.log`""
Invoke-TemplateInstaller -Name 'Chrome' -FilePath 'msiexec.exe' `
-Arguments $arguments -RebootRequired ([ref]$rebootRequired)
}
if ($selection.InstallChrome -and -not (Test-ApplicationFile -Path $chrome)) {
throw 'Chrome installation verification failed.'
}
if ($selection.InstallChrome) {
Write-TemplateStage -Stage 'ChromeInstall' -Message (
'Google Chrome ' + (Get-Item -LiteralPath $chrome).VersionInfo.ProductVersion.Trim() + ' installed for all users.'
)
} else { Write-TemplateStage -Stage 'ChromeInstall' -Message 'Skipped by software selection.' }
$code = "$env:ProgramFiles\Microsoft VS Code\Code.exe"
if ($selection.InstallVSCode -and -not (Test-ApplicationFile -Path $code)) {
$setup = Join-Path $resourceRoot 'VSCodeSetup-x64.exe'
$arguments = '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS="addtopath,!runcode" /LOG="C:\Windows\Temp\vscode-install.log"'
Invoke-TemplateInstaller -Name 'VSCode' -FilePath $setup `
-Arguments $arguments -RebootRequired ([ref]$rebootRequired)
}
if ($selection.InstallVSCode -and -not (Test-ApplicationFile -Path $code)) {
throw 'VS Code system installation verification failed.'
}
if ($selection.InstallVSCode) {
Write-TemplateStage -Stage 'VSCodeInstall' -Message (
'Visual Studio Code ' + (Get-Item -LiteralPath $code).VersionInfo.ProductVersion.Trim() + ' installed for all users.'
)
} else { Write-TemplateStage -Stage 'VSCodeInstall' -Message 'Skipped by software selection.' }
if (-not (Test-VirtioBundleInstalled)) {
$setup = Join-Path $resourceRoot 'virtio-win-guest-tools.exe'
$arguments = '/install /quiet /norestart /log "C:\Windows\Temp\virtio-guest-tools-install.log"'
Invoke-TemplateInstaller -Name 'VirtioGuestTools' -FilePath $setup `
-Arguments $arguments -RebootRequired ([ref]$rebootRequired)
}
if (-not (Test-VirtioBundleInstalled)) { throw 'VirtIO guest tools bundle installation verification failed.' }
if (-not (Get-Service -Name 'qemu-ga' -ErrorAction SilentlyContinue)) {
throw 'The VirtIO guest tools installation did not create the QEMU Guest Agent service.'
}
Set-Service -Name 'qemu-ga' -StartupType Automatic
Write-TemplateStage -Stage 'VirtioGuestToolsInstall' -Message 'Virtio-win-guest-tools installed, including QEMU Guest Agent.'
if ($rebootRequired) {
Write-TemplateStage -Stage 'SoftwareReboot' -Message 'Installer requested a reboot; imaging will resume after restart.'
exit 1005
}
exit 0
}
catch {
Write-Error $_ -ErrorAction Continue
exit 1
}
+36
View File
@@ -0,0 +1,36 @@
$ErrorActionPreference = "Stop"
Write-Host "Running pre-Sysprep cleanup..."
Get-AppxPackage `
-AllUsers `
Microsoft.OneDriveSync |
Remove-AppxPackage `
-AllUsers `
-ErrorAction SilentlyContinue
powercfg.exe /hibernate off
if ($LASTEXITCODE -ne 0) { throw 'Could not disable hibernation.' }
$resourceRoot = 'C:\UnattendResources\CustomResources'
if (Test-Path -LiteralPath (Join-Path $resourceRoot 'Software.Selection.ps1')) {
. (Join-Path $resourceRoot 'Software.Selection.ps1')
$selection = Get-GuestSoftwareSelection -ResourceRoot $resourceRoot
} else { $selection = [PSCustomObject]@{InstallChrome=$true;InstallVSCode=$true} }
$qga = Get-Service -Name 'qemu-ga' -ErrorAction Stop
$cloudbase = Get-Service -Name 'cloudbase-init' -ErrorAction Stop
$proof = [ordered]@{CheckedUtc=[DateTime]::UtcNow.ToString('o');InstallChrome=$selection.InstallChrome;InstallVSCode=$selection.InstallVSCode;QemuGuestAgent=($qga.StartType -eq 'Automatic');CloudbaseInit=($null -ne $cloudbase)}
foreach ($application in @(@{Selected=$selection.InstallChrome;Name='Chrome';Path=(Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe')},@{Selected=$selection.InstallVSCode;Name='VSCode';Path=(Join-Path $env:ProgramFiles 'Microsoft VS Code\Code.exe')})) {
if ($application.Selected) {
if (-not (Test-Path -LiteralPath $application.Path -PathType Leaf)) { throw ('Selected application missing: ' + $application.Name) }
$proof[$application.Name+'Version'] = (Get-Item -LiteralPath $application.Path).VersionInfo.ProductVersion.Trim()
}
}
if (-not $proof.QemuGuestAgent) { throw 'QEMU Guest Agent must start automatically.' }
$proofRoot=Join-Path $env:ProgramData 'PveImageBuilder'
New-Item -ItemType Directory -Path $proofRoot -Force|Out-Null
[IO.File]::WriteAllText((Join-Path $proofRoot 'build-verification.json'),($proof|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
Write-Host "Pre-Sysprep cleanup completed."
exit 0
+86
View File
@@ -0,0 +1,86 @@
$ErrorActionPreference = 'Stop'
# Staged for Cloudbase-Init LocalScripts only; the build hook never invokes it.
$report = [ordered]@{
SchemaVersion = 1
StartedUtc = [DateTime]::UtcNow.ToString('o')
Outcome = 'Skipped'
Attempted = $false
KmsHost = $null
KmsPort = $null
ProductId = $null
LicenseStatus = $null
LicenseStatusReason = $null
ReturnValue = $null
Message = $null
}
try {
$settingsPath = Join-Path $PSScriptRoot 'kms-settings.json'
if (-not (Test-Path -LiteralPath $settingsPath -PathType Leaf)) { throw 'Guest KMS settings are missing.' }
$settings = Get-Content -Raw -Encoding UTF8 -LiteralPath $settingsPath | ConvertFrom-Json
if ($settings.SchemaVersion -ne 1 -or $settings.Host -isnot [string] -or
[Uri]::CheckHostName($settings.Host) -eq [UriHostNameType]::Unknown -or
$settings.Port -isnot [int] -or $settings.Port -lt 1 -or $settings.Port -gt 65535) { throw 'Invalid guest KMS settings.' }
$report.KmsHost = $settings.Host
$report.KmsPort = $settings.Port
# 20-KmsSettings.ps1 runs first. Do not activate against stale settings if it failed.
$service = Get-CimInstance -ClassName SoftwareLicensingService -ErrorAction Stop
if ($service.KeyManagementServiceMachine -ine $settings.Host -or
$service.KeyManagementServicePort -ne $settings.Port) { throw 'The selected KMS server has not been applied; activation was not requested.' }
$filter = "ApplicationID='55c92734-d682-4d71-983e-d6ec3f16059f' AND PartialProductKey IS NOT NULL"
$products = @(Get-CimInstance -ClassName SoftwareLicensingProduct -Filter $filter -ErrorAction Stop |
Where-Object { $_.Description -match '\bVOLUME_KMSCLIENT\b' -and -not $_.LicenseIsAddon })
if ($products.Count -eq 0) {
$report.Message = 'No installed Windows KMS client license was found; activation was not requested.'
}
else {
if ($products.Count -ne 1) { throw 'Multiple Windows KMS client licenses were found; activation was not requested.' }
$product = $products[0]
$report.ProductId = $product.ID
$report.LicenseStatus = $product.LicenseStatus
$report.LicenseStatusReason = $product.LicenseStatusReason
if ($product.LicenseStatus -eq 1) {
$report.Outcome = 'AlreadyActivated'
$report.Message = 'Windows is already activated.'
}
else {
$report.Attempted = $true
# Equivalent to slmgr /ato for this Windows KMS license, with a bounded CIM request.
$result = Invoke-CimMethod -InputObject $product -MethodName Activate -OperationTimeoutSec 30 -ErrorAction Stop
if ($result -is [int] -or $result -is [uint32]) { $report.ReturnValue = $result }
elseif ($null -ne $result -and $result.PSObject.Properties['ReturnValue']) { $report.ReturnValue = $result.ReturnValue }
$current = @(Get-CimInstance -ClassName SoftwareLicensingProduct -Filter $filter -ErrorAction Stop |
Where-Object { $_.ID -eq $product.ID })
if ($current.Count -ne 1) { throw 'The Windows license could not be read back after the activation request.' }
$report.LicenseStatus = $current[0].LicenseStatus
$report.LicenseStatusReason = $current[0].LicenseStatusReason
if ($null -ne $report.ReturnValue -and $report.ReturnValue -ne 0) { throw ('Windows activation returned error ' + $report.ReturnValue + '.') }
if ($report.LicenseStatus -ne 1) { throw ('Windows is not activated after the request (LicenseStatus=' + $report.LicenseStatus + ', LicenseStatusReason=' + $report.LicenseStatusReason + ').') }
$report.Outcome = 'Activated'
$report.Message = 'Windows activation was confirmed by the licensing service.'
}
}
}
catch {
$report.Outcome = 'Failed'
$report.Message = $_.Exception.Message
$report.ExceptionHResult = $_.Exception.HResult
}
$report.CompletedUtc = [DateTime]::UtcNow.ToString('o')
try {
$diagnosticDirectory = Join-Path $env:ProgramData 'PveImageBuilder'
New-Item -ItemType Directory -Path $diagnosticDirectory -Force | Out-Null
[IO.File]::WriteAllText((Join-Path $diagnosticDirectory 'windows-activation.json'),
($report | ConvertTo-Json -Depth 4), [Text.UTF8Encoding]::new($false))
}
catch { Write-Warning ('Could not save Windows activation diagnostics: ' + $_.Exception.Message) -WarningAction Continue }
if ($report.Outcome -eq 'Failed') { Write-Warning ('Windows activation is optional; initialization will continue. ' + $report.Message) -WarningAction Continue }
else { Write-Host $report.Message }
# Cloudbase-Init must finish even when the deployment has no network or KMS access.
exit 0
+26
View File
@@ -0,0 +1,26 @@
$ErrorActionPreference = 'Stop'
# Runs inside the image guest during build and again through Cloudbase-Init LocalScripts.
$cloudbaseRoot = Join-Path $env:ProgramFiles 'Cloudbase Solutions\Cloudbase-Init'
if (-not (Test-Path -LiteralPath $cloudbaseRoot)) {
throw 'Cloudbase-Init is not installed. Do not run this guest settings script on the build host.'
}
Set-NetFirewallProfile -Profile Domain, Private, Public -Enabled False
$enabledProfiles = @(Get-NetFirewallProfile | Where-Object { [string]$_.Enabled -ne 'False' })
if ($enabledProfiles.Count -gt 0) {
throw 'One or more Windows Firewall profiles remain enabled.'
}
# Keep Remote Desktop enabled with Network Level Authentication.
Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
-Name 'fDenyTSConnections' -Value 0
Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' `
-Name 'UserAuthentication' -Value 1
Set-Service -Name 'TermService' -StartupType Automatic
$message = 'Guest firewall disabled for Domain/Private/Public; Remote Desktop enabled with NLA.'
Write-Output $message
if (Get-Command Write-Log -CommandType Function -ErrorAction SilentlyContinue) {
Write-Log -Stage 'GuestNetworkSettings' -StageLog $message
}
+28
View File
@@ -0,0 +1,28 @@
$ErrorActionPreference='Stop'
$settingsPath=Join-Path $PSScriptRoot 'kms-settings.json'
if (-not (Test-Path -LiteralPath $settingsPath -PathType Leaf)) { throw 'Guest KMS settings are missing.' }
$settings=Get-Content -Raw -Encoding UTF8 -LiteralPath $settingsPath | ConvertFrom-Json
if ($settings.SchemaVersion -ne 1 -or $settings.Host -isnot [string] -or
[Uri]::CheckHostName($settings.Host) -eq [UriHostNameType]::Unknown -or
$settings.Port -isnot [int] -or $settings.Port -lt 1 -or $settings.Port -gt 65535) { throw 'Invalid guest KMS settings.' }
# Configure the Windows licensing service locally. No activation request is sent.
$service=Get-CimInstance -ClassName SoftwareLicensingService -ErrorAction Stop
foreach ($operation in @(
@{Name='SetKeyManagementServiceMachine';Arguments=@{MachineName=$settings.Host}},
@{Name='SetKeyManagementServicePort';Arguments=@{PortNumber=[uint32]$settings.Port}}
)) {
$result=Invoke-CimMethod -InputObject $service -MethodName $operation.Name -Arguments $operation.Arguments -ErrorAction Stop
# Some Windows licensing providers complete the CIM method without a numeric
# return value. A missing return value is not a failure code; verify the settings below.
$returnValue=$null
if ($result -is [int] -or $result -is [uint32]) { $returnValue=$result }
elseif ($null -ne $result) {
$returnProperty=$result.PSObject.Properties['ReturnValue']
if ($returnProperty) { $returnValue=$returnProperty.Value }
}
if ($null -ne $returnValue -and $returnValue -ne 0) { throw ('Could not configure the KMS server: '+$operation.Name+' returned '+$returnValue) }
}
$configured=Get-CimInstance -ClassName SoftwareLicensingService -ErrorAction Stop
if ($configured.KeyManagementServiceMachine -ine $settings.Host -or
$configured.KeyManagementServicePort -ne $settings.Port) { throw 'The KMS server configuration did not persist in the Windows licensing service.' }
Write-Host ('KMS server configured: '+$settings.Host+':'+$settings.Port)
+39
View File
@@ -0,0 +1,39 @@
$ErrorActionPreference='Stop'
if (-not (Test-Path -LiteralPath 'C:\UnattendResources\config.ini' -PathType Leaf)) { throw 'Specialize is intended for the imaging guest only.' }
$directory=Join-Path $env:ProgramData 'PveImageBuilder'
$state=[ordered]@{Phase='starting';StartedUtc=[DateTime]::UtcNow.ToString('o');InstallationType=$null;ClientPoliciesApplied=$false}
function Write-SpecializeState {
param([string]$Message,[switch]$Failed)
[IO.File]::WriteAllText((Join-Path $directory 'specialize-status.json'),($state|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
$kvp='HKLM:\SOFTWARE\Microsoft\Virtual Machine\Auto'
if (Test-Path -LiteralPath $kvp) {
$name='ImageGenerationLog-Specialize'
if ($Failed) { $name='ImageGenerationLog-ERROR' }
Set-ItemProperty -LiteralPath $kvp -Name $name -Value $Message -ErrorAction SilentlyContinue
}
Write-Host $Message
}
try {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
Write-SpecializeState -Message 'Specialize started.'
$state.InstallationType=[string](Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name InstallationType -ErrorAction Stop).InstallationType
if ($state.InstallationType -eq 'Client') {
foreach ($policy in @(@{Path='HKLM:\SOFTWARE\Policies\Microsoft\WindowsStore';Name='AutoDownload';Value=2},@{Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent';Name='DisableWindowsConsumerFeatures';Value=1})) {
New-Item -Path $policy.Path -Force | Out-Null
New-ItemProperty -LiteralPath $policy.Path -Name $policy.Name -Value $policy.Value -PropertyType DWord -Force | Out-Null
}
$state.ClientPoliciesApplied=$true
}
# Network rules are configured by Logon and the project installation hooks.
# Do not wait on Store services or keyboard input during Windows setup.
$state.Phase='complete'
$state.CompletedUtc=[DateTime]::UtcNow.ToString('o')
Write-SpecializeState -Message ('Specialize complete: '+$state.InstallationType)
exit 0
} catch {
$state.Phase='failed'
$state.Error=$_.Exception.Message
try { Write-SpecializeState -Message ('Specialize failed: '+$_.Exception.Message) -Failed } catch {}
Write-Error $_ -ErrorAction Continue
exit 1
}
@@ -0,0 +1,14 @@
$ErrorActionPreference='Stop'
$root=Join-Path $env:ProgramFiles 'Cloudbase Solutions\Cloudbase-Init'
if(-not(Test-Path -LiteralPath 'C:\UnattendResources\config.ini')){throw 'This installer is intended for the image build guest.'}
$default=@(Get-ChildItem -LiteralPath $root -Filter 'default.py' -File -Recurse|Where-Object{$_.FullName -like '*\cloudbaseinit\conf\default.py'})
if($default.Count -ne 1){throw 'Cloudbase-Init Python configuration module was not found uniquely.'}
$package=Split-Path -Parent $default[0].DirectoryName
$python=@(Get-ChildItem -LiteralPath $root -Filter 'python.exe' -File -Recurse|Where-Object{$_.DirectoryName -match '\\Python$'})
if($python.Count -ne 1){throw 'Cloudbase-Init Python runtime was not found uniquely.'}
$resources='C:\UnattendResources\CustomResources'
Copy-Item -LiteralPath (Join-Path $resources 'managedadmin.py') -Destination (Join-Path $package 'plugins\common\managedadmin.py') -Force
Copy-Item -LiteralPath (Join-Path $resources 'managed-admin-policy.json') -Destination (Join-Path $root 'conf\managed-admin-policy.json') -Force
& $python[0].FullName (Join-Path $resources 'configure-managed-admin.py') $default[0].FullName (Join-Path $root 'conf\cloudbase-init.conf') 'C:\Windows\Temp\managed-admin-plugin-order.json'
if($LASTEXITCODE -ne 0){throw 'Managed administrator plugin registration failed.'}
Write-Output 'Managed administrator plugin installed after password injection.'
+54
View File
@@ -0,0 +1,54 @@
"""Add the managed administrator plugin after the installed password plugin."""
import argparse
import ast
import configparser
import json
from pathlib import Path
p = argparse.ArgumentParser()
p.add_argument('default_module')
p.add_argument('config')
p.add_argument('proof')
args = p.parse_args()
config_path = Path(args.config)
text = config_path.read_text(encoding='utf-8-sig')
parser = configparser.RawConfigParser()
parser.read_string(text)
plugins = parser.defaults().get('plugins')
if plugins:
plugins = [part.strip() for part in plugins.split(',') if part.strip()]
else:
tree = ast.parse(Path(args.default_module).read_text(encoding='utf-8-sig'))
plugins = None
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == 'ListOpt' and node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value == 'plugins':
plugins = ast.literal_eval(next(k.value for k in node.keywords if k.arg == 'default'))
break
if not plugins:
raise RuntimeError('Installed plugin order could not be determined')
managed = 'cloudbaseinit.plugins.common.managedadmin.EnableManagedAdministratorPlugin'
plugins = [value for value in plugins if value != managed]
password = 'cloudbaseinit.plugins.common.setuserpassword.SetUserPasswordPlugin'
if password not in plugins:
raise RuntimeError('Password injection plugin is not configured')
plugins.insert(plugins.index(password) + 1, managed)
lines = text.splitlines()
section = ''
result = []
inserted = False
for line in lines:
if line.strip().startswith('['):
section = line.strip().lower()
result.append(line)
if section == '[default]':
result.append('plugins=' + ','.join(plugins))
inserted = True
elif section == '[default]' and line.strip().lower().startswith('plugins='):
continue
else:
result.append(line)
if not inserted:
raise RuntimeError('DEFAULT config section is missing')
config_path.write_text('\r\n'.join(result) + '\r\n', encoding='utf-8')
Path(args.proof).write_text(json.dumps({'Plugins': plugins, 'ManagedPluginAfterPassword': plugins.index(managed) == plugins.index(password) + 1}, indent=2), encoding='utf-8')
print('Installed plugin order preserved; managed administrator follows password injection.')
+1
View File
@@ -0,0 +1 @@
{"disable_extra_admin": true}
+74
View File
@@ -0,0 +1,74 @@
"""Activate the managed built-in administrator after password injection."""
import datetime
import hmac
import json
import os
import win32net
from oslo_log import log as logging
from cloudbaseinit import conf
from cloudbaseinit.osutils import factory
from cloudbaseinit.plugins.common import base, constants
LOG = logging.getLogger(__name__)
CONF = conf.CONF
PLUGIN_VERSION = "1.2"
class EnableManagedAdministratorPlugin(base.BasePlugin):
def execute(self, service, shared_data):
username = shared_data.get(constants.SHARED_DATA_USERNAME, CONF.username)
utils = factory.get_os_utils()
if not utils.user_exists(username) or not utils.is_builtin_admin(username):
LOG.warning("Managed administrator activation skipped for non-builtin target %s", username)
return base.PLUGIN_EXECUTION_DONE, False
password = shared_data.get(constants.SHARED_DATA_PASSWORD)
expected = service.get_admin_password() if CONF.inject_user_password else None
if not password or not expected or not hmac.compare_digest(password, expected):
raise RuntimeError("Managed administrator requires successful metadata password injection")
root = os.path.join(os.environ["ProgramFiles"], "Cloudbase Solutions", "Cloudbase-Init")
policy_path = os.path.join(root, "conf", "managed-admin-policy.json")
with open(policy_path, encoding="utf-8-sig") as stream:
policy = json.load(stream)
original_flags = win32net.NetUserGetInfo(None, username, 1)["flags"]
token = None
try:
utils.set_user_info(username, disabled=False)
info = win32net.NetUserGetInfo(None, username, 4)
info["password_expired"] = 0
win32net.NetUserSetInfo(None, username, 4, info)
token = utils.create_user_logon_session(username, password, True)
except Exception:
# Preserve the prior activation state if authentication failed.
win32net.NetUserSetInfo(None, username, 1008, {"flags": original_flags})
raise
finally:
if token is not None:
utils.close_user_logon_session(token)
extra_disabled = False
if policy.get("disable_extra_admin", False) and utils.user_exists("Admin"):
if not utils.is_builtin_admin("Admin") and username.lower() != "admin":
utils.set_user_info("Admin", disabled=True)
extra_disabled = True
final = win32net.NetUserGetInfo(None, username, 4)
proof = {
"PluginVersion": PLUGIN_VERSION,
"Username": username,
"SID": utils.get_user_sid(username),
"Enabled": not bool(final["flags"] & 2),
"PasswordExpired": bool(final["password_expired"]),
"InjectedPasswordAuthenticated": True,
"ExtraAdminDisabled": extra_disabled,
"CompletedUtc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
proof_dir = os.path.join(os.environ["ProgramData"], "Cloudbase-Init")
os.makedirs(proof_dir, exist_ok=True)
with open(os.path.join(proof_dir, "managed-admin-activation.json"), "w", encoding="utf-8") as stream:
json.dump(proof, stream, indent=2)
LOG.info("Managed administrator %s enabled; injected password authenticated; extra Admin disabled: %s", username, extra_disabled)
return base.PLUGIN_EXECUTION_DONE, False
+133
View File
@@ -0,0 +1,133 @@
. (Join-Path $PSScriptRoot 'Build.Resources.ps1')
function Read-PveCleanupChoice {
while ($true) {
$answer=([string](Read-Host '是否清理本轮构建的临时资源?(Y/N) [Y]')).Trim()
if (-not $answer -or $answer -in @('y','yes')) { return $true }
if ($answer -in @('n','no')) { return $false }
Write-Host '请输入 Y 或 N,回车默认选择是。' -ForegroundColor Yellow
}
}
function Test-PveBuildCancellationKey {
if ([Console]::IsInputRedirected) { return $false }
while ([Console]::KeyAvailable) {
$key=[Console]::ReadKey($true)
if ([int]$key.KeyChar -eq 3 -or ($key.Key -eq [ConsoleKey]::C -and ($key.Modifiers -band [ConsoleModifiers]::Control))) { return $true }
}
return $false
}
function Write-PveBuildOutput {
param([string]$Line)
if ($Line -eq '#< CLIXML') { return }
if ($Line.StartsWith('<Objs ') -and $Line.Contains('http://schemas.microsoft.com/powershell/2004/04')) {
try {
$document=[Xml.XmlDocument]::new()
$document.XmlResolver=$null
$document.LoadXml($Line)
foreach ($node in $document.DocumentElement.ChildNodes) {
if ($node.GetAttribute('S') -in @('error','warning','verbose','debug')) {
$value=$node.InnerText
$value=[regex]::Replace($value,'_x([0-9a-fA-F]{4})_',[Text.RegularExpressions.MatchEvaluator]{param($match) [string][char][Convert]::ToInt32($match.Groups[1].Value,16)})
Write-Host $value
}
}
return
} catch {}
}
Write-Host $Line
}
function Set-PveCancelledBuildState {
param([string]$ContextPath,[string]$Cleanup,[string]$Detail)
$context=Get-PveBuildResources -ContextPath $ContextPath
$context.Cleanup=$Cleanup
Save-PveBuildResources -ContextPath $ContextPath -Context $context
$path=Join-Path $context.LogDirectory 'status.json'
$state=[ordered]@{Phase='cancelled';OutputPath=$context.OutputPath;LogDirectory=$context.LogDirectory;Cleanup=$Cleanup;CancelledUtc=[DateTime]::UtcNow.ToString('o')}
if ($Detail) { $state.Error=$Detail }
[IO.File]::WriteAllText($path,($state|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false))
return [PSCustomObject]$state
}
function Invoke-PveCancelledBuild {
param([string]$ContextPath)
try { Stop-PveOwnedBuildVms -ContextPath $ContextPath }
catch { Write-Host ('停止本轮临时 VM 时遇到问题:'+$_.Exception.Message) -ForegroundColor Yellow }
if (Read-PveCleanupChoice) {
try {
Remove-PveOwnedBuildResources -ContextPath $ContextPath
Write-Host '构建已取消,本轮临时资源已清理。ISO、安装包和日志已保留。' -ForegroundColor Green
return Set-PveCancelledBuildState -ContextPath $ContextPath -Cleanup 'complete'
} catch {
Write-Host ('构建已取消,部分资源未能清理:'+$_.Exception.Message) -ForegroundColor Yellow
return Set-PveCancelledBuildState -ContextPath $ContextPath -Cleanup 'failed' -Detail $_.Exception.Message
}
}
Write-Host '构建已取消,临时资源已保留。' -ForegroundColor Yellow
return Set-PveCancelledBuildState -ContextPath $ContextPath -Cleanup 'declined'
}
function Invoke-PveControlledBuild {
param([string]$ScriptPath,[string]$ConfigPath,$Settings,$Config,[string]$ServicingIsoPath,[switch]$SkipBootVerification)
if (-not ('PveImageBuilder.BuildProcess' -as [type])) { Add-Type -Path (Join-Path $PSScriptRoot 'Build.Process.cs') }
$outputLock=$null
try {
$lockDirectory=Join-Path $Settings.ProjectRoot 'local\build-locks'
New-Item -ItemType Directory -Path $lockDirectory -Force | Out-Null
$hasher=[Security.Cryptography.SHA256]::Create()
try { $lockName=([BitConverter]::ToString($hasher.ComputeHash([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($Config.image_path).ToLowerInvariant())))).Replace('-','')+'.lock' }
finally { $hasher.Dispose() }
try { $outputLock=[IO.File]::Open((Join-Path $lockDirectory $lockName),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) }
catch { throw '另一个构建正在使用同一输出路径,请选择新的输出文件名。' }
$runId=(Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N')
$logDirectory=Join-Path $Settings.AssetsRoot ('logs\builds\wizard-'+$runId)
New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null
$contextPath=Join-Path $logDirectory 'resources.json'
$context=[PSCustomObject]@{SchemaVersion=1;RunId=$runId;AssetsRoot=[IO.Path]::GetFullPath($Settings.AssetsRoot);OutputPath=[IO.Path]::GetFullPath($Config.image_path);LogDirectory=[IO.Path]::GetFullPath($logDirectory);Completed=$false;Cleanup='not_requested';Files=@();Directories=@();VMs=@();SourceIsos=@()}
foreach ($iso in @($Config.wim_file_path,$Config.virtio_iso_path,$ServicingIsoPath) | Select-Object -Unique) {
if ([IO.Path]::GetExtension($iso) -ieq '.iso') {
$image=Get-DiskImage -ImagePath $iso -ErrorAction Stop
$context.SourceIsos+= [PSCustomObject]@{Path=[IO.Path]::GetFullPath($iso);AttachedBefore=[bool]$image.Attached}
}
}
Save-PveBuildResources -ContextPath $contextPath -Context $context
Register-PveBuildFile -ContextPath $contextPath -Path $context.OutputPath
Register-PveBuildFile -ContextPath $contextPath -Path ([IO.Path]::ChangeExtension($context.OutputPath,'.vhdx')) -Kind Disk
Register-PveBuildFile -ContextPath $contextPath -Path ([IO.Path]::ChangeExtension($context.OutputPath,'.raw')) -Kind Disk
$quote={param($value) "'"+$value.Replace("'","''")+"'"}
$snapshot=Join-Path $logDirectory 'build.ini'
Register-PveBuildFile -ContextPath $contextPath -Path $snapshot
Register-PveBuildFile -ContextPath $contextPath -Path ($snapshot+'.offline')
Copy-Item -LiteralPath ([IO.Path]::GetFullPath($ConfigPath)) -Destination $snapshot
$command='[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $ProgressPreference=''SilentlyContinue''; try { & '+(& $quote $ScriptPath)+' -ConfigPath '+(& $quote $snapshot)+' -InternalWorker -ResourceContextPath '+(& $quote $contextPath)+' -Confirm:$false'
if ($SkipBootVerification) { $command+=' -SkipBootVerification' }
$command+=' } catch { [Console]::Error.WriteLine($_.ToString()); exit 1 }; if ($?) { exit 0 } else { exit 1 }'
$encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
$worker=$null
$consoleChanged=$false
$previous=$false
$cancelled=$false
try {
if (-not [Console]::IsInputRedirected) {
$previous=[Console]::TreatControlCAsInput
[Console]::TreatControlCAsInput=$true
$consoleChanged=$true
}
$worker=[PveImageBuilder.BuildProcess]::new((Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe'),('-NoProfile -ExecutionPolicy Bypass -OutputFormat Text -EncodedCommand '+$encoded),(Split-Path -Parent $ScriptPath))
Write-Host '构建已启动。按 Ctrl+C 中断后,可选择是否清理本轮临时资源(默认是)。' -ForegroundColor Cyan
while (-not $worker.HasExited) {
foreach ($line in $worker.ReadOutput()) { Write-PveBuildOutput -Line $line }
if (Test-PveBuildCancellationKey) { $cancelled=$true; $worker.Cancel(); break }
Start-Sleep -Milliseconds 150
}
$worker.Wait()
foreach ($line in $worker.ReadOutput()) { Write-PveBuildOutput -Line $line }
$exitCode=$worker.ExitCode
} finally {
if ($consoleChanged) { [Console]::TreatControlCAsInput=$previous }
if ($worker) { $worker.Dispose() }
}
if ($cancelled) {
$latest=Get-PveBuildResources -ContextPath $contextPath
if (-not $latest.Completed) { return Invoke-PveCancelledBuild -ContextPath $contextPath }
}
if ($exitCode -ne 0 -and -not $cancelled) { throw ('构建失败,日志:'+$logDirectory) }
return Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $logDirectory 'status.json') | ConvertFrom-Json
} finally { if ($outputLock) { $outputLock.Dispose() } }
}
+11
View File
@@ -0,0 +1,11 @@
@{
UpstreamCommit = '342c6788c51c23d6441cecd4013fd8c3165d8918'
Qemu = @{
Version = '11.1.0'
InstallerUrl = 'https://qemu.weilnetz.de/w64/qemu-w64-setup-20260811.exe'
SHA512 = '5BCF9EED634E8575A37B74F445AF41A2FE4106DA512D0C30C368301D4C105037FDFAB40A5287367A28A957624CDDEBBC8C07E16C88AB6634F554CDF3D16BF543'
ExecutableSHA256 = '9D930582D0D574BB106D89982C547688E10B8F356192D302CC3D0AE4E8674534'
SevenZipMsiUrl = 'https://github.com/ip7z/7zip/releases/download/26.04/7z2604-x64.msi'
SevenZipMsiSHA256 = '0B01334A418654293513449F61D6BFC99E5196CA371E3C3DC961EDA57CD535C6'
}
}
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace PveImageBuilder {
public sealed class BuildProcess : IDisposable {
[StructLayout(LayoutKind.Sequential)] struct BasicLimits {
public long PerProcessTime, PerJobTime;
public uint LimitFlags;
public UIntPtr MinWorkingSet, MaxWorkingSet;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass, SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)] struct IoCounters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; }
[StructLayout(LayoutKind.Sequential)] struct ExtendedLimits {
public BasicLimits Basic;
public IoCounters Io;
public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory;
}
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimits info, uint length);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint exitCode);
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
readonly ConcurrentQueue<string> output = new ConcurrentQueue<string>();
readonly Process process = new Process();
IntPtr job;
public BuildProcess(string executable, string arguments, string directory) {
job=CreateJobObject(IntPtr.Zero,null);
if (job==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
try {
var limits=new ExtendedLimits();
limits.Basic.LimitFlags=0x2000; // Kill only this job's processes on close.
if (!SetInformationJobObject(job,9,ref limits,(uint)Marshal.SizeOf(typeof(ExtendedLimits)))) throw new Win32Exception(Marshal.GetLastWin32Error());
process.StartInfo=new ProcessStartInfo(executable,arguments) { UseShellExecute=false, CreateNoWindow=true, RedirectStandardOutput=true, RedirectStandardError=true, WorkingDirectory=directory, StandardOutputEncoding=new System.Text.UTF8Encoding(false), StandardErrorEncoding=new System.Text.UTF8Encoding(false) };
process.OutputDataReceived += (sender,e) => { if(e.Data!=null) output.Enqueue(e.Data); };
process.ErrorDataReceived += (sender,e) => { if(e.Data!=null) output.Enqueue(e.Data); };
process.Start();
if (!AssignProcessToJobObject(job,process.Handle)) throw new Win32Exception(Marshal.GetLastWin32Error());
process.BeginOutputReadLine();
process.BeginErrorReadLine();
} catch {
try { if(!process.HasExited) process.Kill(); } catch {}
CloseHandle(job); job=IntPtr.Zero;
process.Dispose();
throw;
}
}
public string[] ReadOutput() { var lines=new System.Collections.Generic.List<string>(); string line; while(output.TryDequeue(out line)) lines.Add(line); return lines.ToArray(); }
public bool HasExited { get { return process.HasExited; } }
public int ExitCode { get { return process.ExitCode; } }
public int Id { get { return process.Id; } }
public void Wait() { process.WaitForExit(); }
public void Cancel() {
if (!TerminateJobObject(job,130)) throw new Win32Exception(Marshal.GetLastWin32Error());
if (!process.WaitForExit(10000)) throw new TimeoutException("Build worker did not stop.");
process.WaitForExit();
}
public void Dispose() { if(job!=IntPtr.Zero) { CloseHandle(job); job=IntPtr.Zero; } process.Dispose(); }
}
}
+162
View File
@@ -0,0 +1,162 @@
function script:Save-PveBuildResources {
param([string]$ContextPath,$Context)
$temporary=$ContextPath+'.writing-'+[guid]::NewGuid().ToString('N')
[IO.File]::WriteAllText($temporary,($Context|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
if (Test-Path -LiteralPath $ContextPath) { [IO.File]::Replace($temporary,$ContextPath,[NullString]::Value) }
else { [IO.File]::Move($temporary,$ContextPath) }
}
function script:Get-PveBuildResources {
param([string]$ContextPath)
$context=Get-Content -Raw -Encoding UTF8 -LiteralPath $ContextPath | ConvertFrom-Json
if ($context.SchemaVersion -ne 1 -or -not $context.RunId) { throw 'Invalid build resource context.' }
return $context
}
function script:Assert-PveResourcePath {
param([string]$Path,[string]$Root,[switch]$Descendant)
$full=[IO.Path]::GetFullPath($Path)
$base=[IO.Path]::GetFullPath($Root).TrimEnd('\')
if ($Descendant) {
if (-not $full.StartsWith($base+'\',[StringComparison]::OrdinalIgnoreCase)) { throw 'Resource path escaped its run directory.' }
} elseif ($full -ine $base) { throw 'Resource path does not match its recorded owner.' }
return $full
}
function script:Test-PveBuildFilePath {
param($Context,[string]$Path)
$full=[IO.Path]::GetFullPath($Path)
if ($full -in @($Context.OutputPath,[IO.Path]::ChangeExtension($Context.OutputPath,'.vhdx'),[IO.Path]::ChangeExtension($Context.OutputPath,'.raw'))) { return $true }
if ($full -in @((Join-Path $Context.LogDirectory 'build.ini'),(Join-Path $Context.LogDirectory 'build.ini.offline'),(Join-Path $Context.LogDirectory 'windows-pe\servicing.iso'))) { return $true }
$prefix=[IO.Path]::GetFileNameWithoutExtension($Context.OutputPath)+'.compressing-'
return ([IO.Path]::GetDirectoryName($full) -ieq [IO.Path]::GetDirectoryName($Context.OutputPath) -and [IO.Path]::GetFileName($full).StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -and [IO.Path]::GetExtension($full) -ieq '.qcow2')
}
function script:Register-PveBuildFile {
param([string]$ContextPath,[string]$Path,[ValidateSet('Disk','Temporary')][string]$Kind='Temporary')
if (-not $ContextPath) { return }
$context=Get-PveBuildResources -ContextPath $ContextPath
$full=[IO.Path]::GetFullPath($Path)
if (@($context.Files|Where-Object Path -ieq $full).Count) { return }
$allowed=Test-PveBuildFilePath -Context $context -Path $full
if (-not $allowed) { throw 'Unrecognized temporary build file.' }
if (Test-Path -LiteralPath $full) { throw ('Resource already exists; do not adopt it: '+$full) }
$context.Files=@($context.Files)+[PSCustomObject]@{Path=$full;Kind=$Kind;ExistedBefore=$false}
Save-PveBuildResources -ContextPath $ContextPath -Context $context
}
function script:Register-PveBuildDirectory {
param([string]$ContextPath,[string]$Path)
if (-not $ContextPath) { return }
$context=Get-PveBuildResources -ContextPath $ContextPath
$full=[IO.Path]::GetFullPath($Path).TrimEnd('\')
if (@($context.Directories|Where-Object Path -ieq $full).Count) { return }
$work=[IO.Path]::GetFullPath((Join-Path $context.AssetsRoot 'work')).TrimEnd('\')+'\'
$logs=[IO.Path]::GetFullPath($context.LogDirectory).TrimEnd('\')+'\'
if (-not $full.StartsWith($work,[StringComparison]::OrdinalIgnoreCase) -and -not $full.StartsWith($logs,[StringComparison]::OrdinalIgnoreCase)) { throw 'Temporary directory escaped its build roots.' }
if (Test-Path -LiteralPath $full) { throw ('Resource already exists; do not adopt it: '+$full) }
$context.Directories=@($context.Directories)+[PSCustomObject]@{Path=$full;ExistedBefore=$false}
Save-PveBuildResources -ContextPath $ContextPath -Context $context
}
function script:Register-PveBuildVm {
param([string]$ContextPath,[string]$Name,[string]$VhdPath,[string]$Id)
if (-not $ContextPath) { return }
$context=Get-PveBuildResources -ContextPath $ContextPath
$disk=[IO.Path]::GetFullPath($VhdPath)
$ownedDisk=@($context.Files|Where-Object { $_.Kind -eq 'Disk' -and $_.Path -ieq $disk }).Count -gt 0
foreach ($directory in $context.Directories) { if ($disk.StartsWith($directory.Path.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)) { $ownedDisk=$true } }
if (-not $ownedDisk) { throw 'Cannot register a VM attached to an unowned disk.' }
$entry=@($context.VMs|Where-Object Name -eq $Name)
if (-not $entry.Count) {
if (Get-VM -Name $Name -ErrorAction SilentlyContinue) { throw 'Cannot adopt an existing VM.' }
$context.VMs=@($context.VMs)+[PSCustomObject]@{Name=$Name;Id=$Id;VhdPath=$disk;ExistedBefore=$false}
} else { if ($Id) { $entry[0].Id=$Id } }
Save-PveBuildResources -ContextPath $ContextPath -Context $context
}
function script:Get-PveOwnedVm {
param($Entry)
if ($Entry.ExistedBefore) { throw 'Refusing to modify an existing VM.' }
$vm=$null
if ($Entry.Id) { $vm=Get-VM -Id ([guid]$Entry.Id) -ErrorAction SilentlyContinue }
else { $vm=Get-VM -Name $Entry.Name -ErrorAction SilentlyContinue }
if (-not $vm) { return }
if ($vm.Name -ne $Entry.Name) { throw 'Recorded VM identity has changed.' }
$disks=@(Get-VMHardDiskDrive -VM $vm -ErrorAction Stop)
if ($disks.Count -ne 1 -or [IO.Path]::GetFullPath($disks[0].Path) -ine $Entry.VhdPath) { throw 'Recorded VM no longer has exactly its owned disk.' }
return $vm
}
function script:Stop-PveOwnedBuildVms {
param([string]$ContextPath,[switch]$Remove)
$context=Get-PveBuildResources -ContextPath $ContextPath
foreach ($entry in $context.VMs) {
$vm=Get-PveOwnedVm -Entry $entry
if ($vm) {
if ($vm.State -ne 'Off') { Stop-VM -VM $vm -TurnOff -Force -ErrorAction Stop }
if ($Remove) { Remove-VM -VM $vm -Force -ErrorAction Stop }
}
}
}
function script:Dismount-PveOwnedDisk {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return }
$disk=Get-VHD -Path $Path -ErrorAction Stop
if ($disk.Attached) {
$physical=@($disk | Get-Disk -ErrorAction Stop)
if ($physical.Count -ne 1 -or $physical[0].IsBoot -or $physical[0].IsSystem) { throw 'Refusing to detach a host system disk.' }
Dismount-VHD -Path $Path -ErrorAction Stop
}
}
function script:Remove-PveOwnedBuildResources {
param([string]$ContextPath)
$context=Get-PveBuildResources -ContextPath $ContextPath
if ($context.Completed) { throw 'A completed build must not be cleaned as cancelled.' }
if ([IO.Path]::GetFullPath((Split-Path -Parent $ContextPath)) -ine [IO.Path]::GetFullPath($context.LogDirectory)) { throw 'Resource context does not belong to its recorded log directory.' }
# Validate every deletion boundary before changing any VM or file.
foreach ($file in $context.Files) {
if ($file.ExistedBefore -or -not (Test-PveBuildFilePath -Context $context -Path $file.Path)) { throw 'Cleanup file is not an owned new build file.' }
}
foreach ($directory in $context.Directories) {
$full=[IO.Path]::GetFullPath($directory.Path).TrimEnd('\')
$work=[IO.Path]::GetFullPath((Join-Path $context.AssetsRoot 'work')).TrimEnd('\')+'\'
$logs=[IO.Path]::GetFullPath($context.LogDirectory).TrimEnd('\')+'\'
if ($directory.ExistedBefore -or (-not $full.StartsWith($work,[StringComparison]::OrdinalIgnoreCase) -and -not $full.StartsWith($logs,[StringComparison]::OrdinalIgnoreCase))) { throw 'Cleanup directory is not an owned run directory.' }
}
# Stop/delete identified VMs first, so their disks cannot still be written.
Stop-PveOwnedBuildVms -ContextPath $ContextPath -Remove
foreach ($iso in $context.SourceIsos) {
if (-not $iso.AttachedBefore) {
$image=Get-DiskImage -ImagePath $iso.Path -ErrorAction Stop
if ($image.Attached) { Dismount-DiskImage -ImagePath $iso.Path -ErrorAction Stop | Out-Null }
}
}
foreach ($directory in $context.Directories) {
if ($directory.ExistedBefore) { throw 'Refusing to remove an existing directory.' }
$full=[IO.Path]::GetFullPath($directory.Path).TrimEnd('\')
$work=[IO.Path]::GetFullPath((Join-Path $context.AssetsRoot 'work')).TrimEnd('\')+'\'
$logs=[IO.Path]::GetFullPath($context.LogDirectory).TrimEnd('\')+'\'
if (-not $full.StartsWith($work,[StringComparison]::OrdinalIgnoreCase) -and -not $full.StartsWith($logs,[StringComparison]::OrdinalIgnoreCase)) { throw 'Cleanup directory escaped its build roots.' }
if (-not (Test-Path -LiteralPath $full -PathType Container)) { continue }
$item=Get-Item -LiteralPath $full -Force
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Refusing to clean a linked directory.' }
$children=@(Get-ChildItem -LiteralPath $full -Recurse -Force)
if (@($children|Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }).Count) { throw 'Refusing to clean a directory containing links.' }
foreach ($file in @($children|Where-Object { -not $_.PSIsContainer })) {
if ($file.Extension -ieq '.vhdx') { Dismount-PveOwnedDisk -Path $file.FullName }
elseif ($file.Extension -ieq '.iso') {
$image=Get-DiskImage -ImagePath $file.FullName -ErrorAction Stop
if ($image.Attached) { Dismount-DiskImage -ImagePath $file.FullName -ErrorAction Stop | Out-Null }
}
}
$resolved=(Resolve-Path -LiteralPath $full).ProviderPath
Assert-PveResourcePath -Path $resolved -Root $full | Out-Null
Remove-Item -LiteralPath $resolved -Recurse -Force -ErrorAction Stop
}
foreach ($file in $context.Files) {
if ($file.ExistedBefore) { throw 'Refusing to remove an existing file.' }
$path=[IO.Path]::GetFullPath($file.Path)
$allowed=Test-PveBuildFilePath -Context $context -Path $path
if (-not $allowed) { throw 'Cleanup file escaped its output paths.' }
if (Test-Path -LiteralPath $path -PathType Leaf) {
if ((Get-Item -LiteralPath $path -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Refusing to remove a linked file.' }
if ($file.Kind -eq 'Disk') { Dismount-PveOwnedDisk -Path $path }
Remove-Item -LiteralPath $path -Force -ErrorAction Stop
}
}
$context.Cleanup='complete'
Save-PveBuildResources -ContextPath $ContextPath -Context $context
}
+88
View File
@@ -0,0 +1,88 @@
function Get-ImageServicingTools {
$adk = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64'
$result = [ordered]@{}
foreach ($entry in @(@{Name='Dism';Relative='DISM\dism.exe';System='dism.exe'},@{Name='Bcdboot';Relative='BCDBoot\bcdboot.exe';System='bcdboot.exe'})) {
$candidates = @((Join-Path $env:windir ('System32\'+$entry.System)),(Join-Path $adk $entry.Relative)) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
$selected = $candidates | Sort-Object { [version]([regex]::Match((Get-Item -LiteralPath $_).VersionInfo.FileVersion,'\d+\.\d+\.\d+\.\d+').Value) } -Descending | Select-Object -First 1
if (-not $selected) { throw ('Missing servicing tool: ' + $entry.Name) }
$result[$entry.Name+'Path'] = $selected
$result[$entry.Name+'Version'] = (Get-Item -LiteralPath $selected).VersionInfo.FileVersion
}
[PSCustomObject]$result
}
function Assert-ImageBuildInputs {
param($Settings,$Config,[switch]$CheckHost)
. (Join-Path $ProjectRoot 'scripts\Configuration.Common.ps1')
. (Join-Path $ProjectRoot 'scripts\Software.Selection.ps1')
. (Join-Path $ProjectRoot 'scripts\Builder.Workflow.ps1')
$hostInfo = $null
if ($CheckHost) { $hostInfo = Get-BuilderHost }
Assert-BuilderFile -Path $Config.wim_file_path -Extensions @('.iso','.wim')
Assert-BuilderFile -Path $Config.virtio_iso_path -Extensions @('.iso')
Assert-BuilderOutput -Path $Config.image_path
if ($Config.virtual_disk_format -ne 'QCOW2' -or $Config.image_type -ne 'KVM' -or $Config.disk_layout -ne 'UEFI') { throw 'This project requires KVM / QCOW2 / UEFI.' }
if (-not (Get-BuilderToolStatus -Settings $Settings)) { throw 'Prepare the pinned QEMU tool with Install-QemuImageTool.ps1.' }
$project = Get-BuildDependencies
$revision = (& $Settings.GitPath -C $Settings.UpstreamRoot rev-parse HEAD) -join ''
if ($LASTEXITCODE -ne 0 -or $revision -ne $project.UpstreamCommit) { throw 'Upstream revision does not match the pinned project version.' }
$selection = Get-GuestSoftwareSelection -ResourceRoot $Config.custom_resources_path
if ($selection.PSObject.Properties['ProductKeyMode']) {
$selectedKey=Resolve-PveProductKey -ImageName $Config.image_name -Mode $selection.ProductKeyMode -ProductKey ([string]$Config.product_key)
if ($selection.ProductKeyMode -ne 'Legacy' -and $selectedKey -cne ([string]$Config.product_key).Trim().ToUpperInvariant()) { throw 'Product key config does not match its selection mode.' }
}
$servicingIso=$null
$isoProperty=$selection.PSObject.Properties['ServicingIsoPath']
if($isoProperty){$servicingIso=$isoProperty.Value}
if(-not$servicingIso -and [IO.Path]::GetExtension($Config.wim_file_path) -ieq '.iso'){$servicingIso=$Config.wim_file_path}
foreach ($package in @(Get-SelectedImagePackages -SkipChrome:(-not $selection.InstallChrome) -SkipVSCode:(-not $selection.InstallVSCode))) {
Assert-ImagePackage -Path (Join-Path $Config.custom_resources_path $package.File) -Package $package
}
if ($CheckHost) {
if(-not$servicingIso -or -not(Test-Path -LiteralPath $servicingIso -PathType Leaf)){throw 'Provide a matching Windows installer ISO with New-ImageConfig.ps1 -ServicingIsoPath.'}
if ($Config.external_switch -notin @($hostInfo.Switches | ForEach-Object Name)) { throw 'Select an existing external Hyper-V switch.' }
Assert-BuilderCapacity -CpuCount $Config.cpu_count -RamBytes $Config.ram_size -HostCpuCount $hostInfo.LogicalProcessors -HostRamBytes ([long]$hostInfo.MemoryGiB*1GB)
Assert-BuilderDiskSpace -OutputPath $Config.image_path -DiskGiB ([int]($Config.disk_size/1GB))
$oldWhatIf = $WhatIfPreference
try { $WhatIfPreference=$false; $images=@(Get-BuilderSourceImages -SourcePath $Config.wim_file_path) }
finally { $WhatIfPreference=$oldWhatIf }
$edition=@($images | Where-Object ImageName -eq $Config.image_name)
if ($edition.Count -ne 1) { throw 'Selected Windows edition was not found in the installation source.' }
return [PSCustomObject]@{ImageVersion=$edition[0].ImageVersion.ToString();ImageName=$edition[0].ImageName;Software=$selection;HostChecks='passed'}
}
return [PSCustomObject]@{Software=$selection;HostChecks='not_checked'}
}
function Get-OfflineBuildVerification {
param([string]$VhdPath,[string]$Directory)
New-Item -ItemType Directory -Path $Directory -Force | Out-Null
$mountedHere=$false
try {
if ((Get-VHD -Path $VhdPath).Attached) { throw 'Verification requires an offline build disk.' }
Mount-VHD -Path $VhdPath -ReadOnly | Out-Null
$mountedHere=$true
$volumes=@(Get-VHD -Path $VhdPath | Get-Disk | Get-Partition | Get-Volume | Where-Object { $_.DriveLetter -and (Test-Path -LiteralPath ($_.DriveLetter+':\Windows\System32\config\SOFTWARE')) })
if ($volumes.Count -ne 1) { throw 'Could not locate exactly one Windows partition for verification.' }
$drive=$volumes[0].DriveLetter+':\'
$proof=Join-Path $drive 'ProgramData\PveImageBuilder\build-verification.json'
if (-not(Test-Path -LiteralPath $proof)){throw 'The guest did not produce its required installation verification.'}
$verification=Get-Content -Raw -Encoding UTF8 -LiteralPath $proof | ConvertFrom-Json
Copy-Item -LiteralPath $proof -Destination (Join-Path $Directory 'guest-verification.json')
$verification | Add-Member -NotePropertyName ActivationReportPresentAtBuild -NotePropertyValue (Test-Path -LiteralPath (Join-Path $drive 'ProgramData\PveImageBuilder\windows-activation.json'))
$verification | Add-Member -NotePropertyName WindowsActivationScriptStaged -NotePropertyValue (Test-Path -LiteralPath (Join-Path $drive 'Program Files\Cloudbase Solutions\Cloudbase-Init\LocalScripts\30-WindowsActivation.ps1'))
$setupLog=Join-Path $drive 'Windows\System32\Sysprep\Panther\setupact.log'
if(Test-Path -LiteralPath $setupLog){Copy-Item -LiteralPath $setupLog -Destination (Join-Path $Directory 'sysprep-setupact.log')}
# Parse the copied hive as data; never mount or change host HKLM keys.
$hivePath=Join-Path $Directory 'SOFTWARE'
Copy-Item -LiteralPath (Join-Path $drive 'Windows\System32\config\SOFTWARE') -Destination $hivePath
foreach($suffix in @('.LOG1','.LOG2')){
$transaction=Join-Path $drive ('Windows\System32\config\SOFTWARE'+$suffix)
if(Test-Path -LiteralPath $transaction){Copy-Item -LiteralPath $transaction -Destination ($hivePath+$suffix)}
}
. (Join-Path $PSScriptRoot 'Offline.Registry.ps1')
$imageState=Get-OfflineImageState -HivePath $hivePath
if($imageState -ne 'IMAGE_STATE_GENERALIZE_RESEAL_TO_OOBE'){throw ('Sysprep did not generalize the image: '+$imageState)}
if(-not$verification.QemuGuestAgent -or -not$verification.CloudbaseInit){throw 'Guest verification is missing required services.'}
$verification|Add-Member -NotePropertyName SysprepImageState -NotePropertyValue $imageState -PassThru
} finally {
if($mountedHere){Dismount-VHD -Path $VhdPath}
}
}
+399
View File
@@ -0,0 +1,399 @@
function Read-BuilderText {
param([string]$Prompt, [string]$Default, [scriptblock]$Validate)
while ($true) {
$label = $Prompt
if ($Default) { $label += ' [' + $Default + ']' }
$value = (Read-Host ($label + ',输入 Q 取消')).Trim()
if ($value -ieq 'q') { throw [OperationCanceledException]::new('用户取消了向导。') }
if (-not $value) { $value = $Default }
if ($value.Length -ge 2 -and (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'")))) { $value = $value.Substring(1,$value.Length-2) }
try {
Assert-ConfigValue -Value $value -Name $Prompt
if ($Validate) { & $Validate $value | Out-Null }
return $value
} catch { Write-Host ('输入无效:' + $_.Exception.Message) -ForegroundColor Yellow }
}
}
function Get-BuilderDownloadLinks {
[PSCustomObject]@{
WindowsIso='https://massgrave.dev/genuine-installation-media'
VirtioIso='https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/latest-virtio/virtio-win.iso'
Chrome='https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi'
VSCode='https://update.code.visualstudio.com/latest/win32-x64/stable'
}
}
function Show-BuilderPreparationGuide {
$links=Get-BuilderDownloadLinks
Write-Host '开始前请准备以下资源:' -ForegroundColor Cyan
Write-Host ('Windows ISO 或 WIM 放入 '+(Join-Path $ProjectRoot 'data\iso')+',向导会列出文件供编号选择;也可手动指定路径。')
Write-Host ('Windows ISO 下载:'+$links.WindowsIso)
Write-Host ('VirtIO ISO 也放入 data\iso,guest-tools 会自动从 ISO 提取。最新下载:'+$links.VirtioIso)
Write-Host ('Chrome 和 VS Code 可选;安装包放入 data\custom-resources。Chrome x64 MSI:'+$links.Chrome)
Write-Host ('VS Code x64 系统安装包:'+$links.VSCode)
Write-Host 'Chrome、VS Code 验证有效厂商签名,不限制版本,下载后无需改名;VirtIO 只检查 guest-tools 文件名。'
Write-Host '没有 Hyper-V 外部交换机时会询问是否创建。回车使用默认值,输入 Q 取消。'
Write-Host ''
}
function Read-BuilderBoolean {
param([string]$Prompt, [bool]$Default=$true)
$defaultValue = 'Y'
if (-not $Default) { $defaultValue = 'N' }
$value = Read-BuilderText -Prompt ($Prompt + ' (Y/N)') -Default $defaultValue -Validate {
param($answer)
if ($answer -notin @('y','n','yes','no')) { throw '请输入 Y 或 N。' }
}
return $value -in @('y','yes')
}
function Read-BuilderNumber {
param([string]$Prompt, [int]$Default, [int]$Minimum, [int]$Maximum)
$value = Read-BuilderText -Prompt ($Prompt + " ($Minimum-$Maximum)") -Default ([string]$Default) -Validate {
param($answer)
$parsed = 0
if (-not [int]::TryParse($answer,[ref]$parsed) -or $parsed -lt $Minimum -or $parsed -gt $Maximum) { throw '数值超出范围。' }
}
return [int]$value
}
function Read-BuilderChoice {
param([string]$Prompt, [object[]]$Items, [scriptblock]$Label, [int]$DefaultIndex=0)
if (-not $Items.Count) { throw ('没有可选择的项目:' + $Prompt) }
Write-Host $Prompt -ForegroundColor Cyan
for ($index=0; $index -lt $Items.Count; $index++) {
$description = [string]$Items[$index]
if ($Label) { $description = & $Label $Items[$index] }
Write-Host (' {0}. {1}' -f ($index+1),$description)
}
$number = Read-BuilderNumber -Prompt '选择编号' -Default ($DefaultIndex+1) -Minimum 1 -Maximum $Items.Count
return $Items[$number-1]
}
function Read-BuilderProductKey {
param([string]$ImageName)
$matched=Get-PveKmsProductKey -ImageName $ImageName
$defaultIndex=0
$label='当前版本无预置 KMS 密钥(不可选)'
if ($matched) { $label=$matched+'(KMS密钥)' }
else { $defaultIndex=1 }
$items=@([PSCustomObject]@{Mode='Kms';Label=$label},[PSCustomObject]@{Mode='Custom';Label='自行填写/留空'})
while ($true) {
$choice=Read-BuilderChoice -Prompt '选择产品密钥' -Items $items -DefaultIndex $defaultIndex -Label {param($item) $item.Label}
if ($choice.Mode -eq 'Kms') {
if (-not $matched) { Write-Host '此版本没有匹配的 KMS 安装密钥,请选择第二项。' -ForegroundColor Yellow;continue }
return [PSCustomObject]@{Mode='Kms';Key=$matched;Description='使用当前版本匹配的 KMS 安装密钥'}
}
while ($true) {
$inputValue=Read-Host '填写产品密钥,直接回车留空,输入 Q 取消(输入隐藏)' -AsSecureString
$value=''
if ($inputValue -is [Security.SecureString]) {
$pointer=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($inputValue)
try { $value=[Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer);$inputValue.Dispose() }
} else { $value=[string]$inputValue }
$value=$value.Trim().ToUpperInvariant()
if ($value -eq 'Q') { throw [OperationCanceledException]::new('用户取消了向导。') }
if (-not $value) { return [PSCustomObject]@{Mode='None';Key='';Description='留空'} }
try { Assert-PveProductKey -ProductKey $value }
catch { Write-Host '密钥格式应为 XXXXX-XXXXX-XXXXX-XXXXX-XXXXX,请重新填写。' -ForegroundColor Yellow;continue }
return [PSCustomObject]@{Mode='Custom';Key=$value;Description='自行填写(不显示密钥)'}
}
}
}
function Read-BuilderKmsServer {
$default=Get-PveDefaultKmsServer
$choice=Read-BuilderChoice -Prompt '选择 KMS 地址' -Items @($default,'自定义输入/留空')
if ($choice -eq $default) { return [PSCustomObject]@{Address=$default;Description=$default} }
while ($true) {
$value=(Read-Host '填写 KMS 地址(域名或 IP,可加 :端口),直接回车留空,输入 Q 取消').Trim()
if ($value -ieq 'q') { throw [OperationCanceledException]::new('用户取消了向导。') }
try { $endpoint=ConvertTo-PveKmsEndpoint -Address $value }
catch { Write-Host ('输入无效:'+$_.Exception.Message) -ForegroundColor Yellow;continue }
$description=$endpoint.Address
if (-not $description) { $description='留空(不设置 KMS 地址)' }
return [PSCustomObject]@{Address=$endpoint.Address;Description=$description}
}
}
function Assert-BuilderFile {
param([string]$Path, [string[]]$Extensions)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw '文件不存在。' }
if ($Extensions -and [IO.Path]::GetExtension($Path).ToLowerInvariant() -notin $Extensions) { throw ('支持的文件类型:' + ($Extensions -join ', ')) }
}
function Get-BuilderSourceCandidates {
param([string]$AssetsRoot, [string]$SourcePath)
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($directory in @((Join-Path $ProjectRoot 'data\iso'),(Join-Path $AssetsRoot 'iso'))) {
if (-not (Test-Path -LiteralPath $directory -PathType Container)) { continue }
foreach ($file in @(Get-ChildItem -LiteralPath $directory -File | Where-Object {
$_.Extension.ToLowerInvariant() -in @('.iso','.wim') -and $_.Name -notlike '*virtio*'
} | Sort-Object Name)) {
if ($seen.Add($file.FullName)) {
$label = $file.FullName
if ([IO.Path]::GetFullPath($directory) -ieq [IO.Path]::GetFullPath((Join-Path $ProjectRoot 'data\iso'))) { $label = 'data\iso\' + $file.Name }
[PSCustomObject]@{Path=$file.FullName;Label=$label}
}
}
}
if ($SourcePath) {
try {
Assert-BuilderFile -Path $SourcePath -Extensions @('.iso','.wim')
$fullPath = [IO.Path]::GetFullPath($SourcePath)
if ($seen.Add($fullPath)) { [PSCustomObject]@{Path=$fullPath;Label=$fullPath} }
} catch {}
}
}
function Read-BuilderInstallationSource {
param([string]$AssetsRoot, [string]$SourcePath)
while ($true) {
$items = @(Get-BuilderSourceCandidates -AssetsRoot $AssetsRoot -SourcePath $SourcePath)
$defaultIndex = 0
if ($SourcePath) {
$defaultIndex = $items.Count
try {
$fullPath = [IO.Path]::GetFullPath($SourcePath)
for ($index=0; $index -lt $items.Count; $index++) { if ($items[$index].Path -ieq $fullPath) { $defaultIndex=$index; break } }
} catch {}
}
if (-not $items.Count) { Write-Host '未发现 Windows ISO 或 WIM,可手动指定路径,或先将文件放入 data\iso。' -ForegroundColor Yellow }
$items += [PSCustomObject]@{Path=$null;Label='手动输入其他 ISO 或 install.wim 路径'}
$chosen = Read-BuilderChoice -Prompt '选择 Windows 安装源(优先列出 data\iso)' -Items $items -DefaultIndex $defaultIndex -Label { param($item) $item.Label }
if (-not $chosen.Path) {
$path = Read-BuilderText -Prompt 'Windows ISO 或 install.wim 完整路径' -Default $SourcePath -Validate { param($candidate) Assert-BuilderFile -Path $candidate -Extensions @('.iso','.wim') }
return [IO.Path]::GetFullPath($path)
}
try { Assert-BuilderFile -Path $chosen.Path -Extensions @('.iso','.wim'); return $chosen.Path }
catch { Write-Host ('安装源不可用:' + $_.Exception.Message + ',请重新选择。') -ForegroundColor Yellow; $SourcePath=$null }
}
}
function Get-BuilderSourceImages {
param([string]$SourcePath)
$source = [IO.Path]::GetFullPath($SourcePath)
$mountedHere = $false
try {
$wim = $source
if ([IO.Path]::GetExtension($source) -ieq '.iso') {
$diskImage = Get-DiskImage -ImagePath $source
if (-not $diskImage.Attached) {
Mount-DiskImage -ImagePath $source -StorageType ISO | Out-Null
$mountedHere = $true
}
$volumes = @(Get-DiskImage -ImagePath $source | Get-Volume | Where-Object DriveLetter)
if ($volumes.Count -ne 1) { throw '无法确定 ISO 盘符。' }
$wim = $volumes[0].DriveLetter + ':\sources\install.wim'
if (-not (Test-Path -LiteralPath $wim -PathType Leaf)) { throw 'ISO 中没有 sources\install.wim。install.esd 请先导出为 WIM。' }
}
$images = @(Get-WimFileImagesInfo -WimFilePath $wim)
if (-not $images.Count) { throw '安装源没有可读取的 Windows 版本。' }
return $images
} finally {
if ($mountedHere) { Dismount-DiskImage -ImagePath $source | Out-Null }
}
}
function Get-BuilderAdk {
$root = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64'
$oscdimg = Join-Path $root 'Oscdimg\oscdimg.exe'
if (-not (Test-Path -LiteralPath $oscdimg -PathType Leaf)) {
throw '未找到 Windows ADK 的 Deployment Tools(oscdimg.exe)。请运行 ADK 安装程序,勾选 Deployment Tools(部署工具)后重新运行向导。下载:https://learn.microsoft.com/windows-hardware/get-started/adk-install'
}
return [PSCustomObject]@{OscdimgPath=$oscdimg;OscdimgVersion=(Get-Item -LiteralPath $oscdimg).VersionInfo.FileVersion}
}
function Get-BuilderHost {
param([switch]$AllowMissingExternalSwitch)
Write-Host '正在检查构建环境……' -ForegroundColor Cyan
try { Assert-PowerShell51 } catch { throw '请使用 Windows PowerShell 5.1(powershell.exe)运行向导。' }
try { Assert-Administrator } catch { throw '当前没有管理员权限,请以管理员身份打开 Windows PowerShell 后重新运行。' }
if (-not (Get-Module -ListAvailable -Name Hyper-V)) {
throw '未找到 Hyper-V 管理模块。请在支持 Hyper-V 的 Windows 上启用 Hyper-V 平台和管理工具,并重启电脑。安装说明:https://learn.microsoft.com/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v'
}
try { Import-Module Hyper-V -ErrorAction Stop }
catch { throw ('无法加载 Hyper-V 管理模块,请检查 Hyper-V 管理工具是否完整安装。'+$_.Exception.Message) }
$service = Get-Service -Name vmms -ErrorAction SilentlyContinue
if (-not $service -or $service.Status -ne 'Running') {
throw 'Hyper-V 虚拟机管理服务(vmms)未运行。请启用 Hyper-V 平台并重启电脑;已安装时请检查该服务。'
}
$system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
if (-not $system.HypervisorPresent) {
throw 'Hyper-V 虚拟机监控程序未运行。请确认已启用 Hyper-V、BIOS/UEFI 中的硬件虚拟化,并在启用后重启电脑。'
}
try {
Get-VMHost -ErrorAction Stop | Out-Null
$switches = @(Get-VMSwitch -ErrorAction Stop | Where-Object SwitchType -eq 'External')
} catch { throw ('无法管理 Hyper-V,请检查当前账号的管理员或 Hyper-V 管理员权限,以及 vmms 服务。'+$_.Exception.Message) }
$adk = Get-BuilderAdk
Write-Host ('环境检查通过:Windows PowerShell 5.1、管理员权限、Hyper-V、ADK Deployment Tools(oscdimg {0})。' -f $adk.OscdimgVersion) -ForegroundColor Green
if (-not $switches.Count -and -not $AllowMissingExternalSwitch) { throw '没有可用的 Hyper-V 外部交换机,请运行 Start-ImageBuilder.ps1 按提示创建。' }
return [PSCustomObject]@{Switches=$switches;LogicalProcessors=[int]$system.NumberOfLogicalProcessors;MemoryGiB=[int][Math]::Floor($system.TotalPhysicalMemory/1GB);Adk=$adk}
}
function Initialize-BuilderExternalSwitches {
param([object[]]$Switches)
if($Switches.Count){return $Switches}
Write-Host '未找到 Hyper-V 外部交换机。' -ForegroundColor Yellow
if(-not(Read-BuilderBoolean -Prompt '是否创建一个外部交换机' -Default $false)){
throw [OperationCanceledException]::new('未创建外部交换机,向导已停止。')
}
$adapters=@(Get-NetAdapter -Physical -ErrorAction Stop|Where-Object Status -eq 'Up'|Sort-Object Name)
if(-not$adapters.Count){throw '没有已连接的物理网卡,请连接网络后重新运行向导。'}
$adapter=Read-BuilderChoice -Prompt '选择外部交换机使用的物理网卡' -Items $adapters -Label {param($item) '{0} — {1}({2})' -f $item.Name,$item.InterfaceDescription,$item.LinkSpeed}
$name=Read-BuilderText -Prompt '外部交换机名称' -Default 'external' -Validate {
param($candidate)
if(Get-VMSwitch -Name $candidate -ErrorAction SilentlyContinue){throw '此名称已被其他交换机使用,请换一个名称。'}
}
Write-Host ('将在网卡“{0}”上创建“{1}”,并允许主机共享该网卡。创建时主机网络可能短暂中断。' -f $adapter.Name,$name) -ForegroundColor Yellow
if(-not(Read-BuilderBoolean -Prompt '确认创建' -Default $false)){
throw [OperationCanceledException]::new('已取消创建外部交换机。')
}
try{
$created=New-VMSwitch -Name $name -NetAdapterName $adapter.Name -AllowManagementOS $true -ErrorAction Stop
if($created.SwitchType -ne 'External'){throw '创建结果不是外部交换机。'}
}catch{throw ('创建外部交换机失败:'+$_.Exception.Message+'。请检查所选网卡和 Hyper-V 网络配置。')}
Write-Host ('已创建外部交换机:'+$name) -ForegroundColor Green
return $created
}
function Assert-BuilderCapacity {
param([int]$CpuCount,[long]$RamBytes,[int]$HostCpuCount,[long]$HostRamBytes)
if($CpuCount -lt 1 -or $CpuCount -gt $HostCpuCount -or $RamBytes -lt 2GB -or $RamBytes -gt $HostRamBytes){throw 'Configured CPU or memory exceeds this build host.'}
}
function Get-BuilderToolStatus {
param($Settings)
if (-not (Test-Path -LiteralPath $Settings.QemuManifestPath -PathType Leaf)) { return $false }
try {
$tool = Get-Content -Raw -Encoding UTF8 -LiteralPath $Settings.QemuManifestPath | ConvertFrom-Json
$project = Get-BuildDependencies
return $tool.Version -eq $project.Qemu.Version -and $tool.PublishedHashVerified -and (Test-Path -LiteralPath $tool.ExecutablePath -PathType Leaf) -and (Get-FileHash -Algorithm SHA256 -LiteralPath $tool.ExecutablePath).Hash -eq $project.Qemu.ExecutableSHA256
} catch { return $false }
}
function Assert-BuilderOutput {
param([string]$Path)
$full = [IO.Path]::GetFullPath($Path)
if ([IO.Path]::GetExtension($full) -ine '.qcow2') { throw '输出文件必须以 .qcow2 结尾。' }
if (Test-Path -LiteralPath $full) { throw '输出已经存在,请选择新文件名。' }
if (Test-Path -LiteralPath ([IO.Path]::ChangeExtension($full,'.vhdx'))) { throw '对应的临时 VHDX 已经存在,请选择新文件名。' }
if (Test-Path -LiteralPath ([IO.Path]::ChangeExtension($full,'.raw'))) { throw '对应的临时 RAW 已经存在,请选择新文件名。' }
}
function Assert-BuilderDiskSpace {
param([string]$OutputPath, [int]$DiskGiB)
$root = [IO.Path]::GetPathRoot([IO.Path]::GetFullPath($OutputPath))
if ($root -notmatch '^[A-Za-z]:\\$') { throw '请使用本机磁盘上的输出路径。' }
$drive = Get-PSDrive -Name $root.Substring(0,1) -PSProvider FileSystem
$required = ([long]$DiskGiB*2+20)*1GB
if ($drive.Free -lt $required) { throw ('空间不足:建议至少 {0} GiB 可用空间(系统盘、转换和启动验收副本)。' -f ($required/1GB)) }
}
function Initialize-BuilderProject {
param([string]$AssetsRoot)
& (Join-Path $ProjectRoot 'Initialize-Project.ps1') -AssetsRoot $AssetsRoot | Out-Null
}
function New-BuilderConfiguration {
param([hashtable]$Parameters)
& (Join-Path $ProjectRoot 'New-ImageConfig.ps1') @Parameters
}
function Invoke-BuilderAction {
param([string]$Action, [string]$ConfigPath)
if ($Action -eq '预检') { & (Join-Path $ProjectRoot 'Build-Image.ps1') -ConfigPath $ConfigPath -WhatIf }
elseif ($Action -eq '开始构建') { & (Join-Path $ProjectRoot 'Build-Image.ps1') -ConfigPath $ConfigPath }
}
function Initialize-BuilderTools {
param($Settings)
if (Get-BuilderToolStatus -Settings $Settings) { return }
Write-Host '尚未准备或未通过校验的固定版本 qemu-img。' -ForegroundColor Yellow
if (-not (Read-BuilderBoolean -Prompt '是否立刻下载 QEMU 与 7-Zip?' -Default $true)) { throw '请先运行 Install-QemuImageTool.ps1 准备工具。' }
& (Join-Path $ProjectRoot 'Install-QemuImageTool.ps1') | Out-Host
}
function Initialize-BuilderPackages {
param($Settings, [string]$VirtioIsoPath, [switch]$SkipChrome, [switch]$SkipVSCode)
foreach ($package in @(Get-SelectedImagePackages -SkipChrome:$SkipChrome -SkipVSCode:$SkipVSCode)) {
if($package.File -eq 'virtio-win-guest-tools.exe'){
Initialize-VirtioGuestTools -Settings $Settings -VirtioIsoPath $VirtioIsoPath|Out-Null
continue
}
$destination = Join-Path $Settings.AssetsRoot ('custom-resources\' + $package.File)
$source = Find-ImagePackageInstaller -Package $package -Directories @((Join-Path $ProjectRoot 'data\custom-resources'),(Join-Path $Settings.AssetsRoot 'custom-resources'))
if ($source) {
if ([IO.Path]::GetFullPath($source) -ine [IO.Path]::GetFullPath($destination)) { Copy-Item -LiteralPath $source -Destination $destination -Force }
continue
}
Write-Host ('需要 {0} 安装包,验证厂商签名,无需改名。' -f $package.Name) -ForegroundColor Yellow
$links=Get-BuilderDownloadLinks
if($package.File -eq 'chrome-enterprise64.msi'){Write-Host ('Chrome Enterprise x64 MSI 下载:'+$links.Chrome)}
elseif($package.File -like 'VSCodeSetup-*'){Write-Host ('VS Code x64 系统安装包下载:'+$links.VSCode)}
$source = Read-BuilderText -Prompt '安装包完整路径(可选择 data\custom-resources 中的文件)' -Validate {
param($candidate)
Assert-ImagePackage -Path $candidate -Package $package
}
if ([IO.Path]::GetFullPath($source) -ine [IO.Path]::GetFullPath($destination)) { Copy-Item -LiteralPath $source -Destination $destination -Force }
}
}
function Invoke-ImageBuilderWizard {
param([string]$AssetsRoot, [string]$SourcePath, [switch]$ConfigureOnly)
Write-Host 'Windows / Proxmox 镜像构建向导' -ForegroundColor Cyan
Write-Host '各步骤可输入 Q 取消;不会覆盖已有成品。请使用管理员 Windows PowerShell 5.1。'
$hostInfo = Get-BuilderHost -AllowMissingExternalSwitch
$hostInfo.Switches=@(Initialize-BuilderExternalSwitches -Switches $hostInfo.Switches)
$defaultAssets = Join-Path $ProjectRoot 'data'
if (Test-Path -LiteralPath (Join-Path $ProjectRoot 'local\settings.json')) { $defaultAssets = (Get-ProjectSettings).AssetsRoot }
if ($AssetsRoot) { $defaultAssets = $AssetsRoot }
$assets = Read-BuilderText -Prompt '资源目录' -Default $defaultAssets -Validate {
param($path)
[IO.Path]::GetFullPath($path) | Out-Null
if (Test-Path -LiteralPath $path -PathType Leaf) { throw '资源目录不能是文件。' }
}
Initialize-BuilderProject -AssetsRoot $assets
$settings = Get-ProjectSettings
Import-Module (Join-Path $settings.UpstreamRoot 'WinImageBuilder.psm1') -Force
while ($true) {
$source = Read-BuilderInstallationSource -AssetsRoot $settings.AssetsRoot -SourcePath $SourcePath
try { $images = @(Get-BuilderSourceImages -SourcePath $source); break }
catch { Write-Host ('无法读取安装源:' + $_.Exception.Message) -ForegroundColor Yellow; $SourcePath = $null }
}
$preferred = 0
for ($index=0; $index -lt $images.Count; $index++) { if ($images[$index].ImageName -eq 'Windows 11 Pro for Workstations') { $preferred = $index; break } }
$image = Read-BuilderChoice -Prompt '选择 Windows 版本' -Items $images -DefaultIndex $preferred -Label { param($item) '{0}(索引 {1},版本 {2})' -f $item.ImageName,$item.ImageIndex,$item.ImageVersion }
$keySelection=Read-BuilderProductKey -ImageName $image.ImageName
$kmsSelection=Read-BuilderKmsServer
$servicingIso=$source
if([IO.Path]::GetExtension($source) -ieq '.wim'){
$defaultIso=Find-WindowsInstallerIso -ProjectRoot $ProjectRoot -AssetsRoot $settings.AssetsRoot
$servicingIso=Read-BuilderText -Prompt '提供匹配的 Windows 安装 ISO(用于 Windows PE 离线 servicing)' -Default $defaultIso -Validate {param($path) Assert-BuilderFile -Path $path -Extensions @('.iso')}
}
$defaultVirtio=Find-VirtioInstallerIso -ProjectRoot $ProjectRoot -AssetsRoot $settings.AssetsRoot
if(-not$defaultVirtio){$defaultVirtio=Join-Path $settings.AssetsRoot 'iso\virtio-win.iso'}
while($true){
$virtio = Read-BuilderText -Prompt 'VirtIO ISO 路径(支持 data\iso,自动提取 guest-tools)' -Default $defaultVirtio -Validate { param($path) Assert-BuilderFile -Path $path -Extensions @('.iso') }
try{
Initialize-VirtioGuestTools -Settings $settings -VirtioIsoPath $virtio -RefreshFromIso|Out-Null
Write-Host '已从 ISO 提取 virtio-win-guest-tools.exe,并确认文件名。' -ForegroundColor Green
break
}catch{Write-Host ('VirtIO 资源准备失败:'+$_.Exception.Message+'。请选择包含 virtio-win-guest-tools.exe 的 ISO。') -ForegroundColor Yellow;$defaultVirtio=$null}
}
$preferredSwitch = 0
for ($index=0; $index -lt $hostInfo.Switches.Count; $index++) { if ($hostInfo.Switches[$index].Name -eq 'external') { $preferredSwitch = $index; break } }
$switch = Read-BuilderChoice -Prompt '选择 Hyper-V 外部交换机' -Items $hostInfo.Switches -DefaultIndex $preferredSwitch -Label { param($item) $item.Name }
$defaultOutput = Join-Path $settings.AssetsRoot ('output\' + (Get-DefaultImageFileName -ImageName $image.ImageName))
$output = Read-BuilderText -Prompt '新的 QCOW2 输出路径' -Default $defaultOutput -Validate { param($path) Assert-BuilderOutput -Path $path }
$cpu = Read-BuilderNumber -Prompt 'CPU 核数' -Default ([Math]::Min(4,$hostInfo.LogicalProcessors)) -Minimum 1 -Maximum $hostInfo.LogicalProcessors
$maxRam = [Math]::Max(2,[Math]::Floor($hostInfo.MemoryGiB*0.75))
$ram = Read-BuilderNumber -Prompt '内存 GiB' -Default ([Math]::Min(8,$maxRam)) -Minimum 2 -Maximum $maxRam
$disk = Read-BuilderNumber -Prompt '系统盘 GiB' -Default 128 -Minimum 64 -Maximum 65536
while ($true) {
try { Assert-BuilderDiskSpace -OutputPath $output -DiskGiB $disk; break }
catch { Write-Host $_.Exception.Message -ForegroundColor Yellow; $output = Read-BuilderText -Prompt '空间足够的新的输出路径' -Validate { param($path) Assert-BuilderOutput -Path $path } }
}
Write-Host '查看全部 Windows 时区(PowerShell):Get-TimeZone -ListAvailable | Format-Table Id, DisplayName -AutoSize'
Write-Host '填写列表中的 Id 列,例如纽约时间:Eastern Standard Time(自动处理夏令时);也可运行 tzutil /l 查看。'
$timeZone = Read-BuilderText -Prompt 'Windows 时区 ID' -Default 'Eastern Standard Time' -Validate { param($id) [TimeZoneInfo]::FindSystemTimeZoneById($id) | Out-Null }
$updates = Read-BuilderBoolean -Prompt '安装 Windows Update(会增加构建时间)' -Default $false
$chrome = Read-BuilderBoolean -Prompt '安装 Chrome' -Default $true
$vscode = Read-BuilderBoolean -Prompt '安装 VS Code' -Default $true
Initialize-BuilderPackages -Settings $settings -VirtioIsoPath $virtio -SkipChrome:(-not $chrome) -SkipVSCode:(-not $vscode)
Write-Host ''
Write-Host '配置摘要' -ForegroundColor Cyan
[PSCustomObject]@{安装源=$source;Windows版本=$image.ImageName;产品密钥=$keySelection.Description;KMS地址=$kmsSelection.Description;输出=$output;外部交换机=$switch.Name;CPU=$cpu;内存GiB=$ram;系统盘GiB=$disk;时区=$timeZone;WindowsUpdate=$updates;Chrome=$chrome;VSCode=$vscode} | Format-List | Out-Host
Write-Host 'UEFI / Secure Boot;VirtIO、QGA、Cloudbase-Init 必装。'
Write-Host '内置 Administrator,PVE 注入密码后禁用额外 Admin;临时构建密码自动生成且不显示。'
Write-Host 'RDP 开启且保留 NLA;客户机三类防火墙关闭;Defender 设置不改动。'
$action = '仅保存配置'
if (-not $ConfigureOnly) { $action = Read-BuilderChoice -Prompt '下一步' -Items @('开始构建','预检','仅保存配置') }
if ($action -ne '仅保存配置') { Initialize-BuilderTools -Settings $settings }
$configurationId=(Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N').Substring(0,8)
$configurationPath=Join-Path $ProjectRoot ('local\configs\image-'+$configurationId+'.ini')
$parameters = @{WimPath=$source;ServicingIsoPath=$servicingIso;ImageName=$image.ImageName;OutputPath=$output;VirtioIsoPath=$virtio;SwitchName=$switch.Name;CpuCount=$cpu;RamGiB=$ram;DiskGiB=$disk;TimeZone=$timeZone;InstallUpdates=$updates;SkipChrome=(-not $chrome);SkipVSCode=(-not $vscode);ConfigPath=$configurationPath;ProductKey=$keySelection.Key;ProductKeyMode=$keySelection.Mode;KmsServer=$kmsSelection.Address}
$result = New-BuilderConfiguration -Parameters $parameters
Write-Host ('已生成配置:' + $result.ConfigPath) -ForegroundColor Green
if ($action -ne '仅保存配置') { Invoke-BuilderAction -Action $action -ConfigPath $result.ConfigPath | Out-Host }
$result | Add-Member -NotePropertyName Action -NotePropertyValue $action -PassThru
}
+77
View File
@@ -0,0 +1,77 @@
function script:Get-PveCloudbaseInitPackage {
param([ValidateSet('AMD64','x64','i386','x86')][string]$OsArch='AMD64',[switch]$BetaRelease)
$architecture='x64'
if ($OsArch -in @('i386','x86')) { $architecture='x86' }
$suffix='_Stable'
if ($BetaRelease) { $suffix='' }
$name='CloudbaseInitSetup'+$suffix+'_'+$architecture+'.msi'
[PSCustomObject]@{Name=$name;Architecture=$architecture;Url=('https://www.cloudbase.it/downloads/'+$name)}
}
function script:Get-PveCloudbaseInitCachePath {
param([Parameter(Mandatory=$true)][string]$AssetsRoot,[string]$OsArch='AMD64',[switch]$BetaRelease)
$package=Get-PveCloudbaseInitPackage -OsArch $OsArch -BetaRelease:$BetaRelease
[IO.Path]::GetFullPath((Join-Path $AssetsRoot ('assets\'+$package.Name)))
}
function script:Assert-PveCloudbaseInitInstaller {
param([Parameter(Mandatory=$true)][string]$Path,[string]$OsArch='AMD64')
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw ('Cloudbase-Init installer is missing: '+$Path) }
$installer=$null;$database=$null;$view=$null;$record=$null;$summary=$null
try {
$installer=New-Object -ComObject WindowsInstaller.Installer
# Open the MSI read-only; this does not install software on the build host.
$database=$installer.GetType().InvokeMember('OpenDatabase','InvokeMethod',$null,$installer,@([IO.Path]::GetFullPath($Path),0))
$view=$database.GetType().InvokeMember('OpenView','InvokeMethod',$null,$database,@('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductName'''))
$view.GetType().InvokeMember('Execute','InvokeMethod',$null,$view,$null) | Out-Null
$record=$view.GetType().InvokeMember('Fetch','InvokeMethod',$null,$view,$null)
if (-not $record) { throw 'MSI ProductName is missing.' }
$product=$record.GetType().InvokeMember('StringData','GetProperty',$null,$record,@(1))
if ($product -notmatch '^Cloudbase-Init(?: \d[\w.+-]*)?$') { throw ('Unexpected MSI product: '+$product) }
$summary=$database.GetType().InvokeMember('SummaryInformation','GetProperty',$null,$database,@(0))
$platform=($summary.GetType().InvokeMember('Property','GetProperty',$null,$summary,@(7)) -split ';')[0]
$expected='x64'
if ((Get-PveCloudbaseInitPackage -OsArch $OsArch).Architecture -eq 'x86') { $expected='Intel' }
if ($platform -ine $expected) { throw ('Incorrect MSI architecture: '+$platform+'; expected '+$expected) }
} catch { throw ('Cloudbase-Init installer is not a valid matching MSI: '+$Path+'. '+$_.Exception.Message) }
finally {
foreach ($comObject in @($summary,$record,$view,$database,$installer)) {
if ($null -ne $comObject) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($comObject) | Out-Null }
}
}
}
function script:Invoke-PveCloudbaseInitDownload {
param([string]$Uri,[string]$Destination)
$previousProtocol=[Net.ServicePointManager]::SecurityProtocol
try {
[Net.ServicePointManager]::SecurityProtocol=$previousProtocol -bor [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $Uri -OutFile $Destination -UseBasicParsing -TimeoutSec 300 -ErrorAction Stop
} finally { [Net.ServicePointManager]::SecurityProtocol=$previousProtocol }
}
function script:Initialize-PveCloudbaseInitInstaller {
param([Parameter(Mandatory=$true)][string]$AssetsRoot,[string]$OsArch='AMD64',[switch]$BetaRelease)
$package=Get-PveCloudbaseInitPackage -OsArch $OsArch -BetaRelease:$BetaRelease
$destination=Get-PveCloudbaseInitCachePath -AssetsRoot $AssetsRoot -OsArch $OsArch -BetaRelease:$BetaRelease
if (Test-Path -LiteralPath $destination -PathType Leaf) {
try {
Assert-PveCloudbaseInitInstaller -Path $destination -OsArch $OsArch
Write-Host ('复用 Cloudbase-Init 安装包:'+$destination)
return $destination
} catch { Write-Host ('Cloudbase-Init 缓存无效,重新下载:'+$destination) -ForegroundColor Yellow }
}
$directory=Split-Path -Parent $destination
New-Item -ItemType Directory -Path $directory -Force | Out-Null
$temporary=Join-Path $directory ($package.Name+'.'+[guid]::NewGuid().ToString('N')+'.partial.msi')
try {
Write-Host ('下载 Cloudbase-Init:'+$package.Url)
Invoke-PveCloudbaseInitDownload -Uri $package.Url -Destination $temporary
Assert-PveCloudbaseInitInstaller -Path $temporary -OsArch $OsArch
if (Test-Path -LiteralPath $destination -PathType Leaf) { [IO.File]::Replace($temporary,$destination,[System.Management.Automation.Language.NullString]::Value) }
else { [IO.File]::Move($temporary,$destination) }
return $destination
} finally {
if (Test-Path -LiteralPath $temporary -PathType Leaf) { Remove-Item -LiteralPath $temporary -Force }
}
}
+93
View File
@@ -0,0 +1,93 @@
function Assert-ConfigValue {
param([string]$Value, [string]$Name)
if ([string]::IsNullOrWhiteSpace($Value) -or $Value.IndexOfAny([char[]]@("`r", "`n", [char]0)) -ge 0) { throw "Invalid configuration value: $Name" }
}
. (Join-Path $PSScriptRoot 'Package.Validation.ps1')
. (Join-Path $PSScriptRoot 'ProductKeys.Common.ps1')
. (Join-Path $PSScriptRoot 'Software.Selection.ps1')
function Get-DefaultImageFileName {
param([string]$ImageName, [datetime]$Timestamp=(Get-Date))
Assert-ConfigValue -Value $ImageName -Name 'ImageName'
if ($ImageName -match '^Windows\s+Server\s+(\d{4})\b') { $prefix = 'WinServer' + $Matches[1] }
elseif ($ImageName -match '^Windows\s+(\d+(?:\.\d+)?)\b') { $prefix = 'Win' + $Matches[1] }
else {
$prefix = $ImageName -replace '[^\p{L}\p{Nd}._-]', ''
if (-not $prefix) { $prefix = 'Windows' }
}
return $prefix + '-' + $Timestamp.ToString('yyyyMMddHHmm', [Globalization.CultureInfo]::InvariantCulture) + '.qcow2'
}
function Find-WindowsInstallerIso {
param([string]$ProjectRoot,[string]$AssetsRoot)
foreach($directory in @((Join-Path $ProjectRoot 'data\iso'),(Join-Path $AssetsRoot 'iso'))){
if(-not(Test-Path -LiteralPath $directory -PathType Container)){continue}
$candidate=Get-ChildItem -LiteralPath $directory -Filter '*.iso' -File|Where-Object Name -notlike '*virtio*'|Sort-Object Name|Select-Object -First 1
if($candidate){return $candidate.FullName}
}
}
function Find-VirtioInstallerIso {
param([string]$ProjectRoot,[string]$AssetsRoot)
foreach($directory in @((Join-Path $ProjectRoot 'data\iso'),(Join-Path $AssetsRoot 'iso'))){
if(-not(Test-Path -LiteralPath $directory -PathType Container)){continue}
$candidate=Get-ChildItem -LiteralPath $directory -Filter '*virtio*.iso' -File|Sort-Object Name|Select-Object -First 1
if($candidate){return $candidate.FullName}
}
}
function Set-ImageConfigValue {
param([string]$Text, [string]$Key, [string]$Value)
Assert-ConfigValue -Value $Value -Name $Key
$pattern = '(?m)^' + [regex]::Escape($Key) + '=.*$'
if (-not [regex]::IsMatch($Text, $pattern)) { throw "Configuration key not found: $Key" }
$replacement = $Key + '=' + $Value
return [regex]::Replace($Text, $pattern, [Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement })
}
function Expand-VirtioGuestTools {
param([string]$IsoPath,[string]$DestinationDirectory)
$member='virtio-win-guest-tools.exe'
$tar=Get-Command tar.exe -ErrorAction SilentlyContinue
if($tar){
$priorErrorAction=$ErrorActionPreference
try{
$ErrorActionPreference='Continue'
$output=@(& $tar.Source -xf $IsoPath -C $DestinationDirectory -- $member 2>&1|ForEach-Object{$_.ToString()})
$exitCode=$LASTEXITCODE
}finally{$ErrorActionPreference=$priorErrorAction}
if($exitCode -ne 0){throw ('Cannot extract VirtIO guest tools from the selected ISO: '+($output -join ' '))}
}else{
$mountedHere=$false
try{
$image=Get-DiskImage -ImagePath $IsoPath
if(-not$image.Attached){Mount-DiskImage -ImagePath $IsoPath -StorageType ISO|Out-Null;$mountedHere=$true}
$volumes=@(Get-DiskImage -ImagePath $IsoPath|Get-Volume|Where-Object DriveLetter)
if($volumes.Count -ne 1){throw 'Cannot identify the VirtIO ISO volume.'}
$source=Join-Path ($volumes[0].DriveLetter+':\') $member
Copy-Item -LiteralPath $source -Destination (Join-Path $DestinationDirectory $member)
}finally{if($mountedHere){Dismount-DiskImage -ImagePath $IsoPath|Out-Null}}
}
$extracted=Join-Path $DestinationDirectory $member
if(-not(Test-Path -LiteralPath $extracted -PathType Leaf)){throw 'The selected VirtIO ISO does not contain virtio-win-guest-tools.exe.'}
return $extracted
}
function Initialize-VirtioGuestTools {
param($Settings,[string]$VirtioIsoPath,[switch]$RefreshFromIso)
$package=Get-SelectedImagePackages -SkipChrome -SkipVSCode
$destination=Join-Path $Settings.AssetsRoot ('custom-resources\'+$package.File)
if(-not$RefreshFromIso){
try{Assert-ImagePackage -Path $destination -Package $package;return $destination}catch{}
}
if(-not(Test-Path -LiteralPath $VirtioIsoPath -PathType Leaf) -or [IO.Path]::GetExtension($VirtioIsoPath) -ine '.iso'){throw 'Select an existing VirtIO ISO.'}
$workRoot=[IO.Path]::GetFullPath((Join-Path $Settings.ProjectRoot 'local\virtio-extract')).TrimEnd('\')
$temporary=Join-Path $workRoot ([guid]::NewGuid().ToString('N'))
if(-not([IO.Path]::GetFullPath($temporary).StartsWith($workRoot+'\',[StringComparison]::OrdinalIgnoreCase))){throw 'Unexpected VirtIO extraction path.'}
New-Item -ItemType Directory -Path $temporary -Force|Out-Null
try{
$extracted=Expand-VirtioGuestTools -IsoPath ([IO.Path]::GetFullPath($VirtioIsoPath)) -DestinationDirectory $temporary
Assert-ImagePackage -Path $extracted -Package $package
New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force|Out-Null
Copy-Item -LiteralPath $extracted -Destination $destination -Force
return $destination
}finally{
$resolved=(Resolve-Path -LiteralPath $temporary -ErrorAction Stop).ProviderPath
if(-not$resolved.StartsWith($workRoot+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'VirtIO extraction cleanup escaped its workspace.'}
Remove-Item -LiteralPath $resolved -Recurse -Force
}
}
+45
View File
@@ -0,0 +1,45 @@
param([string]$Package,[string]$Destination)
$ErrorActionPreference='Stop'
New-Item -ItemType Directory -Path $Destination -Force|Out-Null
Add-Type -TypeDefinition @'
using System;
using System.IO;
using System.Text;
using System.Collections.Generic;
using System.Runtime.InteropServices;
public static class ReadOnlyMsiTools {
[DllImport("msi.dll",CharSet=CharSet.Unicode)] static extern uint MsiOpenDatabase(string path,IntPtr mode,out uint db);
[DllImport("msi.dll",CharSet=CharSet.Unicode)] static extern uint MsiDatabaseOpenView(uint db,string sql,out uint view);
[DllImport("msi.dll")] static extern uint MsiViewExecute(uint view,uint record);
[DllImport("msi.dll")] static extern uint MsiViewFetch(uint view,out uint record);
[DllImport("msi.dll",CharSet=CharSet.Unicode)] static extern uint MsiRecordGetString(uint record,uint field,StringBuilder value,ref uint size);
[DllImport("msi.dll")] static extern uint MsiRecordReadStream(uint record,uint field,byte[] value,ref uint size);
[DllImport("msi.dll")] static extern uint MsiCloseHandle(uint handle);
static void Check(uint code){if(code!=0)throw new Exception("MSI read failed: "+code);}
static string Str(uint record,uint field){uint n=1024;var b=new StringBuilder(1025);Check(MsiRecordGetString(record,field,b,ref n));return b.ToString();}
public static Dictionary<string,string> Extract(string path,string destination){
uint db=0,view=0,record=0; var names=new Dictionary<string,string>();
// MSIDBOPEN_READONLY (0): no installation and no writes to the package.
Check(MsiOpenDatabase(path,IntPtr.Zero,out db));
try{
Check(MsiDatabaseOpenView(db,"SELECT `Name`, `Data` FROM `_Streams`",out view));Check(MsiViewExecute(view,0));
while(true){uint c=MsiViewFetch(view,out record);if(c==259)break;Check(c);
try{string name=Str(record,1);if(!name.EndsWith(".cab",StringComparison.OrdinalIgnoreCase))continue;
if(Path.GetFileName(name)!=name)throw new Exception("Unexpected cabinet name");
using(var output=File.Create(Path.Combine(destination,name))){byte[] buffer=new byte[65536];while(true){uint n=(uint)buffer.Length;Check(MsiRecordReadStream(record,2,buffer,ref n));if(n==0)break;output.Write(buffer,0,(int)n);}}
}finally{MsiCloseHandle(record);record=0;}
}
MsiCloseHandle(view);view=0;
Check(MsiDatabaseOpenView(db,"SELECT `File`, `FileName` FROM `File`",out view));Check(MsiViewExecute(view,0));
while(true){uint c=MsiViewFetch(view,out record);if(c==259)break;Check(c);try{string[] parts=Str(record,2).Split('|');names[Str(record,1)]=parts[parts.Length-1];}finally{MsiCloseHandle(record);record=0;}}
}finally{if(record!=0)MsiCloseHandle(record);if(view!=0)MsiCloseHandle(view);MsiCloseHandle(db);}
return names;
}
}
'@
$files=[ReadOnlyMsiTools]::Extract([IO.Path]::GetFullPath($Package),[IO.Path]::GetFullPath($Destination))
$cabinetFiles=@(Get-ChildItem -LiteralPath $Destination -Filter '*.cab' -File)
if(-not$cabinetFiles.Count){throw 'No embedded cabinet found.'}
foreach($cab in $cabinetFiles){& 'C:\Windows\System32\expand.exe' $cab.FullName '-F:*' $Destination|Out-Null;if($LASTEXITCODE -ne 0){throw 'Cabinet extraction failed.'}}
foreach($id in $files.Keys){if($files[$id] -in @('7z.exe','7z.dll')){Copy-Item -LiteralPath (Join-Path $Destination $id) -Destination (Join-Path $Destination $files[$id]) -Force}}
Get-Item -LiteralPath (Join-Path $Destination '7z.exe'),(Join-Path $Destination '7z.dll')|Select-Object FullName,Length
+123
View File
@@ -0,0 +1,123 @@
param([Parameter(Mandatory=$true)]$Settings,[string]$ResourceContextPath)
Import-Module (Join-Path $Settings.UpstreamRoot 'WinImageBuilder.psm1') -Force
Import-Module (Join-Path $Settings.UpstreamRoot 'Config.psm1') -Force
& (Get-Module WinImageBuilder) {
param($ProjectRoot,$AssetsRoot,$ManifestPath,$ResourceContextPath)
$script:PveProjectRoot=$ProjectRoot;$script:PveAssetsRoot=$AssetsRoot;$script:PveQemuManifest=$ManifestPath
$script:PveDeferredServicing=$false
$script:PveServicingIsoPath=$null
$script:PveResourceContextPath=$ResourceContextPath
$script:PveProductKeyMode='Legacy'
. (Join-Path $ProjectRoot 'scripts\Build.Resources.ps1')
. (Join-Path $ProjectRoot 'scripts\CloudbaseInit.Resources.ps1')
if (-not (Get-Variable -Name PveOriginalDownloadCloudbaseInit -Scope Script -ErrorAction SilentlyContinue)) { $script:PveOriginalDownloadCloudbaseInit=${function:Download-CloudbaseInit} }
function script:Download-CloudbaseInit {
param([string]$resourcesDir,[string]$osArch,[switch]$BetaRelease,[string]$MsiPath,[string]$CloudbaseInitConfigPath,[string]$CloudbaseInitUnattendedConfigPath)
$cachePath=Get-PveCloudbaseInitCachePath -AssetsRoot $script:PveAssetsRoot -OsArch $osArch -BetaRelease:$BetaRelease
$templatePath=Get-PveCloudbaseInitCachePath -AssetsRoot $script:PveAssetsRoot
if (-not $MsiPath -or [IO.Path]::GetFullPath($MsiPath) -in @($cachePath,$templatePath)) {
$PSBoundParameters['MsiPath']=Execute-Retry {
Initialize-PveCloudbaseInitInstaller -AssetsRoot $script:PveAssetsRoot -OsArch $osArch -BetaRelease:$BetaRelease
}
}
& $script:PveOriginalDownloadCloudbaseInit @PSBoundParameters
}
. (Join-Path $ProjectRoot 'scripts\Unattend.Adapter.ps1')
if (-not (Get-Variable -Name PveOriginalGenerateUnattend -Scope Script -ErrorAction SilentlyContinue)) { $script:PveOriginalGenerateUnattend=${function:Generate-UnattendXml} }
function script:Generate-UnattendXml {
param([string]$inUnattendXmlPath,[string]$outUnattendXmlPath,$image,[string]$productKey,$administratorPassword)
$setupKey=Get-PveSetupProductKey -Image $image -RequestedProductKey $productKey -Mode $script:PveProductKeyMode
$automatic=($script:PveProductKeyMode -eq 'Kms' -or (-not $productKey -and -not [string]::IsNullOrWhiteSpace($setupKey)))
if ($setupKey) { $PSBoundParameters['productKey']=$setupKey }
else { $PSBoundParameters.Remove('productKey') | Out-Null }
& $script:PveOriginalGenerateUnattend @PSBoundParameters
Update-PveUnattendSettings -Path $outUnattendXmlPath -Image $image -AutomaticSetupKey:$automatic
if ($automatic) { Write-Log 'Public KMS client setup key supplied for the selected edition; build-time automatic activation disabled.' }
}
. (Join-Path $ProjectRoot 'scripts\Servicing.Adapter.ps1')
if (-not (Get-Variable -Name PveOriginalGetPath -Scope Script -ErrorAction SilentlyContinue)) { $script:PveOriginalGetPath = ${function:Get-PathWithoutExtension} }
function script:Get-PathWithoutExtension {
param([string]$Path,[int]$Depth=0)
if ([IO.Path]::GetExtension($Path).ToLowerInvariant() -in @('.qcow2','.vhdx','.vhd','.raw')) {
return Join-Path ([IO.Path]::GetDirectoryName($Path)) ([IO.Path]::GetFileNameWithoutExtension($Path))
}
& $script:PveOriginalGetPath -Path $Path -Depth $Depth
}
if (-not (Get-Variable -Name PveOriginalRunSysprep -Scope Script -ErrorAction SilentlyContinue)) {
$script:PveOriginalRunSysprep = ${function:Run-Sysprep}
}
if (-not (Get-Variable -Name PveOriginalCopyUnattendResources -Scope Script -ErrorAction SilentlyContinue)) { $script:PveOriginalCopyUnattendResources=${function:Copy-UnattendResources} }
function script:Copy-UnattendResources {
param([string]$resourcesDir,[string]$imageInstallationType,[bool]$InstallMaaSHooks,[string]$VMwareToolsPath)
& $script:PveOriginalCopyUnattendResources @PSBoundParameters
Copy-Item -LiteralPath (Join-Path $script:PveProjectRoot 'resources\Builder.Specialize.ps1') -Destination (Join-Path $resourcesDir 'Specialize.ps1') -Force
}
if (-not (Get-Variable -Name PveOriginalAddVirtioIso -Scope Script -ErrorAction SilentlyContinue)) { $script:PveOriginalAddVirtioIso=${function:Add-VirtIODriversFromISO} }
function script:Add-VirtIODriversFromISO {
param([string]$vhdDriveLetter,$image,[string]$isoPath)
if ($script:PveResourceContextPath) {
$directory=Join-Path $script:PveRunLogDirectory ('temporary-virtio-'+[guid]::NewGuid().ToString('N'))
Register-PveBuildDirectory -ContextPath $script:PveResourceContextPath -Path $directory
New-Item -ItemType Directory -Path $directory | Out-Null
$copy=Join-Path $directory 'virtio.iso'
Copy-Item -LiteralPath $isoPath -Destination $copy
try { & $script:PveOriginalAddVirtioIso -vhdDriveLetter $vhdDriveLetter -image $image -isoPath $copy }
finally {
if (Test-Path -LiteralPath $copy -PathType Leaf) { Assert-PveResourcePath -Path $copy -Root $directory -Descendant | Out-Null; Remove-Item -LiteralPath $copy -Force }
if ((Test-Path -LiteralPath $directory -PathType Container) -and -not @(Get-ChildItem -LiteralPath $directory -Force).Count) { Remove-Item -LiteralPath $directory }
}
} else { & $script:PveOriginalAddVirtioIso @PSBoundParameters }
}
function script:Run-Sysprep {
param([string]$Name,[string]$VhdPath,[uint64]$Memory,[int]$CpuCores,[string]$VMSwitch,[string]$Generation='1',[switch]$DisableSecureBoot)
if($script:PveDeferredServicing){
. (Join-Path $script:PveProjectRoot 'scripts\WindowsPE.Servicing.ps1')
Write-Log 'Starting isolated Windows PE EFI configuration and driver injection.'
Invoke-WindowsPeServicing -VhdPath $VhdPath -WindowsIsoPath $script:PveServicingIsoPath -LogDirectory (Join-Path $script:PveRunLogDirectory 'windows-pe')
}
if ($script:PveResourceContextPath) {
$Name='PveBuilder-Sysprep-'+[guid]::NewGuid().ToString('N')
$PSBoundParameters['Name']=$Name
Register-PveBuildVm -ContextPath $script:PveResourceContextPath -Name $Name -VhdPath $VhdPath
}
try { & $script:PveOriginalRunSysprep @PSBoundParameters }
catch {
$own=Get-VM -Name $Name -ErrorAction SilentlyContinue
if($own){$disks=@(Get-VMHardDiskDrive -VM $own);if($disks.Count -eq 1 -and $disks[0].Path -ieq $VhdPath -and $own.State -ne 'Off'){Stop-VM -VM $own -TurnOff -Force}}
throw
}
. (Join-Path $script:PveProjectRoot 'scripts\Build.Validation.ps1')
$verification=Get-OfflineBuildVerification -VhdPath $VhdPath -Directory (Join-Path $script:PveRunLogDirectory 'verification')
[IO.File]::WriteAllText((Join-Path $script:PveRunLogDirectory 'verified-guest.json'),($verification|ConvertTo-Json -Depth 6),[Text.UTF8Encoding]::new($false))
Write-Log 'Guest software, required services, and generalized Sysprep state verified.'
}
function script:Wait-ForVMShutdown {
param([string]$Name)
$deadline=[DateTime]::UtcNow.AddHours(4)
$lastMessages=@{}
if ($script:PveResourceContextPath) {
$owned=Get-VM -Name $Name -ErrorAction Stop
$ownedDisks=@(Get-VMHardDiskDrive -VM $owned -ErrorAction Stop)
if ($ownedDisks.Count -ne 1) { throw 'The build VM must have exactly one owned disk.' }
Register-PveBuildVm -ContextPath $script:PveResourceContextPath -Name $Name -VhdPath $ownedDisks[0].Path -Id ([string]$owned.Id)
}
while ((Get-VM -Name $Name).State -ne 'Off') {
if([DateTime]::UtcNow -gt $deadline){throw ('Build VM timed out; retained for diagnosis: '+$Name)}
$messages=$null
try { $messages=Get-KVPData -VMName $Name } catch { Write-Verbose 'VM runtime logs are not available yet.' }
if($messages){foreach($key in $messages.Keys){if($lastMessages[$key] -ne $messages[$key]){Write-Log ('{0}: {1}' -f $key,$messages[$key])};if($key -ieq 'ERROR'){throw ('Build guest reported an error; VM retained for diagnosis: '+$Name+'. '+$messages[$key])}};$lastMessages=$messages}
Start-Sleep -Seconds 3
}
}
function script:Convert-VirtualDisk {
param([Parameter(Mandatory=$true)][string]$vhdPath,[Parameter(Mandatory=$true)][string]$outPath,[Parameter(Mandatory=$true)][string]$format,[bool]$CompressQcow2)
if($format.ToLower() -eq 'qcow2' -and $CompressQcow2){
$result=& (Join-Path $script:PveProjectRoot 'src\Compress-Qcow2Image.ps1') -SourcePath $vhdPath -OutputPath $outPath -ToolManifestPath $script:PveQemuManifest -BackupDirectory (Join-Path $script:PveAssetsRoot 'archive\verified-builds') -ReplaceExisting -ResourceContextPath $script:PveResourceContextPath
Write-Log ('Verified compressed image: '+$result.OutputPath)
}else{
$manifest=Get-Content -Raw -Encoding UTF8 -LiteralPath $script:PveQemuManifest|ConvertFrom-Json
& $manifest.ExecutablePath convert -m 1 -O $format $vhdPath $outPath
if($LASTEXITCODE -ne 0){throw 'Disk conversion failed.'}
}
}
} $Settings.ProjectRoot $Settings.AssetsRoot $Settings.QemuManifestPath $ResourceContextPath
+93
View File
@@ -0,0 +1,93 @@
function Get-OfflineImageState {
param([Parameter(Mandatory=$true)][string]$HivePath)
if (-not ('PveOffline.HiveFileReader' -as [type])) {
Add-Type -TypeDefinition @"
using System;
using System.IO;
using System.Text;
using System.Collections.Generic;
namespace PveOffline {
public sealed class HiveFileReader {
private readonly byte[] data;
private HiveFileReader(string file) {
var info = new FileInfo(file);
if (info.Length < 4096 || info.Length > 512L*1024*1024) throw new InvalidDataException("Unsupported registry hive size.");
data = File.ReadAllBytes(file);
if (Encoding.ASCII.GetString(data, 0, 4) != "regf") throw new InvalidDataException("Not a registry hive.");
if (U32(20) != 1 || U32(24) > 6) throw new InvalidDataException("Unsupported registry hive format.");
}
private void Bounds(int at, int size) {
if (at < 0 || size < 0 || at > data.Length-size) throw new InvalidDataException("Registry cell is outside the hive.");
}
private uint U32(int at) { Bounds(at,4); return BitConverter.ToUInt32(data,at); }
private ushort U16(int at) { Bounds(at,2); return BitConverter.ToUInt16(data,at); }
private int Cell(uint relative) {
long at = 4096L + relative;
if (at > int.MaxValue) throw new InvalidDataException("Invalid registry cell offset.");
int offset = (int)at;
Bounds(offset,4);
int size = BitConverter.ToInt32(data,offset);
if (size >= -4 || size == int.MinValue) throw new InvalidDataException("Registry cell is not allocated.");
Bounds(offset,-size);
return offset+4;
}
private string Signature(int at) { Bounds(at,2); return Encoding.ASCII.GetString(data,at,2); }
private string Name(int at, int size, bool ascii) {
Bounds(at,size);
return (ascii ? Encoding.GetEncoding(1252) : Encoding.Unicode).GetString(data,at,size);
}
private string KeyName(int at) {
if (Signature(at) != "nk") throw new InvalidDataException("Expected a registry key cell.");
return Name(at+76,U16(at+72),(U16(at+2)&0x20)!=0);
}
private IEnumerable<uint> Subkeys(uint index, int depth) {
if (depth > 32) throw new InvalidDataException("Registry subkey index is too deep.");
int at=Cell(index); string kind=Signature(at); int count=U16(at+2);
if (kind=="lf" || kind=="lh") {
Bounds(at+4,count*8);
for(int i=0;i<count;i++) yield return U32(at+4+i*8);
} else if(kind=="li" || kind=="ri") {
Bounds(at+4,count*4);
for(int i=0;i<count;i++) {
uint next=U32(at+4+i*4);
if(kind=="li") yield return next;
else foreach(uint child in Subkeys(next,depth+1)) yield return child;
}
} else throw new InvalidDataException("Unsupported registry subkey index.");
}
private uint FindChild(uint parent,string name) {
int at=Cell(parent);
if (Signature(at)!="nk" || U32(at+20)==0) throw new InvalidDataException("Offline registry key is missing: "+name);
foreach(uint child in Subkeys(U32(at+28),0))
if(string.Equals(KeyName(Cell(child)),name,StringComparison.OrdinalIgnoreCase)) return child;
throw new InvalidDataException("Offline registry key is missing: "+name);
}
private string StringValue(uint key,string name) {
int at=Cell(key); uint count=U32(at+36);
if(count>100000) throw new InvalidDataException("Invalid registry value count.");
int values=Cell(U32(at+40)); Bounds(values,checked((int)count*4));
for(int i=0;i<(int)count;i++) {
int value=Cell(U32(values+i*4));
if(Signature(value)!="vk") throw new InvalidDataException("Invalid registry value cell.");
string valueName=Name(value+20,U16(value+2),(U16(value+16)&1)!=0);
if(!string.Equals(valueName,name,StringComparison.OrdinalIgnoreCase)) continue;
if(U32(value+12)!=1) throw new InvalidDataException("Expected a REG_SZ registry value.");
uint length=U32(value+4); bool inline=(length&0x80000000)!=0; length&=0x7fffffff;
if(length>4096 || (inline && length>4) || length%2!=0) throw new InvalidDataException("Invalid registry string size.");
int text=inline ? value+8 : Cell(U32(value+8));
return Name(text,(int)length,false).TrimEnd((char)0);
}
throw new InvalidDataException("Offline registry value is missing: "+name);
}
public static string ReadImageState(string file) {
var hive=new HiveFileReader(file);
uint key=hive.U32(36);
foreach(string segment in new[]{"Microsoft","Windows","CurrentVersion","Setup","State"}) key=hive.FindChild(key,segment);
return hive.StringValue(key,"ImageState");
}
}
}
"@
}
[PveOffline.HiveFileReader]::ReadImageState([IO.Path]::GetFullPath($HivePath))
}
+42
View File
@@ -0,0 +1,42 @@
function Get-SelectedImagePackages {
param([switch]$SkipChrome, [switch]$SkipVSCode)
if (-not $SkipChrome) {
[PSCustomObject]@{Name='Chrome';File='chrome-enterprise64.msi';Publishers=@('Google LLC','Google Inc');Validation='Signature'}
}
if (-not $SkipVSCode) {
[PSCustomObject]@{Name='VSCode';File='VSCodeSetup-x64.exe';Publishers=@('Microsoft Corporation');Validation='Signature'}
}
[PSCustomObject]@{Name='VirtIO';File='virtio-win-guest-tools.exe';Publishers=@();Validation='FileName'}
}
function Assert-ImagePackage {
param([string]$Path, $Package)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Required installer is missing: $Path" }
if ($Package.Validation -eq 'FileName') {
if ([IO.Path]::GetFileName($Path) -ine $Package.File) { throw ('Installer filename must be ' + $Package.File) }
return
}
if ($Package.Validation -ne 'Signature') { throw 'Unknown installer validation rule.' }
if ([IO.Path]::GetExtension($Path) -ine [IO.Path]::GetExtension($Package.File)) { throw ('Incorrect installer file type: ' + $Package.Name) }
$signature = Get-AuthenticodeSignature -LiteralPath $Path
if ($signature.Status -ne 'Valid' -or -not $signature.SignerCertificate) {
throw ('Installer signature is not valid: ' + $Package.Name + ' (' + $signature.Status + ')')
}
$publisher = $signature.SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)
if ($publisher -notin $Package.Publishers) { throw ('Unexpected installer publisher: ' + $publisher + '; expected ' + ($Package.Publishers -join ' / ')) }
}
function Find-ImagePackageInstaller {
param($Package, [string[]]$Directories)
foreach ($directory in $Directories) {
if (-not (Test-Path -LiteralPath $directory -PathType Container)) { continue }
$candidates = @(Get-ChildItem -LiteralPath $directory -File | Where-Object {
$_.Name -ieq $Package.File -or
($Package.Name -eq 'Chrome' -and $_.Name -like '*chrome*.msi') -or
($Package.Name -eq 'VSCode' -and $_.Name -like 'VSCodeSetup*.exe')
} | Sort-Object LastWriteTime -Descending)
foreach ($candidate in $candidates) {
try { Assert-ImagePackage -Path $candidate.FullName -Package $Package; return $candidate.FullName } catch {}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
function script:Get-PveKmsCatalog {
Import-PowerShellDataFile -LiteralPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'config\Kms.ClientKeys.psd1')
}
function script:Get-PveKmsProductKey {
param([string]$ImageName)
$name=([regex]::Replace($ImageName,'\s+',' ')).Trim()
foreach ($entry in (Get-PveKmsCatalog).Entries) {
if ($name -in $entry.Names) { return [string]$entry.Key }
}
}
function script:Assert-PveProductKey {
param([AllowEmptyString()][string]$ProductKey)
if ($ProductKey -and $ProductKey -notmatch '^[A-Z0-9]{5}(?:-[A-Z0-9]{5}){4}$') { throw 'Product key must contain five groups of five letters or digits.' }
}
function script:Resolve-PveProductKey {
param([string]$ImageName,[ValidateSet('Legacy','Kms','Custom','None')][string]$Mode='Legacy',[AllowEmptyString()][string]$ProductKey='')
$key=([string]$ProductKey).Trim().ToUpperInvariant()
if ($Mode -eq 'None') {
if ($key) { throw 'An empty-key selection cannot contain a product key.' }
return ''
}
if ($Mode -eq 'Kms') {
$matched=Get-PveKmsProductKey -ImageName $ImageName
if (-not $matched) { throw 'No matching KMS client setup key exists for this Windows edition.' }
if ($key -and $key -ne $matched) { throw 'The KMS key does not match the selected Windows edition.' }
return $matched
}
if ($Mode -eq 'Legacy' -and $key -eq 'DEFAULT_KMS_KEY') { return 'default_kms_key' }
Assert-PveProductKey -ProductKey $key
return $key
}
+21
View File
@@ -0,0 +1,21 @@
$ProjectRoot=Split-Path -Parent $PSScriptRoot
function Get-BuildDependencies {
return Import-PowerShellDataFile -LiteralPath (Join-Path $ProjectRoot 'scripts\Build.Dependencies.psd1')
}
function Get-ProjectSettings {
$file=Join-Path $ProjectRoot 'local\settings.json'
if(-not(Test-Path -LiteralPath $file)){throw 'Run Initialize-Project.ps1 first.'}
return Get-Content -Raw -Encoding UTF8 -LiteralPath $file|ConvertFrom-Json
}
function Get-ProjectGit {
$git=Get-Command git.exe -ErrorAction SilentlyContinue
if($git){return $git.Source}
throw 'Git for Windows must be available on PATH, or pass GitPath to Initialize-Project.ps1.'
}
function Assert-PowerShell51 {
if($PSVersionTable.PSEdition -ne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -lt 1){throw 'Use Windows PowerShell 5.1 (powershell.exe).'}
}
function Assert-Administrator {
$p=New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if(-not$p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'Run from an elevated administrator PowerShell.'}
}
+60
View File
@@ -0,0 +1,60 @@
function script:Invoke-PveServicingTool {
param([string]$Executable,[string[]]$Arguments,[string]$Stage)
$output=@()
$priorErrorAction=$ErrorActionPreference
try {
$ErrorActionPreference='Continue'
$output=@(& $Executable @Arguments 2>&1 | ForEach-Object { $_.ToString() })
$exitCode=$LASTEXITCODE
} finally { $ErrorActionPreference=$priorErrorAction }
$entry=[ordered]@{Stage=$Stage;Executable=$Executable;Version=(Get-Item -LiteralPath $Executable).VersionInfo.FileVersion;Arguments=$Arguments;ExitCode=$exitCode;Output=$output;CheckedUtc=[DateTime]::UtcNow.ToString('o')}
$filename=$Stage+'-'+[guid]::NewGuid().ToString('N').Substring(0,8)+'.json'
[IO.File]::WriteAllText((Join-Path $script:PveRunLogDirectory $filename),($entry|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false))
foreach($line in $output){Write-Log $line}
if($exitCode -ne 0 -and $exitCode -ne 3010){
$message=("$Stage failed (exit $exitCode). Logs: "+$script:PveRunLogDirectory)
if(($output -join ' ') -match 'c0000035'){$message+=' BCD hive collision: the host BCD must not be unloaded or modified; a compatible servicing environment is required.'}
throw $message
}
}
function script:Create-BCDBootConfig {
param([string]$systemDrive,[string]$windowsDrive,[string]$diskLayout,$image)
if($diskLayout -ne 'UEFI'){throw 'Only UEFI is supported by this project.'}
$systemLetter=$systemDrive.TrimEnd(':','\')
$windowsLetter=$windowsDrive.TrimEnd(':','\')
$efi=Get-Partition -DriveLetter $systemLetter
$windows=Get-Partition -DriveLetter $windowsLetter
$targetDisk=$efi|Get-Disk
if($efi.DiskNumber -ne $windows.DiskNumber -or $targetDisk.IsBoot -or $targetDisk.IsSystem){throw 'Refusing BCDBoot against a host system disk or mismatched partitions.'}
if([guid]$efi.GptType -ne [guid]'c12a7328-f81f-11d2-ba4b-00a0c93ec93b'){throw 'The selected system partition is not an EFI system partition.'}
if($script:PveServicingIsoPath){
$script:PveDeferredServicing=$true
$script:PveServicingWindowsDrive=$windowsDrive.TrimEnd('\')
$resources=$script:PveServicingWindowsDrive+'\UnattendResources'
New-Item -ItemType Directory -Path (Join-Path $resources 'PveDrivers') -Force|Out-Null
[IO.File]::WriteAllText((Join-Path $resources 'pve-servicing-marker.txt'),'PVE builder: service only this newly created image disk.')
[IO.File]::WriteAllText((Join-Path $resources 'pve-efi-partition.txt'),[string]$efi.PartitionNumber,[Text.Encoding]::ASCII)
Write-Log 'EFI boot configuration deferred to the isolated Windows PE servicing VM.'
return
}
$tool=$script:PveServicingTools.BcdbootPath
$help=(& $tool /? 2>&1|Out-String)
$arguments=@(($windowsDrive.TrimEnd('\')+'\Windows'),'/s',$systemDrive,'/f','UEFI','/v')
# Current Microsoft tools support offline servicing without firmware sync.
if($help -match '/offline'){$arguments+='/offline'}
Invoke-PveServicingTool -Executable $tool -Arguments $arguments -Stage 'bcdboot'
if(-not(Test-Path -LiteralPath ($systemDrive.TrimEnd('\')+'\EFI\Microsoft\Boot\BCD'))){throw 'BCDBoot did not create the image EFI BCD store.'}
}
function script:Add-DriversToImage {
param([string]$winImagePath,[string]$driversPath)
if($script:PveDeferredServicing){
if($winImagePath.TrimEnd('\') -ine $script:PveServicingWindowsDrive){throw 'Driver staging target differs from the build Windows partition.'}
$destination=$winImagePath.TrimEnd('\')+'\UnattendResources\PveDrivers\'+[guid]::NewGuid().ToString('N')
Copy-Item -LiteralPath $driversPath -Destination $destination -Recurse
Write-Log ('Staged drivers for Windows PE servicing: '+$driversPath)
return
}
$tool=$script:PveServicingTools.DismPath
$log=Join-Path $script:PveRunLogDirectory ('dism-'+[guid]::NewGuid().ToString('N').Substring(0,8)+'.log')
Invoke-PveServicingTool -Executable $tool -Arguments @(('/image:'+$winImagePath),'/Add-Driver',('/driver:'+$driversPath),'/recurse',('/LogPath:'+$log)) -Stage 'dism-add-driver'
}
+35
View File
@@ -0,0 +1,35 @@
function Get-GuestSoftwareSelection {
param([string]$ResourceRoot)
$path = Join-Path $ResourceRoot 'software-selection.json'
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
return [PSCustomObject]@{InstallChrome=$true;InstallVSCode=$true}
}
$selection = Get-Content -Raw -Encoding UTF8 -LiteralPath $path | ConvertFrom-Json
if ($selection.SchemaVersion -ne 1 -or $selection.InstallChrome -isnot [bool] -or $selection.InstallVSCode -isnot [bool]) { throw 'Invalid guest software selection.' }
if ($selection.PSObject.Properties['ProductKeyMode'] -and $selection.ProductKeyMode -notin @('Legacy','Kms','Custom','None')) { throw 'Invalid product key selection mode.' }
if ($selection.PSObject.Properties['KmsServer']) {
if ($selection.KmsServer -isnot [string]) { throw 'Invalid KMS server selection.' }
$selection.KmsServer=(ConvertTo-PveKmsEndpoint -Address $selection.KmsServer).Address
}
return $selection
}
function Get-PveDefaultKmsServer { return 'kms-default.cangshui.net' }
function ConvertTo-PveKmsEndpoint {
param([AllowEmptyString()][string]$Address)
$address=([string]$Address).Trim()
if (-not $address) { return [PSCustomObject]@{Address='';Host='';Port=1688} }
$hostName=$address
$port=1688
$portText=$null
if ($address -match '^\[([^\[\]]+)\](?::([0-9]+))?$') {
$hostName=$Matches[1]
if ($Matches[2]) { $portText=$Matches[2] }
if ([Uri]::CheckHostName($hostName) -ne [UriHostNameType]::IPv6) { throw 'KMS brackets require an IPv6 address.' }
} elseif ($address -match '^([^:]+):([0-9]+)$') {
$hostName=$Matches[1]
$portText=$Matches[2]
}
if ($hostName -match '[\s/\\"'';]' -or [Uri]::CheckHostName($hostName) -eq [UriHostNameType]::Unknown) { throw 'KMS address must be a hostname or IP address, optionally followed by a port.' }
if ($null -ne $portText -and (-not [int]::TryParse($portText,[ref]$port) -or $port -lt 1 -or $port -gt 65535)) { throw 'KMS port must be between 1 and 65535.' }
return [PSCustomObject]@{Address=$address;Host=$hostName;Port=$port}
}
+41
View File
@@ -0,0 +1,41 @@
function script:Get-PveSetupProductKey {
param($Image,[string]$RequestedProductKey,[ValidateSet('Legacy','Kms','Custom','None')][string]$Mode='Legacy')
if ($Mode -ne 'Legacy') { return Resolve-PveProductKey -ImageName $Image.ImageName -Mode $Mode -ProductKey $RequestedProductKey }
if ($RequestedProductKey) { return $RequestedProductKey }
# Microsoft-published setup keys. These are not activation licenses.
# https://learn.microsoft.com/windows-server/get-started/kms-client-activation-keys
if ($Image.ImageName -match '^Windows Server 2025 SERVER(DATACENTER|STANDARD)(CORE)?$') { return Get-PveKmsProductKey -ImageName $Image.ImageName }
}
. (Join-Path $PSScriptRoot 'ProductKeys.Common.ps1')
function script:Set-PveUnattendElement {
param([xml]$Document,[Xml.XmlElement]$Parent,[string]$Name,[string]$Value)
$element=@($Parent.ChildNodes | Where-Object { $_.LocalName -eq $Name }) | Select-Object -First 1
if (-not $element) { $element=$Document.CreateElement($Name,$Document.DocumentElement.NamespaceURI);$null=$Parent.AppendChild($element) }
$element.InnerText=$Value
}
function script:Update-PveUnattendSettings {
param([string]$Path,$Image,[switch]$AutomaticSetupKey)
[xml]$document=[IO.File]::ReadAllText($Path)
$namespace=[Xml.XmlNamespaceManager]::new($document.NameTable)
$namespace.AddNamespace('u',$document.DocumentElement.NamespaceURI)
if ($Image.ImageVersion.Major -ge 10) {
$oobe=$document.SelectSingleNode('//u:settings[@pass="oobeSystem"]/u:component[@name="Microsoft-Windows-Shell-Setup"]/u:OOBE',$namespace)
if (-not $oobe) { throw 'The unattended template has no OOBE configuration.' }
foreach ($name in @('HideOnlineAccountScreens','HideLocalAccountScreen','HideOEMRegistrationScreen')) { Set-PveUnattendElement -Document $document -Parent $oobe -Name $name -Value 'true' }
}
if ($AutomaticSetupKey) {
$specialize=$document.SelectSingleNode('//u:settings[@pass="specialize"]',$namespace)
$component=$document.SelectSingleNode('//u:settings[@pass="specialize"]/u:component[@name="Microsoft-Windows-Security-SPP-UX"]',$namespace)
if (-not $component) {
$component=$document.CreateElement('component',$document.DocumentElement.NamespaceURI)
foreach ($attribute in @{name='Microsoft-Windows-Security-SPP-UX';processorArchitecture=([string]$Image.ImageArchitecture).ToLowerInvariant();publicKeyToken='31bf3856ad364e35';language='neutral';versionScope='nonSxS'}.GetEnumerator()) { $component.SetAttribute($attribute.Key,$attribute.Value) }
$null=$specialize.AppendChild($component)
}
Set-PveUnattendElement -Document $document -Parent $component -Name 'SkipAutoActivation' -Value 'true'
}
$settings=[Xml.XmlWriterSettings]::new()
$settings.Encoding=[Text.UTF8Encoding]::new($false)
$settings.Indent=$true
$writer=[Xml.XmlWriter]::Create($Path,$settings)
try { $document.Save($writer) } finally { $writer.Dispose() }
}
+138
View File
@@ -0,0 +1,138 @@
function Invoke-WindowsPeServicing {
param([string]$VhdPath,[string]$WindowsIsoPath,[string]$LogDirectory)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'Build.Resources.ps1')
if((Get-VHD -Path $VhdPath).Attached){throw 'Windows PE servicing requires a detached build VHDX.'}
if(-not(Test-Path -LiteralPath $WindowsIsoPath -PathType Leaf)){throw 'A Windows installer ISO is required for Windows PE servicing.'}
$mediaRoot=Join-Path $LogDirectory 'media'
Register-PveBuildDirectory -ContextPath $script:PveResourceContextPath -Path $mediaRoot
$scripts=Join-Path $mediaRoot 'PveServicing'
New-Item -ItemType Directory -Path $scripts -Force|Out-Null
$batch=@'
@echo off
setlocal
wpeinit
set "OS="
for %%L in (C D E F G H I J K L M N O P Q R S T U V W Y Z) do if exist %%L:\UnattendResources\pve-servicing-marker.txt set "OS=%%L:"
if not defined OS goto no_os
ver > "%OS%\UnattendResources\winpe-version.txt"
set /p EFI_PARTITION=< "%OS%\UnattendResources\pve-efi-partition.txt"
if not defined EFI_PARTITION goto failed
> X:\pve-assign-efi.txt echo select disk 0
>> X:\pve-assign-efi.txt echo select partition %EFI_PARTITION%
>> X:\pve-assign-efi.txt echo assign letter=S
>> X:\pve-assign-efi.txt echo exit
diskpart /s X:\pve-assign-efi.txt > "%OS%\UnattendResources\winpe-diskpart.log" 2>&1
if errorlevel 1 goto failed
if not exist S:\ goto failed
dism.exe /Image:%OS%\ /Add-Driver /Driver:%OS%\UnattendResources\PveDrivers /Recurse /LogPath:%OS%\UnattendResources\winpe-dism.log > "%OS%\UnattendResources\winpe-dism-output.txt" 2>&1
set "DISMEXIT=%ERRORLEVEL%"
if not "%DISMEXIT%"=="0" if not "%DISMEXIT%"=="3010" goto failed
bcdboot.exe "%OS%\Windows" /s S: /f UEFI /v > "%OS%\UnattendResources\winpe-bcdboot-output.txt" 2>&1
if errorlevel 1 goto failed
if not exist S:\EFI\Microsoft\Boot\BCD goto failed
echo {"Phase":"complete","Disk":0,"BootMode":"UEFI"} > "%OS%\UnattendResources\winpe-servicing-status.json"
wpeutil shutdown
exit /b 0
:failed
echo {"Phase":"failed","Disk":0,"BootMode":"UEFI"} > "%OS%\UnattendResources\winpe-servicing-status.json"
wpeutil shutdown
exit /b 1
:no_os
wpeutil shutdown
exit /b 2
'@
[IO.File]::WriteAllText((Join-Path $scripts 'run.cmd'),($batch -replace '\r?\n',"`r`n"),[Text.Encoding]::ASCII)
$answer=@'
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage><UILanguage>__UI_LANGUAGE__</UILanguage></SetupUILanguage>
<InputLocale>__UI_LANGUAGE__</InputLocale><SystemLocale>__UI_LANGUAGE__</SystemLocale><UILanguage>__UI_LANGUAGE__</UILanguage><UserLocale>__UI_LANGUAGE__</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>cmd.exe /c for %d in (C D E F G H I J K L M N O P Q R S T U V W Y Z) do @if exist %d:\PveServicing\run.cmd call %d:\PveServicing\run.cmd</Path>
<Description>Service only the attached PVE image disk</Description>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
</unattend>
'@
$mountedIsoHere=$false
try {
$sourceDisk=Get-DiskImage -ImagePath $WindowsIsoPath
if(-not$sourceDisk.Attached){Mount-DiskImage -ImagePath $WindowsIsoPath -StorageType ISO|Out-Null;$mountedIsoHere=$true}
$sourceVolumes=@(Get-DiskImage -ImagePath $WindowsIsoPath|Get-Volume|Where-Object DriveLetter)
if($sourceVolumes.Count -ne 1){throw 'Could not identify the Windows installer ISO volume.'}
$sourceRoot=$sourceVolumes[0].DriveLetter+':\'
foreach($name in @('boot','efi')){Copy-Item -LiteralPath (Join-Path $sourceRoot $name) -Destination (Join-Path $mediaRoot $name) -Recurse}
New-Item -ItemType Directory -Path (Join-Path $mediaRoot 'sources') -Force|Out-Null
Copy-Item -LiteralPath (Join-Path $sourceRoot 'sources\boot.wim') -Destination (Join-Path $mediaRoot 'sources\boot.wim')
foreach($name in @('bootmgr','bootmgr.efi','setup.exe')){if(Test-Path -LiteralPath (Join-Path $sourceRoot $name)){Copy-Item -LiteralPath (Join-Path $sourceRoot $name) -Destination (Join-Path $mediaRoot $name)}}
$uiLanguage='en-US'
$langPath=Join-Path $sourceRoot 'sources\lang.ini'
if(Test-Path -LiteralPath $langPath){$match=[regex]::Match([IO.File]::ReadAllText($langPath),'(?im)^([a-z]{2,3}-[a-z0-9]{2,8})\s*=');if($match.Success){$uiLanguage=$match.Groups[1].Value}}
$answer=$answer.Replace('__UI_LANGUAGE__',$uiLanguage)
} finally {if($mountedIsoHere){Dismount-DiskImage -ImagePath $WindowsIsoPath|Out-Null}}
[IO.File]::WriteAllText((Join-Path $mediaRoot 'Autounattend.xml'),$answer,[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)){throw 'Microsoft ADK Deployment Tools (oscdimg) is required for Windows PE servicing.'}
$mediaIso=Join-Path $LogDirectory 'servicing.iso'
Register-PveBuildFile -ContextPath $script:PveResourceContextPath -Path $mediaIso
$efiBoot=Join-Path $mediaRoot 'efi\microsoft\boot\efisys_noprompt.bin'
if(-not(Test-Path -LiteralPath $efiBoot)){$efiBoot=Join-Path (Split-Path -Parent $oscdimg) 'efisys_noprompt.bin'}
if(-not(Test-Path -LiteralPath $efiBoot)){throw 'Microsoft efisys_noprompt.bin is required for unattended Windows PE boot.'}
$priorErrorAction=$ErrorActionPreference
try{$ErrorActionPreference='Continue';$output=@(& $oscdimg -m -o -u2 -udfver102 ('-bootdata:1#pEF,e,b'+$efiBoot) '-lPVE-SERVICE' $mediaRoot $mediaIso 2>&1|ForEach-Object{$_.ToString()});$exitCode=$LASTEXITCODE}
finally{$ErrorActionPreference=$priorErrorAction}
[IO.File]::WriteAllText((Join-Path $LogDirectory 'oscdimg.txt'),($output -join [Environment]::NewLine),[Text.UTF8Encoding]::new($false))
if($exitCode -ne 0){throw 'Could not create the Windows PE servicing answer ISO.'}
$vmName='PveBuilder-WinPE-'+[guid]::NewGuid().ToString('N').Substring(0,12)
$vm=$null;$mountedHere=$false;$successful=$false
$state=[ordered]@{Phase='starting';VMName=$vmName;VhdPath=$VhdPath;WindowsIsoPath=$WindowsIsoPath;StartedUtc=[DateTime]::UtcNow.ToString('o')}
function Save-WinPeState{[IO.File]::WriteAllText((Join-Path $LogDirectory 'status.json'),($state|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false))}
try{
Save-WinPeState
# Exactly one hard disk, with no network or shared host drives.
Register-PveBuildVm -ContextPath $script:PveResourceContextPath -Name $vmName -VhdPath $VhdPath
$vm=New-VM -Name $vmName -Generation 2 -MemoryStartupBytes 4GB -VHDPath $VhdPath
Register-PveBuildVm -ContextPath $script:PveResourceContextPath -Name $vmName -VhdPath $VhdPath -Id ([string]$vm.Id)
Set-VM -VM $vm -AutomaticCheckpointsEnabled $false -CheckpointType Disabled -AutomaticStartAction Nothing -AutomaticStopAction ShutDown
Set-VMProcessor -VM $vm -Count 2
Set-VMMemory -VM $vm -DynamicMemoryEnabled $false
$installerDvd=Add-VMDvdDrive -VM $vm -Path $mediaIso -Passthru
Add-VMDvdDrive -VM $vm -Path $WindowsIsoPath|Out-Null
Set-VMFirmware -VM $vm -EnableSecureBoot On -SecureBootTemplate MicrosoftWindows -FirstBootDevice $installerDvd
Start-VM -VM $vm
$state.Phase='servicing';Save-WinPeState
$deadline=[DateTime]::UtcNow.AddMinutes(20)
Start-Sleep -Seconds 10
while((Get-VM -Id $vm.Id).State -ne 'Off'){
if([DateTime]::UtcNow -gt $deadline){throw ('Windows PE servicing timed out; inspect '+$vmName)}
Start-Sleep -Seconds 3
}
Mount-VHD -Path $VhdPath -ReadOnly|Out-Null;$mountedHere=$true
$windowsVolumes=@(Get-VHD -Path $VhdPath|Get-Disk|Get-Partition|Get-Volume|Where-Object{ $_.DriveLetter -and (Test-Path -LiteralPath ($_.DriveLetter+':\UnattendResources\pve-servicing-marker.txt')) })
if($windowsVolumes.Count -ne 1){throw 'Could not find the serviced Windows partition.'}
$resources=$windowsVolumes[0].DriveLetter+':\UnattendResources'
foreach($name in @('winpe-servicing-status.json','winpe-version.txt','winpe-diskpart.log','winpe-dism.log','winpe-dism-output.txt','winpe-bcdboot-output.txt')){
$path=Join-Path $resources $name
if(Test-Path -LiteralPath $path){Copy-Item -LiteralPath $path -Destination (Join-Path $LogDirectory $name)}
}
$resultPath=Join-Path $LogDirectory 'winpe-servicing-status.json'
if(-not(Test-Path -LiteralPath $resultPath)){throw 'Windows Setup did not execute the Windows PE servicing answer file.'}
$result=Get-Content -Raw -Encoding UTF8 -LiteralPath $resultPath|ConvertFrom-Json
if($result.Phase -ne 'complete'){throw ('Isolated Windows PE servicing failed; inspect '+$LogDirectory)}
$state.Phase='complete';$state.CompletedUtc=[DateTime]::UtcNow.ToString('o');Save-WinPeState
$successful=$true
}catch{$state.Phase='failed';$state.Error=$_.Exception.Message;Save-WinPeState;throw}
finally{
if($mountedHere){Dismount-VHD -Path $VhdPath}
if($vm){$own=Get-VM -Id $vm.Id -ErrorAction SilentlyContinue;if($own){if($own.State -ne 'Off'){Stop-VM -VM $own -TurnOff -Force};if($successful){Remove-VM -VM $own -Force}}}
}
}
+108
View File
@@ -0,0 +1,108 @@
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$SourcePath,
[Parameter(Mandatory=$true)][string]$OutputPath,
[string]$LogDirectory,
[string]$ToolManifestPath,
[string]$BackupDirectory,
[switch]$ReplaceExisting,
[string]$ResourceContextPath
)
$ErrorActionPreference='Stop'
$projectRoot=Split-Path -Parent $PSScriptRoot
. (Join-Path $projectRoot 'scripts\Build.Resources.ps1')
$settings=Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $projectRoot 'local\settings.json')|ConvertFrom-Json
if(-not$ToolManifestPath){$ToolManifestPath=$settings.QemuManifestPath}
if(-not$BackupDirectory){$BackupDirectory=Join-Path $settings.AssetsRoot 'archive\verified-builds'}
$utf8=[Text.UTF8Encoding]::new($false)
$source=[IO.Path]::GetFullPath($SourcePath)
$output=[IO.Path]::GetFullPath($OutputPath)
$sourceItem=Get-Item -LiteralPath $source -ErrorAction Stop
if($sourceItem.PSIsContainer){throw 'Source must be an offline disk-image file.'}
if([IO.Path]::GetExtension($output) -ne '.qcow2'){throw 'Output must have the .qcow2 extension.'}
if((Test-Path -LiteralPath $output) -and -not$ReplaceExisting){throw 'Output already exists. ReplaceExisting is required to replace a verified image.'}
$outputDir=[IO.Path]::GetDirectoryName($output)
New-Item -ItemType Directory -Path $outputDir -Force|Out-Null
$runId=Get-Date -Format 'yyyyMMdd-HHmmss'
$runId+='-'+[guid]::NewGuid().ToString('N').Substring(0,8)
if(-not$LogDirectory){$LogDirectory=Join-Path $settings.AssetsRoot ('logs\compression-'+$runId)}
$logDir=[IO.Path]::GetFullPath($LogDirectory)
New-Item -ItemType Directory -Path $logDir -Force|Out-Null
$manifest=Get-Content -Raw -Encoding UTF8 -LiteralPath $ToolManifestPath|ConvertFrom-Json
$qemu=[IO.Path]::GetFullPath($manifest.ExecutablePath)
if(-not$manifest.PublishedHashVerified -or (Get-FileHash -Algorithm SHA256 -LiteralPath $qemu).Hash -ne $manifest.ExecutableSha256){throw 'Pinned QEMU tool verification failed.'}
$candidate=Join-Path $outputDir ([IO.Path]::GetFileNameWithoutExtension($output)+'.compressing-'+$runId+'.qcow2')
$candidate=[IO.Path]::GetFullPath($candidate)
if([IO.Path]::GetDirectoryName($candidate) -ne $outputDir -or (Test-Path -LiteralPath $candidate)){throw 'Unexpected temporary output path.'}
$state=[ordered]@{Phase='preparing';Pid=$PID;SourcePath=$source;OutputPath=$output;CandidatePath=$candidate;StartedUtc=[DateTime]::UtcNow.ToString('o');ToolVersion=$manifest.Version;CompressionType='zlib';ConversionCoroutines=1;LogDirectory=$logDir}
function Save-CompressionState([string]$Phase){$state.Phase=$Phase;$state.UpdatedUtc=[DateTime]::UtcNow.ToString('o');[IO.File]::WriteAllText((Join-Path $logDir 'compression-status.json'),($state|ConvertTo-Json -Depth 8),$utf8)}
function Quote-NativeArgument([string]$Value){
$escaped=[regex]::Replace($Value,'(\\*)"','$1$1\"')
$escaped=[regex]::Replace($escaped,'(\\+)$','$1$1')
return '"'+$escaped+'"'
}
function Invoke-Qemu([string[]]$Arguments,[string]$Name){
$stdout=Join-Path $logDir ($Name+'.stdout.txt')
$stderr=Join-Path $logDir ($Name+'.stderr.txt')
$argumentLine=($Arguments|ForEach-Object{Quote-NativeArgument $_}) -join ' '
$p=Start-Process -FilePath $qemu -ArgumentList $argumentLine -RedirectStandardOutput $stdout -RedirectStandardError $stderr -WindowStyle Hidden -PassThru
try{$null=$p.Handle;$p.WaitForExit();$exitCode=$p.ExitCode}
finally{if(-not$p.HasExited){$p.Kill();$p.WaitForExit()}}
$lines=@()
foreach($file in @($stdout,$stderr)){if(Test-Path -LiteralPath $file){$lines+=@(Get-Content -Encoding UTF8 -LiteralPath $file)}}
[IO.File]::WriteAllText((Join-Path $logDir ($Name+'.txt')),($lines -join [Environment]::NewLine),$utf8)
if($exitCode -ne 0){throw "qemu-img $Name failed (exit $exitCode); inspect $logDir."}
}
Register-PveBuildFile -ContextPath $ResourceContextPath -Path $candidate
$committed=$false
try{
Save-CompressionState 'inspecting_source'
Invoke-Qemu -Arguments @('info','--output=json',$source) -Name 'source-info'
$sourceInfo=Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $logDir 'source-info.stdout.txt')|ConvertFrom-Json
$sourceBytes=$sourceItem.Length
$sourceWrite=$sourceItem.LastWriteTimeUtc
Save-CompressionState 'compressing_qcow2'
Invoke-Qemu -Arguments @('convert','-p','-c','-m','1','-f',$sourceInfo.format,'-O','qcow2','-o','compat=1.1,compression_type=zlib',$source,$candidate) -Name 'qemu-convert'
Save-CompressionState 'checking_compressed_qcow2'
Invoke-Qemu -Arguments @('check','-f','qcow2',$candidate) -Name 'qemu-check'
Save-CompressionState 'comparing_compressed_payload'
Invoke-Qemu -Arguments @('compare','-p','-f',$sourceInfo.format,'-F','qcow2',$source,$candidate) -Name 'qemu-compare'
Invoke-Qemu -Arguments @('info','--output=json',$candidate) -Name 'compressed-info'
$info=Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $logDir 'compressed-info.stdout.txt')|ConvertFrom-Json
$backingProperty=$info.PSObject.Properties['backing-filename']
if($info.format -ne 'qcow2' -or $info.'virtual-size' -ne $sourceInfo.'virtual-size' -or ($backingProperty -and $backingProperty.Value)){throw 'Compressed output metadata is inconsistent with the source.'}
$now=Get-Item -LiteralPath $source
if($now.Length -ne $sourceBytes -or $now.LastWriteTimeUtc -ne $sourceWrite){throw 'Source changed during compression.'}
$state.SHA256=(Get-FileHash -Algorithm SHA256 -LiteralPath $candidate).Hash
$state.OutputBytes=(Get-Item -LiteralPath $candidate).Length
$state.VirtualBytes=$info.'virtual-size'
$state.SourceBytes=$sourceBytes
$state.Check='passed'
$state.PayloadCompare='passed'
Save-CompressionState 'publishing_verified_output'
$backupPath=$null
if(Test-Path -LiteralPath $output){
$backupRoot=[IO.Path]::GetFullPath($BackupDirectory)
$backupFolder=Join-Path $backupRoot ('qcow2-before-compression-'+$runId)
New-Item -ItemType Directory -Path $backupFolder -Force|Out-Null
$backupPath=[IO.Path]::GetFullPath((Join-Path $backupFolder ([IO.Path]::GetFileName($output))))
if(-not$backupPath.StartsWith($backupRoot.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase) -or (Test-Path -LiteralPath $backupPath)){throw 'Unexpected backup target.'}
Move-Item -LiteralPath $output -Destination $backupPath
}
try{Move-Item -LiteralPath $candidate -Destination $output}
catch{if($backupPath -and -not(Test-Path -LiteralPath $output)){Move-Item -LiteralPath $backupPath -Destination $output};throw}
$committed=$true
$state.PreviousOutputBackup=$backupPath
$state.CompletedUtc=[DateTime]::UtcNow.ToString('o')
Save-CompressionState 'complete'
return [PSCustomObject]$state
}catch{
$state.Error=$_.Exception.Message
Save-CompressionState 'failed'
throw
}finally{
if(-not$committed -and (Test-Path -LiteralPath $candidate)){
if([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($candidate)) -ne $outputDir){throw 'Temporary-image deletion escaped the output directory.'}
Remove-Item -LiteralPath $candidate -Force
}
}
+93
View File
@@ -0,0 +1,93 @@
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
Assert-PowerShell51
function Assert-BootTest { param([bool]$Condition,[string]$Message) if (-not $Condition) { throw $Message } }
function Assert-BootPending {
param([scriptblock]$Action,[string]$ExpectedMessage)
$message=$null
try { & $Action|Out-Null } catch { $message=$_.Exception.Message }
Assert-BootTest ($message -and $message -match $ExpectedMessage) ('Expected boot verification to wait or fail: '+$ExpectedMessage+'; got: '+$message)
}
$tokens=$null;$errors=$null
$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $root 'Verify-BuiltImage.ps1'),[ref]$tokens,[ref]$errors)
if ($errors.Count) { throw ($errors|Out-String) }
$blocks=@($ast.FindAll({param($node)
$node -is [System.Management.Automation.Language.ScriptBlockAst] -and
$node.ParamBlock.Parameters.Count -eq 5 -and
$node.ParamBlock.Parameters[2].Name.VariablePath.UserPath -eq 'VerifyActivation'
},$true))
Assert-BootTest ($blocks.Count -eq 1) 'The guest boot verification probe was not found uniquely.'
$probe=$blocks[0].GetScriptBlock()
$fixture=Join-Path $root ('local\boot verification tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
$priorProgramData=$env:ProgramData
$priorProgramFiles=$env:ProgramFiles
try {
$env:ProgramData=Join-Path $fixture 'ProgramData'
$env:ProgramFiles=Join-Path $fixture 'Program Files'
$activationPath=Join-Path $env:ProgramData 'PveImageBuilder\windows-activation.json'
$cloudbaseLog=Join-Path $env:ProgramFiles 'Cloudbase Solutions\Cloudbase-Init\log\cloudbase-init.log'
foreach ($path in @($activationPath,$cloudbaseLog)) { New-Item -ItemType Directory -Path (Split-Path -Parent $path) -Force|Out-Null }
& {
function Reset-BootTest {
$global:PveGuestBootTest=@{Host='kms.example.com';Port=1688}
$report=@{SchemaVersion=1;Outcome='Failed';Attempted=$true;KmsHost='kms.example.com';KmsPort=1688;CompletedUtc=[DateTime]::UtcNow.ToString('o');Message='Mocked unavailable KMS server.'}
[IO.File]::WriteAllText($activationPath,($report|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
[IO.File]::WriteAllText($cloudbaseLog,('INFO Script "C:\LocalScripts\30-WindowsActivation.ps1" ended with exit code: 0'+[Environment]::NewLine+'INFO Plugins execution done'))
}
function Get-LocalUser {
param($Name,$ErrorAction)
if ($Name -eq 'Admin') { return $null }
[PSCustomObject]@{SID=[PSCustomObject]@{Value='S-1-5-21-1-2-3-500'};Enabled=$true}
}
function Get-Service { param($Name) [PSCustomObject]@{Name=$Name;StartType='Automatic'} }
function Get-ItemProperty { param($LiteralPath,$Name) [PSCustomObject]@{fDenyTSConnections=0;UserAuthentication=1} }
function Get-NetTCPConnection { param($LocalPort,$State,$ErrorAction) [PSCustomObject]@{LocalPort=3389;State='Listen'} }
function Get-NetFirewallProfile { [PSCustomObject]@{Enabled='False'} }
function Get-CimInstance {
param($ClassName,$Filter,$ErrorAction)
switch ($ClassName) {
'Win32_OperatingSystem' { [PSCustomObject]@{Version='10.0.26100'} }
'SoftwareLicensingService' { [PSCustomObject]@{KeyManagementServiceMachine=$global:PveGuestBootTest.Host;KeyManagementServicePort=$global:PveGuestBootTest.Port} }
'SoftwareLicensingProduct' { [PSCustomObject]@{Description='Windows Operating System, VOLUME_KMSCLIENT channel';LicenseIsAddon=$false} }
default { throw ('Unexpected guest CIM query: '+$ClassName) }
}
}
function Invoke-CimMethod { throw 'Boot verification must not issue an activation request.' }
foreach ($outcome in @('Failed','Skipped','Activated','AlreadyActivated')) {
Reset-BootTest
$report=Get-Content -Raw -LiteralPath $activationPath|ConvertFrom-Json
$report.Outcome=$outcome
[IO.File]::WriteAllText($activationPath,($report|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
$proof=& $probe $false $false $true 'kms.example.com' 1688
Assert-BootTest ($proof.Boot -eq 'passed' -and $proof.CloudbaseLocalScriptsCompleted -and $proof.KmsSettingsReapplied -and $proof.WindowsKmsClientInstalled -and $proof.WindowsActivation.Outcome -eq $outcome) 'Completed initialization incorrectly required activation success.'
}
Reset-BootTest
Remove-Item -LiteralPath $activationPath
Assert-BootPending { & $probe $false $false $true 'kms.example.com' 1688 } 'Waiting for the optional activation script'
foreach ($log in @('INFO Executing plugin LocalScriptsPlugin',
'INFO Script "C:\LocalScripts\30-WindowsActivation.ps1" ended with exit code: 0',
('INFO Script "C:\LocalScripts\30-WindowsActivation.ps1" ended with exit code: 1'+[Environment]::NewLine+'INFO Plugins execution done'))) {
Reset-BootTest
[IO.File]::WriteAllText($cloudbaseLog,$log)
Assert-BootPending { & $probe $false $false $true 'kms.example.com' 1688 } 'Waiting for Cloudbase-Init LocalScripts'
}
Reset-BootTest
$global:PveGuestBootTest.Host='stale.example.com'
Assert-BootPending { & $probe $false $false $true 'kms.example.com' 1688 } 'KMS settings were not reapplied'
Reset-BootTest
$report=Get-Content -Raw -LiteralPath $activationPath|ConvertFrom-Json
$report.CompletedUtc=$null
[IO.File]::WriteAllText($activationPath,($report|ConvertTo-Json))
Assert-BootPending { & $probe $false $false $true 'kms.example.com' 1688 } 'diagnostics are incomplete'
Remove-Item -LiteralPath $activationPath,$cloudbaseLog
$proof=& $probe $false $false $false '' 1688
Assert-BootTest ($proof.Boot -eq 'passed') 'Legacy or blank KMS configurations acquired an activation requirement.'
}
}
finally {
$env:ProgramData=$priorProgramData
$env:ProgramFiles=$priorProgramFiles
Remove-Variable -Name PveGuestBootTest -Scope Global -ErrorAction SilentlyContinue
}
[PSCustomObject]@{EarlyShutdownRegression='passed';ActivationFailure='accepted after initialization completes';IncompleteLocalScripts='wait';KmsReadback='passed';LegacyAndBlank='passed';HostOperations='mocked'}
+202
View File
@@ -0,0 +1,202 @@
[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}
}
+116
View File
@@ -0,0 +1,116 @@
[CmdletBinding()]
param()
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
Assert-PowerShell51
. (Join-Path $root 'scripts\CloudbaseInit.Resources.ps1')
function Assert-CloudbaseTest {
param([bool]$Condition,[string]$Message)
if (-not $Condition) { throw $Message }
}
function Assert-CloudbaseFailure {
param([scriptblock]$Action,[string]$Pattern)
$failed=$false
try { & $Action | Out-Null } catch { $failed=$true; if ($_.Exception.Message -notmatch $Pattern) { throw } }
if (-not $failed) { throw ('Expected failure: '+$Pattern) }
}
function New-CloudbaseTestMsi {
param([string]$Path,[string]$Product='Cloudbase-Init 1.1.8',[string]$Platform='x64')
$installer=$null;$database=$null;$view=$null;$summary=$null
try {
$installer=New-Object -ComObject WindowsInstaller.Installer
$database=$installer.GetType().InvokeMember('OpenDatabase','InvokeMethod',$null,$installer,@($Path,3))
foreach ($sql in @('CREATE TABLE `Property` (`Property` CHAR(72) NOT NULL, `Value` CHAR(0) LOCALIZABLE PRIMARY KEY `Property`)',('INSERT INTO `Property` (`Property`, `Value`) VALUES (''ProductName'', '''+$Product+''')'))) {
$view=$database.GetType().InvokeMember('OpenView','InvokeMethod',$null,$database,@($sql))
$view.GetType().InvokeMember('Execute','InvokeMethod',$null,$view,$null) | Out-Null
[Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) | Out-Null
$view=$null
}
$summary=$database.GetType().InvokeMember('SummaryInformation','GetProperty',$null,$database,@(1))
$summary.GetType().InvokeMember('Property','SetProperty',$null,$summary,@(7,($Platform+';1033'))) | Out-Null
$summary.GetType().InvokeMember('Persist','InvokeMethod',$null,$summary,$null) | Out-Null
$database.GetType().InvokeMember('Commit','InvokeMethod',$null,$database,$null) | Out-Null
} finally {
foreach ($comObject in @($summary,$view,$database,$installer)) {
if ($null -ne $comObject) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($comObject) | Out-Null }
}
}
}
$fixture=Join-Path $root ('local\cloudbase cache tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $fixture -Force | Out-Null
$valid=Join-Path $fixture 'valid.msi'
$x86=Join-Path $fixture 'x86.msi'
$wrong=Join-Path $fixture 'wrong.msi'
$html=Join-Path $fixture 'error page.msi'
New-CloudbaseTestMsi -Path $valid
New-CloudbaseTestMsi -Path $x86 -Platform 'Intel'
New-CloudbaseTestMsi -Path $wrong -Product 'Other installer'
[IO.File]::WriteAllText($html,'<html>Download error</html>')
Assert-PveCloudbaseInitInstaller -Path $valid -OsArch AMD64
Assert-PveCloudbaseInitInstaller -Path $x86 -OsArch i386
Assert-CloudbaseFailure { Assert-PveCloudbaseInitInstaller -Path $wrong } 'Unexpected MSI product'
Assert-CloudbaseFailure { Assert-PveCloudbaseInitInstaller -Path $html } 'not a valid matching MSI'
Assert-CloudbaseFailure { Assert-PveCloudbaseInitInstaller -Path $x86 } 'Incorrect MSI architecture'
$assets=Join-Path $fixture 'chosen assets $literal'
& {
$script:downloadCalls=0
$script:downloadMode='valid'
function Invoke-PveCloudbaseInitDownload {
param($Uri,$Destination)
$script:downloadCalls++
if ($script:downloadMode -eq 'failure') { [IO.File]::WriteAllText($Destination,'partial download'); throw 'Simulated download failure.' }
if ($script:downloadMode -eq 'html') { Copy-Item -LiteralPath $html -Destination $Destination; return }
$source=$valid
if ($Uri -like '*_x86.msi') { $source=$x86 }
Copy-Item -LiteralPath $source -Destination $Destination
}
$cache=Get-PveCloudbaseInitCachePath -AssetsRoot $assets
$script:downloadMode='failure'
Assert-CloudbaseFailure { Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets } 'Simulated download failure'
Assert-CloudbaseTest (-not (Test-Path -LiteralPath $cache)) 'A failed download was published as a cache.'
$script:downloadMode='valid'
$first=Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets
Assert-CloudbaseTest ($first -eq $cache) 'Cache path differs from the selected asset directory.'
$originalHash=(Get-FileHash -LiteralPath $valid).Hash
Assert-CloudbaseTest ((Get-FileHash -LiteralPath $cache).Hash -eq $originalHash) 'Caching changed the MSI bytes.'
$before=$script:downloadCalls
$script:downloadMode='failure'
$second=Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets
Assert-CloudbaseTest ($second -eq $cache -and $script:downloadCalls -eq $before) 'A valid cache required network access.'
[IO.File]::WriteAllText($cache,'damaged cache')
$script:downloadMode='html'
Assert-CloudbaseFailure { Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets } 'not a valid matching MSI'
Assert-CloudbaseTest ([IO.File]::ReadAllText($cache) -eq 'damaged cache') 'An invalid download overwrote the previous file.'
$script:downloadMode='valid'
Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets | Out-Null
Assert-CloudbaseTest ((Get-FileHash -LiteralPath $cache).Hash -eq $originalHash) 'The corrupt cache was not repaired.'
$beta=Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets -BetaRelease
$otherArch=Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets -OsArch i386
Assert-CloudbaseTest ($beta -ne $cache -and $otherArch -ne $cache -and $otherArch -ne $beta) 'Architecture/release caches overlap.'
Assert-CloudbaseTest (-not @(Get-ChildItem -LiteralPath (Split-Path -Parent $cache) -Filter '*.partial.msi').Count) 'Partial downloads were left behind.'
}
$settings=[PSCustomObject]@{ProjectRoot=$root;AssetsRoot=$assets;UpstreamRoot=(Join-Path $root 'vendor\windows-imaging-tools');QemuManifestPath=(Join-Path $fixture 'unused-tool-manifest.json')}
. (Join-Path $root 'scripts\Import-ImagingTools.ps1') -Settings $settings
$module=Get-Module WinImageBuilder
& $module {
function script:Invoke-PveCloudbaseInitDownload { throw 'Image staging must reuse the validated cache.' }
} | Out-Null
foreach ($mode in @('generated','legacy','custom')) {
$imageResources=Join-Path $fixture ('image '+$mode+'\UnattendResources')
New-Item -ItemType Directory -Path $imageResources -Force | Out-Null
$msi=''
if ($mode -eq 'generated') { $msi=Get-PveCloudbaseInitCachePath -AssetsRoot $assets }
if ($mode -eq 'custom') { $msi=$valid }
& $module {
param($Resources,$Msi,$Config)
Download-CloudbaseInit -resourcesDir $Resources -osArch AMD64 -MsiPath $Msi -CloudbaseInitConfigPath $Config
} $imageResources $msi (Join-Path $root 'config\cloudbase-init.conf')
Assert-CloudbaseTest ((Get-FileHash -LiteralPath (Join-Path $imageResources 'CloudbaseInit.msi')).Hash -eq (Get-FileHash -LiteralPath $valid).Hash) ('Image MSI staging failed: '+$mode)
Assert-CloudbaseTest ((Get-FileHash -LiteralPath (Join-Path $imageResources 'cloudbase-init.conf')).Hash -eq (Get-FileHash -LiteralPath (Join-Path $root 'config\cloudbase-init.conf')).Hash) 'Cloudbase-Init configuration staging changed.'
}
Assert-CloudbaseFailure {
& $module { param($Resources,$Missing); Download-CloudbaseInit -resourcesDir $Resources -osArch AMD64 -MsiPath $Missing } $imageResources (Join-Path $fixture 'missing custom.msi')
} 'does not exist'
Assert-CloudbaseTest (Test-Path -LiteralPath (Get-PveCloudbaseInitCachePath -AssetsRoot $assets)) 'Staging removed the host cache.'
[PSCustomObject]@{MsiValidation='passed';FailedDownload='not published';CacheReuse='offline';CorruptCache='repaired';InvalidDownload='previous file preserved';ArchitectureAndRelease='separate';PartialCleanup='passed';ImageStaging='generated, legacy and custom';PathsWithSpaces='passed'}
+154
View File
@@ -0,0 +1,154 @@
[CmdletBinding()]
param()
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
Assert-PowerShell51
$dependencies=Get-BuildDependencies
. (Join-Path $root 'scripts\Builder.Workflow.ps1')
function Assert-EnvironmentFailure {
param([scriptblock]$Action,[string]$Pattern)
$failed=$false
try { & $Action | Out-Null }
catch { $failed=$true; if ($_.Exception.Message -notmatch $Pattern) { throw } }
if (-not $failed) { throw ('Expected environment failure: '+$Pattern) }
}
& {
$global:PveEnvironmentTestState=@{}
$global:PveEnvironmentTestState.Mode='ok'
$global:PveEnvironmentTestState.Switches=@([PSCustomObject]@{Name='external';SwitchType='External'})
$global:PveEnvironmentTestState.Prompts=0
$oscdimg=Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe'
function Assert-PowerShell51 { if ($global:PveEnvironmentTestState.Mode -eq 'powershell') { throw 'Wrong shell.' } }
function Assert-Administrator { if ($global:PveEnvironmentTestState.Mode -eq 'administrator') { throw 'Not elevated.' } }
function Get-Module {
param($Name,[switch]$ListAvailable)
if ($Name -eq 'Hyper-V' -and $ListAvailable) {
if ($global:PveEnvironmentTestState.Mode -ne 'module_missing') { [PSCustomObject]@{Name='Hyper-V'} }
return
}
Microsoft.PowerShell.Core\Get-Module -Name $Name
}
function Import-Module {
param($Name,$ErrorAction)
if ($Name -ne 'Hyper-V') { throw 'Unexpected module import.' }
if ($global:PveEnvironmentTestState.Mode -eq 'module_broken') { throw 'Broken module.' }
}
function Get-Service {
param($Name,$ErrorAction)
if ($Name -ne 'vmms') { throw 'Unexpected service query.' }
if ($global:PveEnvironmentTestState.Mode -eq 'service_missing') { return }
$status='Running'
if ($global:PveEnvironmentTestState.Mode -eq 'service_stopped') { $status='Stopped' }
[PSCustomObject]@{Name=$Name;Status=$status}
}
function Get-CimInstance {
param($ClassName,$ErrorAction)
if ($ClassName -ne 'Win32_ComputerSystem') { throw 'Unexpected hardware query.' }
[PSCustomObject]@{HypervisorPresent=($global:PveEnvironmentTestState.Mode -ne 'hypervisor');NumberOfLogicalProcessors=8;TotalPhysicalMemory=32GB}
}
function Get-VMHost {
param($ErrorAction)
if ($global:PveEnvironmentTestState.Mode -eq 'permission') { throw 'Access denied.' }
[PSCustomObject]@{Name='Mock build host'}
}
function Get-VMSwitch { param($ErrorAction) $global:PveEnvironmentTestState.Switches }
function Test-Path {
param($LiteralPath,$PathType)
if ($LiteralPath -eq $oscdimg) { return $global:PveEnvironmentTestState.Mode -ne 'adk' }
if ($PathType) { return Microsoft.PowerShell.Management\Test-Path -LiteralPath $LiteralPath -PathType $PathType }
Microsoft.PowerShell.Management\Test-Path -LiteralPath $LiteralPath
}
function Get-Item {
param($LiteralPath)
if ($LiteralPath -eq $oscdimg) { return [PSCustomObject]@{VersionInfo=[PSCustomObject]@{FileVersion='10.0.26100.1'}} }
Microsoft.PowerShell.Management\Get-Item -LiteralPath $LiteralPath
}
function Read-Host { param($Prompt) $global:PveEnvironmentTestState.Prompts++; throw 'An invalid environment must stop before prompting.' }
$cases=@{
powershell='Windows PowerShell 5.1';administrator='管理员权限';module_missing='启用 Hyper-V';
module_broken='无法加载 Hyper-V';service_missing='vmms';service_stopped='vmms';
hypervisor='BIOS/UEFI';permission='无法管理 Hyper-V';adk='Deployment Tools.*https://learn.microsoft.com'
}
foreach ($mode in $cases.Keys) {
$global:PveEnvironmentTestState.Mode=$mode
Assert-EnvironmentFailure { Get-BuilderHost -AllowMissingExternalSwitch } $cases[$mode]
}
$global:PveEnvironmentTestState.Mode='ok'
$hostInfo=Get-BuilderHost
if ($hostInfo.Switches[0].Name -ne 'external' -or $hostInfo.LogicalProcessors -ne 8 -or $hostInfo.MemoryGiB -ne 32 -or -not $hostInfo.Adk.OscdimgPath) { throw 'Environment inventory is incorrect.' }
$global:PveEnvironmentTestState.Switches=@()
if (@((Get-BuilderHost -AllowMissingExternalSwitch).Switches).Count) { throw 'Missing switch was invented.' }
Assert-EnvironmentFailure { Get-BuilderHost } '外部交换机'
$global:PveEnvironmentTestState.Switches=@([PSCustomObject]@{Name='external';SwitchType='External'})
foreach ($mode in @('module_missing','adk')) {
$global:PveEnvironmentTestState.Mode=$mode
Assert-EnvironmentFailure { Invoke-ImageBuilderWizard } $cases[$mode]
}
if ($global:PveEnvironmentTestState.Prompts) { throw 'Wizard continued to ask for resources in an invalid environment.' }
# Exercise the actual -WhatIf entry in an isolated fixture. Native environment
# queries are mocked above; any attempt to build or mount an ISO is rejected.
$fixture=Join-Path $root ('local\environment tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
foreach ($directory in @('scripts','config','local','assets','resources')) { New-Item -ItemType Directory -Path (Join-Path $fixture $directory) -Force | Out-Null }
foreach ($file in @('Build-Image.ps1','scripts\Build.Validation.ps1','scripts\Builder.Workflow.ps1','scripts\Configuration.Common.ps1','scripts\Package.Validation.ps1','scripts\ProductKeys.Common.ps1','scripts\Software.Selection.ps1','config\Kms.ClientKeys.psd1')) {
Copy-Item -LiteralPath (Join-Path $root $file) -Destination (Join-Path $fixture $file)
}
$common=@'
$ProjectRoot=Split-Path -Parent $PSScriptRoot
function Get-ProjectSettings { Get-Content -Raw -LiteralPath (Join-Path $ProjectRoot 'local\settings.json') | ConvertFrom-Json }
function Get-BuildDependencies { Get-Content -Raw -LiteralPath (Join-Path $ProjectRoot 'local\dependencies.json') | ConvertFrom-Json }
'@
[IO.File]::WriteAllText((Join-Path $fixture 'scripts\Project.Common.ps1'),$common)
$import=@'
param($Settings)
function Get-WindowsImageConfig { param($ConfigFilePath) Get-Content -Raw -LiteralPath $ConfigFilePath | ConvertFrom-Json }
function Get-WimFileImagesInfo { param($WimFilePath) [PSCustomObject]@{ImageName='Windows 11 Pro';ImageVersion=[version]'10.0.26100.1'} }
function New-WindowsOnlineImage { throw 'Preflight must never start a build.' }
function Mount-DiskImage { throw 'This WIM-only test must not mount an ISO.' }
'@
[IO.File]::WriteAllText((Join-Path $fixture 'scripts\Import-ImagingTools.ps1'),$import)
foreach ($file in @('install.wim','Windows.iso','virtio.iso','qemu-img.exe')) { [IO.File]::WriteAllText((Join-Path $fixture ('assets\'+$file)),'Fixture; never executed.') }
[IO.File]::WriteAllText((Join-Path $fixture 'resources\virtio-win-guest-tools.exe'),'Unsigned fixture; filename only.')
[IO.File]::WriteAllText((Join-Path $fixture 'resources\software-selection.json'),(@{SchemaVersion=1;InstallChrome=$false;InstallVSCode=$false;ServicingIsoPath=(Join-Path $fixture 'assets\Windows.iso')}|ConvertTo-Json))
$dependencies.Qemu.ExecutableSHA256=(Get-FileHash -LiteralPath (Join-Path $fixture 'assets\qemu-img.exe')).Hash
[IO.File]::WriteAllText((Join-Path $fixture 'local\dependencies.json'),($dependencies|ConvertTo-Json -Depth 4))
$manifest=Join-Path $fixture 'local\tool.json'
[IO.File]::WriteAllText($manifest,(@{Version=$dependencies.Qemu.Version;PublishedHashVerified=$true;ExecutablePath=(Join-Path $fixture 'assets\qemu-img.exe')}|ConvertTo-Json))
$settings=@{ProjectRoot=$fixture;AssetsRoot=(Join-Path $fixture 'assets');UpstreamRoot=(Join-Path $root 'vendor\windows-imaging-tools');GitPath=(Get-Command git.exe).Source;QemuManifestPath=$manifest}
[IO.File]::WriteAllText((Join-Path $fixture 'local\settings.json'),($settings|ConvertTo-Json))
$config=@{wim_file_path=(Join-Path $fixture 'assets\install.wim');virtio_iso_path=(Join-Path $fixture 'assets\virtio.iso');image_path=(Join-Path $fixture 'assets\preflight.qcow2');virtual_disk_format='QCOW2';image_type='KVM';disk_layout='UEFI';custom_resources_path=(Join-Path $fixture 'resources');external_switch='external';cpu_count=4;ram_size=4GB;disk_size=64GB;image_name='Windows 11 Pro'}
$configPath=Join-Path $fixture 'local\config.json'
[IO.File]::WriteAllText($configPath,($config|ConvertTo-Json))
function Get-PSDrive {
param($Name,$PSProvider)
$free=1TB
if ($global:PveEnvironmentTestState.Mode -eq 'disk_space') { $free=1GB }
[PSCustomObject]@{Free=$free}
}
foreach ($mode in @('module_missing','adk')) {
$global:PveEnvironmentTestState.Mode=$mode
Assert-EnvironmentFailure { & (Join-Path $fixture 'Build-Image.ps1') -ConfigPath $configPath -WhatIf } $cases[$mode]
}
$global:PveEnvironmentTestState.Mode='ok'
$preflight=& (Join-Path $fixture 'Build-Image.ps1') -ConfigPath $configPath -WhatIf
if ($preflight.HostChecks -ne 'passed') { throw 'WhatIf skipped host checks.' }
$config.cpu_count=9
[IO.File]::WriteAllText($configPath,($config|ConvertTo-Json))
Assert-EnvironmentFailure { & (Join-Path $fixture 'Build-Image.ps1') -ConfigPath $configPath -WhatIf } 'CPU or memory'
$config.cpu_count=4
$config.ram_size=64GB
[IO.File]::WriteAllText($configPath,($config|ConvertTo-Json))
Assert-EnvironmentFailure { & (Join-Path $fixture 'Build-Image.ps1') -ConfigPath $configPath -WhatIf } 'CPU or memory'
$config.ram_size=4GB
[IO.File]::WriteAllText($configPath,($config|ConvertTo-Json))
$global:PveEnvironmentTestState.Mode='disk_space'
Assert-EnvironmentFailure { & (Join-Path $fixture 'Build-Image.ps1') -ConfigPath $configPath -WhatIf } '空间不足'
$global:PveEnvironmentTestState.Mode='ok'
$config.image_name='Missing edition'
[IO.File]::WriteAllText($configPath,($config|ConvertTo-Json))
Assert-EnvironmentFailure { & (Join-Path $fixture 'Build-Image.ps1') -ConfigPath $configPath -WhatIf } 'edition was not found'
if (Test-Path -LiteralPath $config.image_path) { throw 'Preflight created an image.' }
if (Test-Path -LiteralPath (Join-Path $fixture 'assets\logs')) { throw 'Preflight entered the build stage.' }
[PSCustomObject]@{EnvironmentFailures=$cases.Count;MissingSwitch='prompt allowed only after environment checks';Wizard='stops before resources';WhatIfHostChecks='passed; missing dependencies rejected';CpuMemoryDiskAndEdition='checked; invalid inputs rejected';BuildSideEffects='none';EnvironmentQueries='mocked'}
}
+101
View File
@@ -0,0 +1,101 @@
[CmdletBinding()]
param()
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
Assert-PowerShell51
$fixture=Join-Path $root ('local\guest software tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $fixture -Force|Out-Null
foreach ($file in @('Software.Selection.ps1','Package.Validation.ps1')) {
Copy-Item -LiteralPath (Join-Path $root ('scripts\'+$file)) -Destination (Join-Path $fixture $file)
}
foreach ($file in @('chrome-enterprise64.msi','VSCodeSetup-x64.exe','virtio-win-guest-tools.exe')) {
[IO.File]::WriteAllText((Join-Path $fixture $file),'Fixture; never executed.')
}
# Redirect resources and return exit codes to the test caller. All operating-system
# operations below are mocked; this test never installs software or changes services.
$hook=[IO.File]::ReadAllText((Join-Path $root 'hooks\RunBeforeCloudbaseInitInstall.ps1'))
$hook=$hook.Replace("'C:\UnattendResources\CustomResources'",("'"+$fixture+"'"))
$hook=$hook.Replace('exit 1005','return 1005').Replace('exit 0','return 0').Replace('exit 1','return 1')
$guestHook=[scriptblock]::Create($hook)
& {
$script:guestState=@{}
$script:installerCalls=New-Object 'System.Collections.Generic.List[string]'
$script:signatureStatus='Valid'
$script:installedChrome=Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'
$script:installedCode=Join-Path $env:ProgramFiles 'Microsoft VS Code\Code.exe'
function Test-Path {
param($LiteralPath,$PathType)
if ($LiteralPath -eq 'C:\UnattendResources\config.ini') { return $true }
if ($LiteralPath -eq $script:installedChrome) { return [bool]$script:guestState.Chrome }
if ($LiteralPath -eq $script:installedCode) { return [bool]$script:guestState.VSCode }
Microsoft.PowerShell.Management\Test-Path -LiteralPath $LiteralPath -PathType Leaf
}
function Get-Item {
param($LiteralPath)
if ($LiteralPath -in @($script:installedChrome,$script:installedCode)) {
return [PSCustomObject]@{VersionInfo=[PSCustomObject]@{ProductVersion='999.0.0'}}
}
Microsoft.PowerShell.Management\Get-Item -LiteralPath $LiteralPath
}
function Get-ItemProperty {
param($Path,$ErrorAction)
if ($script:guestState.VirtIO) { [PSCustomObject]@{DisplayName='Virtio-win-guest-tools';DisplayVersion='999.0.0'} }
}
function Get-AuthenticodeSignature {
param($LiteralPath)
$publisher='Microsoft Corporation'
if ([IO.Path]::GetExtension($LiteralPath) -eq '.msi') { $publisher='Google LLC' }
$certificate=[PSCustomObject]@{Publisher=$publisher}
$certificate | Add-Member -MemberType ScriptMethod -Name GetNameInfo -Value {param($Type,$ForIssuer) $this.Publisher}
[PSCustomObject]@{Status=$script:signatureStatus;SignerCertificate=$certificate}
}
function Start-Process {
param($FilePath,$ArgumentList,$WindowStyle,[switch]$PassThru)
if ($WindowStyle -ne 'Hidden') { throw 'Unexpected visible installer.' }
$name='Chrome'
if ($FilePath -like '*VSCodeSetup-*') { $name='VSCode' }
elseif ($FilePath -like '*virtio-win-guest-tools.exe') { $name='VirtIO' }
elseif ($FilePath -ne 'msiexec.exe') { throw 'Unexpected process in guest test.' }
$script:installerCalls.Add($name)
$script:guestState[$name]=$true
$exitCode=0
if ($name -eq 'VirtIO') { $exitCode=3010 }
$process=[PSCustomObject]@{Handle=[IntPtr]::Zero;ExitCode=$exitCode}
$process | Add-Member -MemberType ScriptMethod -Name WaitForExit -Value {}
return $process
}
function Get-Service {
param($Name,$ErrorAction)
if ($Name -ne 'qemu-ga') { throw 'Unexpected service query.' }
if ($script:guestState.VirtIO) { [PSCustomObject]@{Name=$Name} }
}
function Set-Service {
param($Name,$StartupType)
if ($Name -ne 'qemu-ga' -or $StartupType -ne 'Automatic') { throw 'Unexpected service mutation.' }
}
$checked=0
foreach ($chrome in @($false,$true)) {
foreach ($code in @($false,$true)) {
$selection=@{SchemaVersion=1;InstallChrome=$chrome;InstallVSCode=$code}
[IO.File]::WriteAllText((Join-Path $fixture 'software-selection.json'),($selection|ConvertTo-Json))
$script:guestState=@{}
$script:installerCalls.Clear()
$first=& $guestHook
if ($first -ne 1005) { throw 'First pass did not request the installer reboot.' }
$expected=1+[int]$chrome+[int]$code
if ($script:installerCalls.Count -ne $expected -or
($script:installerCalls.Contains('Chrome') -ne $chrome) -or
($script:installerCalls.Contains('VSCode') -ne $code)) { throw 'Guest installed an incorrect software selection.' }
$resumed=& $guestHook
if ($resumed -ne 0 -or $script:installerCalls.Count -ne $expected) { throw 'Guest reinstalled software or requested another reboot on resume.' }
$checked++
}
}
$script:guestState=@{}
$script:installerCalls.Clear()
$script:signatureStatus='HashMismatch'
$failed=& $guestHook 2>$null
if ($failed -ne 1 -or $script:installerCalls.Count) { throw 'Guest did not stop before installing an invalidly signed package.' }
[PSCustomObject]@{GuestSoftwareCombinations=$checked;RebootResume='passed; no repeat installation';InvalidSignature='stopped before installation';SystemOperations='mocked'}
}
+72
View File
@@ -0,0 +1,72 @@
[CmdletBinding()]
param([string]$ChromePath,[string]$VSCodePath)
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
Assert-PowerShell51
. (Join-Path $root 'scripts\Package.Validation.ps1')
$fixture=Join-Path $root ('local\signature tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $fixture -Force|Out-Null
function Assert-SignatureThrows {
param([scriptblock]$Action,[string]$Pattern)
try { & $Action; throw 'Expected validation to reject the installer.' }
catch { if ($_.Exception.Message -notmatch $Pattern) { throw } }
}
& {
$script:testSignatureStatus='Valid'
$script:testPublisher='Google LLC'
$script:signatureCalls=0
function Get-AuthenticodeSignature {
param($LiteralPath)
$script:signatureCalls++
$certificate=[PSCustomObject]@{Publisher=$script:testPublisher}
$certificate | Add-Member -MemberType ScriptMethod -Name GetNameInfo -Value {param($Type,$ForIssuer) $this.Publisher}
[PSCustomObject]@{Status=$script:testSignatureStatus;SignerCertificate=$certificate}
}
$chrome=Get-SelectedImagePackages -SkipVSCode | Where-Object Name -eq 'Chrome'
$code=Get-SelectedImagePackages -SkipChrome | Where-Object Name -eq 'VSCode'
$virtio=Get-SelectedImagePackages -SkipChrome -SkipVSCode
$msi=Join-Path $fixture 'download with any name.msi'
$exe=Join-Path $fixture 'new version 999.0 setup.exe'
[IO.File]::WriteAllText($msi,'Installer fixture; never executed.')
[IO.File]::WriteAllText($exe,'Installer fixture; never executed.')
Assert-ImagePackage -Path $msi -Package $chrome
$script:testPublisher='Google Inc'
Assert-ImagePackage -Path $msi -Package $chrome
$script:testPublisher='Microsoft Corporation'
Assert-ImagePackage -Path $exe -Package $code
Assert-SignatureThrows { Assert-ImagePackage -Path $msi -Package $chrome } 'Unexpected installer publisher'
foreach ($status in @('NotSigned','HashMismatch','NotTrusted','UnknownError')) {
$script:testSignatureStatus=$status
Assert-SignatureThrows { Assert-ImagePackage -Path $exe -Package $code } 'signature is not valid'
}
$script:testSignatureStatus='Valid'
Assert-SignatureThrows { Assert-ImagePackage -Path $msi -Package $code } 'file type'
Assert-SignatureThrows { Assert-ImagePackage -Path (Join-Path $fixture 'missing.exe') -Package $code } 'Required installer'
$guestTools=Join-Path $fixture 'virtio-win-guest-tools.exe'
[IO.File]::WriteAllText($guestTools,'Unsigned fixture with arbitrary version/content.')
$callsBefore=$script:signatureCalls
Assert-ImagePackage -Path $guestTools -Package $virtio
if ($script:signatureCalls -ne $callsBefore) { throw 'VirtIO unexpectedly requested signature validation.' }
Assert-SignatureThrows { Assert-ImagePackage -Path $exe -Package $virtio } 'filename must be'
}
$realChecks=@()
foreach ($entry in @(@{Name='Chrome';Path=$ChromePath},@{Name='VSCode';Path=$VSCodePath})) {
if (-not $entry.Path) { continue }
$package=Get-SelectedImagePackages | Where-Object Name -eq $entry.Name
Assert-ImagePackage -Path $entry.Path -Package $package
# Renaming a download must not affect its signature or acceptance.
$copy=Join-Path $fixture ('arbitrary downloaded name '+$entry.Name+[IO.Path]::GetExtension($entry.Path))
Copy-Item -LiteralPath $entry.Path -Destination $copy
Assert-ImagePackage -Path $copy -Package $package
$stream=[IO.File]::Open($copy,[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite)
try {
$stream.Position=256
$value=$stream.ReadByte()
$stream.Position=256
$stream.WriteByte([byte]($value -bxor 1))
} finally { $stream.Dispose() }
Assert-SignatureThrows { Assert-ImagePackage -Path $copy -Package $package } 'signature is not valid'
$realChecks+=$entry.Name
}
[PSCustomObject]@{SignaturePolicy='passed';InvalidSignatures='rejected';WrongPublisher='rejected';ArbitraryFilename='passed';Virtio='exact filename only';RealSignedInstallers=($realChecks -join ', ');Fixture=$fixture}
+175
View File
@@ -0,0 +1,175 @@
$ErrorActionPreference='Stop'
$repo=Split-Path -Parent $PSScriptRoot
. (Join-Path $repo 'scripts\Project.Common.ps1')
Assert-PowerShell51
. (Join-Path $repo 'scripts\Configuration.Common.ps1')
. (Join-Path $repo 'scripts\Builder.Workflow.ps1')
function Assert-KmsTest { param([bool]$Condition,[string]$Message) if (-not $Condition) { throw $Message } }
function Assert-KmsFailure {
param([scriptblock]$Action)
$failed=$false
try { & $Action | Out-Null } catch { $failed=$true }
Assert-KmsTest $failed 'Expected KMS validation failure.'
}
foreach ($case in @(
@{Address='';Host='';Port=1688},
@{Address='kms-default.cangshui.net';Host='kms-default.cangshui.net';Port=1688},
@{Address=' kms.example.com:1689 ';Host='kms.example.com';Port=1689},
@{Address='192.0.2.10';Host='192.0.2.10';Port=1688},
@{Address='[2001:db8::10]:1689';Host='2001:db8::10';Port=1689},
@{Address='[2001:db8::10]';Host='2001:db8::10';Port=1688},
@{Address='2001:db8::10';Host='2001:db8::10';Port=1688}
)) {
$endpoint=ConvertTo-PveKmsEndpoint -Address $case.Address
Assert-KmsTest ($endpoint.Address -ceq $case.Address.Trim() -and $endpoint.Host -ceq $case.Host -and $endpoint.Port -eq $case.Port) 'KMS host or port parsed incorrectly.'
}
foreach ($address in @('https://kms.example.com','kms.example.com/path','kms.example.com:0','kms.example.com:65536','kms.example.com:999999999999','kms.example.com:abc','kms host','[kms.example.com]:1688',"kms.example.com`nother",'kms.example.com;whoami')) {
Assert-KmsFailure { ConvertTo-PveKmsEndpoint -Address $address }
}
& {
$script:kmsAnswers=New-Object 'System.Collections.Generic.Queue[string]'
function Read-Host {
param($Prompt)
if (-not $script:kmsAnswers.Count) { throw 'KMS test input exhausted.' }
$script:kmsAnswers.Dequeue()
}
function Write-Host { param($Object,$ForegroundColor) }
$script:kmsAnswers.Enqueue('')
Assert-KmsTest ((Read-BuilderKmsServer).Address -ceq 'kms-default.cangshui.net') 'Default KMS choice changed.'
foreach ($answer in @('2','')) { $script:kmsAnswers.Enqueue($answer) }
Assert-KmsTest ((Read-BuilderKmsServer).Address -ceq '') 'Explicit blank was replaced by the default server.'
foreach ($answer in @('2','https://invalid',' kms.example.com:1689 ')) { $script:kmsAnswers.Enqueue($answer) }
Assert-KmsTest ((Read-BuilderKmsServer).Address -ceq 'kms.example.com:1689') 'Manual KMS input did not retry or trim.'
$script:kmsAnswers.Enqueue('Q')
Assert-KmsFailure { Read-BuilderKmsServer }
foreach ($answer in @('2','Q')) { $script:kmsAnswers.Enqueue($answer) }
Assert-KmsFailure { Read-BuilderKmsServer }
}
$fixture=Join-Path $repo ('local\kms settings tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
foreach ($directory in @('scripts','config','resources','local','assets\iso','assets\custom-resources','assets\output','LocalScripts')) { New-Item -ItemType Directory -Path (Join-Path $fixture $directory) -Force | Out-Null }
foreach ($file in @('New-ImageConfig.ps1','scripts\Project.Common.ps1','scripts\Configuration.Common.ps1','scripts\Package.Validation.ps1','scripts\ProductKeys.Common.ps1','scripts\Software.Selection.ps1','config\Kms.ClientKeys.psd1','config\image.example.ini','resources\Apply-KmsSettings.ps1','resources\Activate-Windows.ps1')) {
Copy-Item -LiteralPath (Join-Path $repo $file) -Destination (Join-Path $fixture $file)
}
[IO.File]::WriteAllText((Join-Path $fixture 'local\settings.json'),(@{ProjectRoot=$fixture;AssetsRoot=(Join-Path $fixture 'assets')}|ConvertTo-Json))
foreach ($file in @('assets\iso\Windows.iso','assets\iso\virtio-win.iso','assets\custom-resources\virtio-win-guest-tools.exe')) { [IO.File]::WriteAllText((Join-Path $fixture $file),'Fixture; never used to build an image.') }
$configurations=@()
foreach ($address in @($null,'kms.example.com:1689','')) {
$parameters=@{WimPath=(Join-Path $fixture 'assets\iso\Windows.iso');ProductKeyMode='Kms';OutputPath=(Join-Path $fixture ('assets\output\image-'+$configurations.Count+'.qcow2'));ConfigPath=(Join-Path $fixture ('local\image-'+$configurations.Count+'.ini'));SkipChrome=$true;SkipVSCode=$true}
if ($null -ne $address) { $parameters.KmsServer=$address }
$result=& (Join-Path $fixture 'New-ImageConfig.ps1') @parameters
$selection=Get-GuestSoftwareSelection -ResourceRoot $result.StagedResources
$expected=$address
if ($null -eq $expected) { $expected='kms-default.cangshui.net' }
Assert-KmsTest ($selection.KmsServer -ceq $expected -and $result.KmsServer -ceq $expected) 'KMS address did not survive configuration staging.'
Assert-KmsTest (Test-Path -LiteralPath (Join-Path $result.StagedResources 'Apply-KmsSettings.ps1')) 'Guest KMS script was not staged.'
Assert-KmsTest (Test-Path -LiteralPath (Join-Path $result.StagedResources 'Activate-Windows.ps1')) 'Guest activation script was not staged.'
$configurations+=@($result)
}
# Preserve old configurations without silently adding a public KMS endpoint.
$legacy=@{SchemaVersion=1;InstallChrome=$false;InstallVSCode=$false}
$selectionPath=Join-Path $fixture 'resources\software-selection.json'
[IO.File]::WriteAllText($selectionPath,($legacy|ConvertTo-Json))
Assert-KmsTest (-not (Get-GuestSoftwareSelection -ResourceRoot (Join-Path $fixture 'resources')).KmsServer) 'Legacy resources acquired a KMS server.'
$legacy.KmsServer=$false
[IO.File]::WriteAllText($selectionPath,($legacy|ConvertTo-Json))
Assert-KmsFailure { Get-GuestSoftwareSelection -ResourceRoot (Join-Path $fixture 'resources') }
$legacy.KmsServer='https://invalid'
[IO.File]::WriteAllText($selectionPath,($legacy|ConvertTo-Json))
Assert-KmsFailure { Get-GuestSoftwareSelection -ResourceRoot (Join-Path $fixture 'resources') }
$guestResources=Join-Path $fixture 'resources'
$localScripts=Join-Path $fixture 'LocalScripts'
Copy-Item -LiteralPath (Join-Path $repo 'scripts\Software.Selection.ps1') -Destination (Join-Path $guestResources 'Software.Selection.ps1')
foreach ($name in @('Apply-GuestNetworkSettings.ps1','Install-ManagedAdministrator.ps1')) { [IO.File]::WriteAllText((Join-Path $guestResources $name),'# Other guest actions are intentionally omitted in this fixture.') }
$hook=[IO.File]::ReadAllText((Join-Path $repo 'hooks\RunAfterCloudbaseInitInstall.ps1'))
$hook=$hook.Replace('C:\UnattendResources\CustomResources',$guestResources)
$hook=$hook.Replace('C:\ProgramData\PveImageBuilder',(Join-Path $fixture 'Diagnostics'))
$hook=$hook.Replace("Join-Path `$env:ProgramFiles 'Cloudbase Solutions\Cloudbase-Init\LocalScripts'",("'"+$localScripts+"'"))
$hook=$hook.Replace('exit 0','return 0').Replace('exit 1','return 1')
$guestHook=[scriptblock]::Create($hook)
& {
$global:PveKmsSettingsTest=@{Calls=(New-Object 'System.Collections.Generic.List[object]');ReturnCode=0;ReturnMode='Object';ApplySettings=$true;Host='';Port=0}
function Write-Host { param($Object,$ForegroundColor) }
function Test-Path {
param($LiteralPath,$PathType)
if ($LiteralPath -eq 'C:\UnattendResources\config.ini') { return $true }
Microsoft.PowerShell.Management\Test-Path -LiteralPath $LiteralPath
}
function Get-Service { param($Name,$ErrorAction) [PSCustomObject]@{Name=$Name} }
function Set-Service { param($Name,$StartupType) }
function Get-CimInstance {
param($ClassName,$ErrorAction)
Assert-KmsTest ($ClassName -eq 'SoftwareLicensingService') 'Unexpected guest CIM query.'
[PSCustomObject]@{ClassName=$ClassName;KeyManagementServiceMachine=$global:PveKmsSettingsTest.Host;KeyManagementServicePort=$global:PveKmsSettingsTest.Port}
}
function Invoke-CimMethod {
param($InputObject,$MethodName,$Arguments,$ErrorAction)
Assert-KmsTest ($MethodName -in @('SetKeyManagementServiceMachine','SetKeyManagementServicePort')) 'An activation or unexpected licensing method was called.'
$global:PveKmsSettingsTest.Calls.Add([PSCustomObject]@{Method=$MethodName;Arguments=$Arguments})
if ($global:PveKmsSettingsTest.ApplySettings -and $global:PveKmsSettingsTest.ReturnCode -eq 0) {
if ($MethodName -eq 'SetKeyManagementServiceMachine') { $global:PveKmsSettingsTest.Host=$Arguments.MachineName }
else { $global:PveKmsSettingsTest.Port=$Arguments.PortNumber }
}
switch ($global:PveKmsSettingsTest.ReturnMode) {
'Object' { [PSCustomObject]@{ReturnValue=$global:PveKmsSettingsTest.ReturnCode} }
'Scalar' { [uint32]$global:PveKmsSettingsTest.ReturnCode }
'Missing' { [PSCustomObject]@{} }
'NullValue' { [PSCustomObject]@{ReturnValue=$null} }
'Null' { $null }
}
}
# Blank and legacy choices must not call Windows licensing or create startup scripts.
foreach ($selection in @(@{SchemaVersion=1;InstallChrome=$false;InstallVSCode=$false;KmsServer=''},@{SchemaVersion=1;InstallChrome=$false;InstallVSCode=$false})) {
[IO.File]::WriteAllText($selectionPath,($selection|ConvertTo-Json))
Assert-KmsTest ((& $guestHook) -eq 0 -and $global:PveKmsSettingsTest.Calls.Count -eq 0) 'Blank/legacy choice configured KMS.'
Assert-KmsTest (-not (Test-Path -LiteralPath (Join-Path $localScripts '20-KmsSettings.ps1'))) 'Blank/legacy choice created a KMS startup script.'
Assert-KmsTest (-not (Test-Path -LiteralPath (Join-Path $localScripts '30-WindowsActivation.ps1'))) 'Blank/legacy choice created an activation startup script.'
}
foreach ($result in $configurations[0..1]) {
Copy-Item -LiteralPath (Join-Path $result.StagedResources 'software-selection.json') -Destination $selectionPath -Force
$global:PveKmsSettingsTest.Calls.Clear()
Assert-KmsTest ((& $guestHook) -eq 0) 'Guest hook failed to configure KMS.'
Assert-KmsTest ((Get-FileHash -LiteralPath (Join-Path $localScripts '30-WindowsActivation.ps1')).Hash -eq
(Get-FileHash -LiteralPath (Join-Path $guestResources 'Activate-Windows.ps1')).Hash) 'The first-boot activation script was not copied intact.'
$endpoint=ConvertTo-PveKmsEndpoint -Address $result.KmsServer
Assert-KmsTest ($global:PveKmsSettingsTest.Calls.Count -eq 2 -and $global:PveKmsSettingsTest.Calls[0].Arguments.MachineName -eq $endpoint.Host -and $global:PveKmsSettingsTest.Calls[1].Arguments.PortNumber -eq $endpoint.Port) 'Guest licensing received the wrong host or port.'
$global:PveKmsSettingsTest.Calls.Clear()
& (Join-Path $localScripts '20-KmsSettings.ps1')
Assert-KmsTest ($global:PveKmsSettingsTest.Calls.Count -eq 2 -and $global:PveKmsSettingsTest.Calls[0].Arguments.MachineName -eq $endpoint.Host) 'First-boot script did not reapply the configured KMS host.'
}
foreach ($returnMode in @('Null','Missing','NullValue','Scalar')) {
$global:PveKmsSettingsTest.ReturnMode=$returnMode
$global:PveKmsSettingsTest.Host='';$global:PveKmsSettingsTest.Port=0
& (Join-Path $localScripts '20-KmsSettings.ps1')
Assert-KmsTest ($global:PveKmsSettingsTest.Host -eq 'kms.example.com' -and $global:PveKmsSettingsTest.Port -eq 1689) 'A successful licensing method with no ReturnValue was rejected.'
}
$global:PveKmsSettingsTest.ReturnMode='Null'
$global:PveKmsSettingsTest.ApplySettings=$false
$global:PveKmsSettingsTest.Host='';$global:PveKmsSettingsTest.Port=0
Assert-KmsFailure { & (Join-Path $localScripts '20-KmsSettings.ps1') }
$global:PveKmsSettingsTest.ApplySettings=$true
$global:PveKmsSettingsTest.ReturnMode='Scalar'
$global:PveKmsSettingsTest.ReturnCode=5
Assert-KmsFailure { & (Join-Path $localScripts '20-KmsSettings.ps1') }
$global:PveKmsSettingsTest.ReturnMode='Object'
Assert-KmsFailure { & (Join-Path $localScripts '20-KmsSettings.ps1') }
Assert-KmsTest ((& $guestHook 2>$null) -eq 1) 'Guest hook ignored a licensing configuration failure.'
$failureReport=Get-Content -Raw -LiteralPath (Join-Path $fixture 'Diagnostics\post-cloudbase-init-error.json') | ConvertFrom-Json
Assert-KmsTest ($failureReport.Stage -eq 'ApplyKmsSettings' -and $failureReport.Message -match 'SetKeyManagementServiceMachine returned 5' -and $failureReport.ScriptStackTrace) 'Guest hook did not preserve the original licensing failure and stage.'
$global:PveKmsSettingsTest.ReturnCode=0
[IO.File]::WriteAllText((Join-Path $localScripts 'kms-settings.json'),(@{SchemaVersion=1;Host='https://invalid';Port=1688}|ConvertTo-Json))
$global:PveKmsSettingsTest.Calls.Clear()
Assert-KmsFailure { & (Join-Path $localScripts '20-KmsSettings.ps1') }
Assert-KmsTest ($global:PveKmsSettingsTest.Calls.Count -eq 0) 'Invalid guest settings reached Windows licensing.'
$activationTarget=Join-Path $localScripts '30-WindowsActivation.ps1'
$originalActivation=[IO.File]::ReadAllText($activationTarget)
try {
[IO.File]::WriteAllText($activationTarget,'# Pre-existing user script must be preserved.')
Assert-KmsTest ((& $guestHook 2>$null) -eq 1) 'Guest hook overwrote a different activation LocalScript.'
Assert-KmsTest ([IO.File]::ReadAllText($activationTarget) -eq '# Pre-existing user script must be preserved.') 'A pre-existing activation script was modified.'
$failureReport=Get-Content -Raw -LiteralPath (Join-Path $fixture 'Diagnostics\post-cloudbase-init-error.json') | ConvertFrom-Json
Assert-KmsTest ($failureReport.Stage -eq 'StageWindowsActivation' -and $failureReport.Message -match 'refusing to overwrite') 'Activation staging failure was not diagnosed.'
}
finally { [IO.File]::WriteAllText($activationTarget,$originalActivation,[Text.UTF8Encoding]::new($false)) }
}
[PSCustomObject]@{TwoOptionMenu='passed';DefaultCustomBlank='passed';InputValidation='passed';IndependentConfigurations=$configurations.Count;GuestAndFirstBoot='passed';CimReturnShapes='passed';ReadbackVerification='passed';FailureHandling='passed';LicensingCalls='mocked; no activation request'}
+17
View File
@@ -0,0 +1,17 @@
[CmdletBinding()]
param([string]$HivePath)
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Offline.Registry.ps1')
$directory=Join-Path $root ('local\registry-tests-'+[guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $directory -Force|Out-Null
$bad=Join-Path $directory 'not-a-hive'
[IO.File]::WriteAllText($bad,'not a Windows registry hive')
$rejected=$false
try{Get-OfflineImageState -HivePath $bad|Out-Null}catch{$rejected=$true}
if(-not$rejected){throw 'Malformed hive was accepted.'}
if($HivePath){
$state=Get-OfflineImageState -HivePath $HivePath
if($state -ne 'IMAGE_STATE_GENERALIZE_RESEAL_TO_OOBE'){throw ('Expected a generalized image, got: '+$state)}
[PSCustomObject]@{MalformedHive='rejected';ActualImageState=$state}
}else{[PSCustomObject]@{MalformedHive='rejected';ActualImageState='not_requested'}}
+98
View File
@@ -0,0 +1,98 @@
$ErrorActionPreference='Stop'
$repo=Split-Path -Parent $PSScriptRoot
. (Join-Path $repo 'scripts\Project.Common.ps1')
Assert-PowerShell51
. (Join-Path $repo 'scripts\ProductKeys.Common.ps1')
. (Join-Path $repo 'scripts\Configuration.Common.ps1')
. (Join-Path $repo 'scripts\Builder.Workflow.ps1')
function Assert-KeyTest { param([bool]$Condition,[string]$Message) if (-not $Condition) { throw $Message } }
function Assert-KeyFailure {
param([scriptblock]$Action)
$failed=$false
try { & $Action | Out-Null } catch { $failed=$true }
Assert-KeyTest $failed 'Expected key validation failure.'
}
$catalog=Get-PveKmsCatalog
$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$aliases=0
foreach ($entry in $catalog.Entries) {
Assert-PveProductKey -ProductKey $entry.Key
foreach ($name in $entry.Names) {
Assert-KeyTest ($seen.Add($name)) 'The catalog contains an ambiguous edition alias.'
Assert-KeyTest ((Get-PveKmsProductKey -ImageName $name) -ceq $entry.Key) 'Catalog edition matched a different key.'
$aliases++
}
}
$workstation='NRG8B-VKK3Q-CXVCJ-9G2XF-6Q84J'
$datacenter='D764K-2NDRG-47T6Q-P8T8W-YP6DF'
Assert-KeyTest ((Get-PveKmsProductKey -ImageName 'Windows 11 Pro for Workstations') -eq $workstation) 'Win11 workstation key changed.'
Assert-KeyTest ((Get-PveKmsProductKey -ImageName 'Windows Server 2025 SERVERDATACENTERCORE') -eq $datacenter) 'Server Core key changed.'
Assert-KeyTest (-not (Get-PveKmsProductKey -ImageName 'Windows 11 Home')) 'Home was assigned a KMS key.'
Assert-KeyFailure { Resolve-PveProductKey -ImageName 'Windows 11 Pro for Workstations' -Mode Kms -ProductKey $datacenter }
& {
$script:keyAnswers=New-Object 'System.Collections.Generic.Queue[string]'
$script:keyMessages=New-Object 'System.Collections.Generic.List[string]'
function Read-Host {
param($Prompt,[switch]$AsSecureString)
if (-not $script:keyAnswers.Count) { throw 'Key test input exhausted.' }
$answer=$script:keyAnswers.Dequeue()
if ($AsSecureString) {
if (-not $answer) { return [Security.SecureString]::new() }
return ConvertTo-SecureString $answer -AsPlainText -Force
}
return $answer
}
function Write-Host { param($Object,$ForegroundColor) $script:keyMessages.Add([string]$Object) }
$script:keyAnswers.Enqueue('')
$selected=Read-BuilderProductKey -ImageName 'Windows 11 Pro for Workstations'
Assert-KeyTest ($selected.Mode -eq 'Kms' -and $selected.Key -eq $workstation) 'Default key choice did not use the selected edition.'
$script:keyAnswers.Enqueue('2');$script:keyAnswers.Enqueue('')
$selected=Read-BuilderProductKey -ImageName 'Windows Server 2025 SERVERDATACENTER'
Assert-KeyTest ($selected.Mode -eq 'None' -and -not $selected.Key) 'Manual blank did not remain empty.'
$custom='AAAAA-BBBBB-CCCCC-DDDDD-EEEEE'
foreach ($answer in @('2','invalid-private-value',$custom.ToLowerInvariant())) { $script:keyAnswers.Enqueue($answer) }
$selected=Read-BuilderProductKey -ImageName 'Windows 11 Pro'
Assert-KeyTest ($selected.Mode -eq 'Custom' -and $selected.Key -eq $custom) 'Manual input was not normalized or retried.'
Assert-KeyTest (($script:keyMessages -join ' ') -notmatch 'AAAAA|invalid-private-value') 'A manual key was disclosed by a prompt or error.'
$script:keyAnswers.Enqueue('Q')
Assert-KeyFailure { Read-BuilderProductKey -ImageName 'Windows 11 Pro' }
$script:keyAnswers.Enqueue('2');$script:keyAnswers.Enqueue('Q')
Assert-KeyFailure { Read-BuilderProductKey -ImageName 'Windows 11 Pro' }
$script:keyAnswers.Enqueue('1');$script:keyAnswers.Enqueue('');$script:keyAnswers.Enqueue('')
$selected=Read-BuilderProductKey -ImageName 'Windows 11 Home'
Assert-KeyTest ($selected.Mode -eq 'None') 'Unsupported edition could select a KMS key.'
}
$fixture=Join-Path $repo ('local\product key tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
foreach ($directory in @('scripts','config','resources','local','assets\iso','assets\custom-resources','assets\output')) { New-Item -ItemType Directory -Path (Join-Path $fixture $directory) -Force | Out-Null }
foreach ($file in @('New-ImageConfig.ps1','scripts\Project.Common.ps1','scripts\Configuration.Common.ps1','scripts\Package.Validation.ps1','scripts\ProductKeys.Common.ps1','scripts\Software.Selection.ps1','config\Kms.ClientKeys.psd1','config\image.example.ini')) { Copy-Item -LiteralPath (Join-Path $repo $file) -Destination (Join-Path $fixture $file) }
$settings=[PSCustomObject]@{ProjectRoot=$fixture;AssetsRoot=(Join-Path $fixture 'assets')}
[IO.File]::WriteAllText((Join-Path $fixture 'local\settings.json'),($settings|ConvertTo-Json))
foreach ($file in @('assets\iso\Windows.iso','assets\iso\virtio-win.iso','assets\custom-resources\virtio-win-guest-tools.exe')) { [IO.File]::WriteAllText((Join-Path $fixture $file),'Fixture; never used to build an image.') }
Import-Module (Join-Path $repo 'vendor\windows-imaging-tools\Config.psm1') -Force
$counter=0
foreach ($case in @(@{Edition='Windows 11 Pro for Workstations';Mode='Kms';Key='';Expected=$workstation},@{Edition='Windows Server 2025 SERVERDATACENTER';Mode='Kms';Key='';Expected=$datacenter},@{Edition='Windows Server 2025 SERVERDATACENTER';Mode='None';Key='';Expected=''},@{Edition='Windows 11 Pro';Mode='Custom';Key='AAAAA-BBBBB-CCCCC-DDDDD-EEEEE';Expected='AAAAA-BBBBB-CCCCC-DDDDD-EEEEE'})) {
$parameters=@{WimPath=(Join-Path $fixture 'assets\iso\Windows.iso');ImageName=$case.Edition;ProductKeyMode=$case.Mode;ProductKey=$case.Key;OutputPath=(Join-Path $fixture ('assets\output\image-'+$counter+'.qcow2'));ConfigPath=(Join-Path $fixture ('local\configs\image-'+$counter+'.ini'));SkipChrome=$true;SkipVSCode=$true}
$result=& (Join-Path $fixture 'New-ImageConfig.ps1') @parameters
$parsed=Get-WindowsImageConfig -ConfigFilePath $result.ConfigPath
Assert-KeyTest ([string]$parsed.product_key -ceq $case.Expected) 'The selected key did not reach the generated INI.'
$selection=Get-Content -Raw -LiteralPath (Join-Path $result.StagedResources 'software-selection.json') | ConvertFrom-Json
Assert-KeyTest ($selection.ProductKeyMode -eq $case.Mode) 'The selection mode did not reach staged metadata.'
Assert-KeyTest (($result|ConvertTo-Json) -notmatch 'AAAAA|NRG8B|D764K') 'The configuration summary contains a product key.'
$counter++
}
$realSettings=Get-Content -Raw -LiteralPath (Join-Path $repo 'local\settings.json') | ConvertFrom-Json
. (Join-Path $repo 'scripts\Import-ImagingTools.ps1') -Settings $realSettings
foreach ($mode in @('Kms','None','Custom')) {
$image=[PSCustomObject]@{ImageName='Windows Server 2025 SERVERDATACENTER';ImageArchitecture='AMD64';ImageVersion=[version]'10.0.26100.1';ImageInstallationType='Server'}
$requested=''
$expected=$datacenter
if ($mode -eq 'None') { $expected='' }
elseif ($mode -eq 'Custom') { $requested='AAAAA-BBBBB-CCCCC-DDDDD-EEEEE';$expected=$requested }
$xmlPath=Join-Path $fixture ('selected-'+$mode+'.xml')
& (Get-Module WinImageBuilder) {param($InputPath,$OutputPath,$Image,$Mode,$Key);$script:PveProductKeyMode=$Mode;Generate-UnattendXml -inUnattendXmlPath $InputPath -outUnattendXmlPath $OutputPath -image $Image -productKey $Key -administratorPassword 'Fixture!NeverUsedToBuild'} (Join-Path $realSettings.UpstreamRoot 'UnattendTemplate.xml') $xmlPath $image $mode $requested
[xml]$document=[IO.File]::ReadAllText($xmlPath)
$ns=[Xml.XmlNamespaceManager]::new($document.NameTable);$ns.AddNamespace('u',$document.DocumentElement.NamespaceURI)
$keyNode=$document.SelectSingleNode('//u:settings[@pass="specialize"]/u:component[@name="Microsoft-Windows-Shell-Setup"]/u:ProductKey',$ns)
Assert-KeyTest ([string]$keyNode.InnerText -ceq $expected) 'Unattended setup ignored the key selection.'
}
[PSCustomObject]@{CatalogEntries=$catalog.Entries.Count;EditionAliases=$aliases;TwoOptionMenu='passed';SecureManualInput='passed';ExplicitBlank='preserved in INI and XML';WrongEdition='rejected';IndependentConfigurations=$counter;UnattendedModes='passed'}
+33
View File
@@ -0,0 +1,33 @@
[CmdletBinding()]
param([switch]$RunConversionCheck)
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
Assert-PowerShell51
$settings=Get-ProjectSettings
$errorsFound=@()
foreach($file in Get-ChildItem -LiteralPath $root -Filter '*.ps1' -File -Recurse|Where-Object{$_.FullName -notlike ($root+'\vendor\*') -and $_.FullName -notlike ($root+'\local\*') -and $_.FullName -notlike ($root+'\data\*')}){
$tokens=$null;$syntaxErrors=$null
[System.Management.Automation.Language.Parser]::ParseFile($file.FullName,[ref]$tokens,[ref]$syntaxErrors)|Out-Null
if($syntaxErrors.Count){$errorsFound+=@($syntaxErrors)}
}
if($errorsFound.Count){throw ($errorsFound|Out-String)}
$example=[IO.File]::ReadAllText((Join-Path $root 'config\image.example.ini'))
if($example -notmatch '(?m)^administrator_password=__EPHEMERAL_BUILD_PASSWORD__'){throw 'Example configuration is not sanitized.'}
$revision=(& $settings.GitPath -C $settings.UpstreamRoot rev-parse HEAD)-join ''
$project=Get-BuildDependencies
if($revision -ne $project.UpstreamCommit){throw 'Unexpected upstream revision.'}
. (Join-Path $root 'scripts\Import-ImagingTools.ps1') -Settings $settings
$checked=@{Syntax='passed';SanitizedConfig='passed';PinnedSubmodule='passed';ModuleImport='passed';Conversion='not_requested'}
if($RunConversionCheck){
$dir=Join-Path $root 'local\smoke test'
New-Item -ItemType Directory -Path $dir -Force|Out-Null
$source=Join-Path $dir 'test input.raw';$output=Join-Path $dir ('test output-'+[guid]::NewGuid().ToString('N').Substring(0,8)+'.qcow2')
$bytes=New-Object byte[] (2MB)
for($i=0;$i -lt 64KB;$i++){$bytes[$i]=[byte]($i%251)}
[IO.File]::WriteAllBytes($source,$bytes)
& (Get-Module WinImageBuilder) {param($Source,$Output);Convert-VirtualDisk -vhdPath $Source -outPath $Output -format qcow2 -CompressQcow2 $true} $source $output
if(-not(Test-Path -LiteralPath $output)){throw 'Module conversion bridge did not publish an image.'}
$checked.Conversion='passed; content compared; paths contain spaces'
}
[PSCustomObject]$checked
+51
View File
@@ -0,0 +1,51 @@
$ErrorActionPreference='Stop'
$repo=Split-Path -Parent $PSScriptRoot
. (Join-Path $repo 'scripts\Project.Common.ps1')
Assert-PowerShell51
$fixture=Join-Path $repo ('local\specialize tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $fixture | Out-Null
$text=[IO.File]::ReadAllText((Join-Path $repo 'resources\Builder.Specialize.ps1'))
$text=$text.Replace("Join-Path `$env:ProgramData 'PveImageBuilder'",("'"+$fixture+"'"))
$text=$text.Replace('exit 0','return 0').Replace('exit 1','return 1')
$scriptBlock=[scriptblock]::Create($text)
& {
$global:PveSpecializeTest=@{Type='Server';PolicyWrites=0;Fail=$false;Guest=$true;Errors=@()}
function Test-Path {
param($LiteralPath,$PathType)
if ($LiteralPath -eq 'C:\UnattendResources\config.ini') { return $global:PveSpecializeTest.Guest }
if ($LiteralPath -like 'HKLM:*') { return $true }
Microsoft.PowerShell.Management\Test-Path -LiteralPath $LiteralPath
}
function Get-ItemProperty {
param($LiteralPath,$Name,$ErrorAction)
if ($global:PveSpecializeTest.Fail) { throw 'Simulated setup error.' }
[PSCustomObject]@{InstallationType=$global:PveSpecializeTest.Type}
}
function New-Item {
param($ItemType,$Path,[switch]$Force)
if ($Path -like 'HKLM:*') { return }
Microsoft.PowerShell.Management\New-Item -ItemType $ItemType -Path $Path -Force
}
function New-ItemProperty { param($LiteralPath,$Name,$Value,$PropertyType,[switch]$Force) $global:PveSpecializeTest.PolicyWrites++ }
function Set-ItemProperty { param($LiteralPath,$Name,$Value,$ErrorAction) if($Name -eq 'ImageGenerationLog-ERROR'){$global:PveSpecializeTest.Errors+= $Value} }
function Stop-Service { throw 'Specialize must not wait on Windows services.' }
function Read-Host { throw 'Specialize must never wait for user input.' }
foreach ($type in @('Server','Server Core','Client')) {
$global:PveSpecializeTest.Type=$type
$global:PveSpecializeTest.PolicyWrites=0
$result=& $scriptBlock
if ($result -ne 0) { throw 'Specialize did not complete.' }
$state=Get-Content -Raw -LiteralPath (Join-Path $fixture 'specialize-status.json') | ConvertFrom-Json
if ($state.Phase -ne 'complete' -or $state.InstallationType -ne $type) { throw 'Specialize proof does not match the selected OS.' }
$expected=0
if ($type -eq 'Client') { $expected=2 }
if ($global:PveSpecializeTest.PolicyWrites -ne $expected) { throw 'Client policies were applied to the wrong OS.' }
}
$global:PveSpecializeTest.Fail=$true
if ((& $scriptBlock 2>$null) -ne 1 -or -not $global:PveSpecializeTest.Errors.Count) { throw 'Setup error did not fail promptly with a host log.' }
$global:PveSpecializeTest.Guest=$false
$rejected=$false
try { & $scriptBlock | Out-Null } catch { $rejected=$true }
if (-not $rejected) { throw 'Specialize was allowed to run on the host.' }
[PSCustomObject]@{Server='passed';ServerCore='passed';Client='passed';ErrorHandling='no user input; host error emitted';HostGuard='passed';RegistryAndServiceCalls='mocked'}
}
+35
View File
@@ -0,0 +1,35 @@
$ErrorActionPreference='Stop'
$repo=Split-Path -Parent $PSScriptRoot
. (Join-Path $repo 'scripts\Project.Common.ps1')
Assert-PowerShell51
$settings=Get-ProjectSettings
. (Join-Path $repo 'scripts\Import-ImagingTools.ps1') -Settings $settings
$directory=Join-Path $repo ('local\unattend tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $directory | Out-Null
$checked=0
foreach ($name in @('Windows 11 Pro for Workstations','Windows Server 2025 SERVERSTANDARD','Windows Server 2025 SERVERSTANDARDCORE','Windows Server 2025 SERVERDATACENTER','Windows Server 2025 SERVERDATACENTERCORE')) {
$image=[PSCustomObject]@{ImageArchitecture='AMD64';ImageName=$name;ImageVersion=[version]'10.0.26100.1';ImageInstallationType=$(if($name -like 'Windows Server*'){'Server'}else{'Client'})}
$output=Join-Path $directory ('unattend-'+$checked+'.xml')
& (Get-Module WinImageBuilder) {param($InputPath,$OutputPath,$Image); Generate-UnattendXml -inUnattendXmlPath $InputPath -outUnattendXmlPath $OutputPath -image $Image -administratorPassword 'Fixture!NeverUsedToBuild'} (Join-Path $settings.UpstreamRoot 'UnattendTemplate.xml') $output $image
[xml]$xml=[IO.File]::ReadAllText($output)
$ns=[Xml.XmlNamespaceManager]::new($xml.NameTable)
$ns.AddNamespace('u',$xml.DocumentElement.NamespaceURI)
$key=$xml.SelectSingleNode('//u:settings[@pass="specialize"]/u:component[@name="Microsoft-Windows-Shell-Setup"]/u:ProductKey',$ns)
$expected=$null
if ($name -like '*SERVERSTANDARD*') { $expected='TVRH6-WHNXV-R9WG3-9XRFY-MY832' }
elseif ($name -like '*SERVERDATACENTER*') { $expected='D764K-2NDRG-47T6Q-P8T8W-YP6DF' }
if ($expected) {
if (-not $key -or $key.InnerText -ne $expected) { throw 'Server setup key is incorrect.' }
$skip=$xml.SelectSingleNode('//u:component[@name="Microsoft-Windows-Security-SPP-UX"]/u:SkipAutoActivation',$ns)
if (-not $skip -or $skip.InnerText -ne 'true') { throw 'Automatic activation was not suppressed.' }
} elseif ($key) { throw 'A setup key was injected into the client image.' }
foreach ($setting in @('HideOnlineAccountScreens','HideLocalAccountScreen','HideOEMRegistrationScreen')) {
$node=$xml.SelectSingleNode(('//u:OOBE/u:'+$setting),$ns)
if (-not $node -or $node.InnerText -ne 'true') { throw ('Missing modern OOBE setting: '+$setting) }
}
if (($xml.SelectNodes('//u:AutoLogon/u:Password/u:Value',$ns))[0].InnerText -ne 'Fixture!NeverUsedToBuild') { throw 'Automatic logon configuration changed.' }
$checked++
}
. (Join-Path $repo 'scripts\Unattend.Adapter.ps1')
if ((Get-PveSetupProductKey -Image $image -RequestedProductKey 'AAAAA-BBBBB-CCCCC-DDDDD-EEEEE') -ne 'AAAAA-BBBBB-CCCCC-DDDDD-EEEEE') { throw 'Explicit product key was overwritten.' }
[PSCustomObject]@{ImageCases=$checked;ServerSetupKeys='passed';ModernOobe='passed';ClientKey='unchanged';ExplicitKey='preserved';AutoLogon='preserved'}
+60
View File
@@ -0,0 +1,60 @@
[CmdletBinding()]
param([string]$VirtioIsoPath)
$ErrorActionPreference='Stop'
$root=Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
Assert-PowerShell51
. (Join-Path $root 'scripts\Configuration.Common.ps1')
$tar=Get-Command tar.exe -ErrorAction Stop
$fixture=Join-Path $root ('local\virtio resource tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
$fixtureInput=Join-Path $fixture 'input'
New-Item -ItemType Directory -Path $fixtureInput -Force|Out-Null
$member=Join-Path $fixtureInput 'virtio-win-guest-tools.exe'
[IO.File]::WriteAllBytes($member,[byte[]]@(0,1,2,128,255,0,13,10))
$originalHash=(Get-FileHash -LiteralPath $member -Algorithm SHA256).Hash
$settings=[PSCustomObject]@{ProjectRoot=$fixture;AssetsRoot=(Join-Path $fixture 'assets')}
$archive=Join-Path $fixture 'resource fixture.iso'
& $tar.Source -cf $archive -C $fixtureInput -- 'virtio-win-guest-tools.exe'
if($LASTEXITCODE -ne 0){throw 'Could not create extraction fixture.'}
$prepared=Initialize-VirtioGuestTools -Settings $settings -VirtioIsoPath $archive -RefreshFromIso
if((Get-FileHash -LiteralPath $prepared -Algorithm SHA256).Hash -ne $originalHash){throw 'Binary extraction changed the installer bytes.'}
$wrong=Join-Path $fixtureInput 'renamed-guest-tools.exe'
Copy-Item -LiteralPath $member -Destination $wrong
$rejected=$false
try{Assert-ImagePackage -Path $wrong -Package (Get-SelectedImagePackages -SkipChrome -SkipVSCode)}catch{if($_.Exception.Message -notmatch 'filename must be'){throw};$rejected=$true}
if(-not$rejected){throw 'A renamed VirtIO installer was accepted.'}
$missing=Join-Path $fixture 'missing member.iso'
[IO.File]::WriteAllText((Join-Path $fixtureInput 'placeholder.txt'),'No installer.')
& $tar.Source -cf $missing -C $fixtureInput -- 'placeholder.txt'
if($LASTEXITCODE -ne 0){throw 'Could not create missing-member fixture.'}
$rejected=$false
try{Initialize-VirtioGuestTools -Settings $settings -VirtioIsoPath $missing -RefreshFromIso|Out-Null}catch{$rejected=$true}
if(-not$rejected -or (Get-FileHash -LiteralPath $prepared).Hash -ne $originalHash){throw 'A failed extraction was accepted or changed the cached installer.'}
& {
function Expand-VirtioGuestTools { throw 'A correctly named cached installer must not be extracted again.' }
function Get-AuthenticodeSignature { throw 'VirtIO should use filename validation only.' }
Initialize-VirtioGuestTools -Settings $settings -VirtioIsoPath $archive|Out-Null
}
if(@(Get-ChildItem -LiteralPath (Join-Path $fixture 'local\virtio-extract') -Directory).Count){throw 'Temporary extraction directories were left behind.'}
$actual='not_requested'
$configuration='not_requested'
if($VirtioIsoPath){
$realSettings=[PSCustomObject]@{ProjectRoot=$fixture;AssetsRoot=(Join-Path $fixture 'real assets')}
$actualInstaller=Initialize-VirtioGuestTools -Settings $realSettings -VirtioIsoPath $VirtioIsoPath -RefreshFromIso
$actual=(Get-FileHash -LiteralPath $actualInstaller -Algorithm SHA256).Hash
# Exercise the normal config entry with no standalone guest-tools file.
foreach($directory in @('scripts','config','resources','local')){New-Item -ItemType Directory -Path (Join-Path $fixture $directory) -Force|Out-Null}
foreach($name in @('New-ImageConfig.ps1','scripts\Project.Common.ps1','scripts\Configuration.Common.ps1','scripts\Package.Validation.ps1','scripts\ProductKeys.Common.ps1','scripts\Software.Selection.ps1','config\Kms.ClientKeys.psd1','config\image.example.ini')){Copy-Item -LiteralPath (Join-Path $root $name) -Destination (Join-Path $fixture $name)}
New-Item -ItemType Directory -Path (Join-Path $realSettings.AssetsRoot 'iso') -Force|Out-Null
[IO.File]::WriteAllText((Join-Path $fixture 'local\settings.json'),($realSettings|ConvertTo-Json))
$wim=Join-Path $fixtureInput 'config-only.wim'
[IO.File]::WriteAllText($wim,'Config fixture; never used to create a disk.')
Move-Item -LiteralPath $actualInstaller -Destination ($actualInstaller+'.validated-copy')
$config=& (Join-Path $fixture 'New-ImageConfig.ps1') -WimPath $wim -VirtioIsoPath $VirtioIsoPath -OutputPath (Join-Path $realSettings.AssetsRoot 'output\config-only.qcow2') -SkipChrome -SkipVSCode
if((Get-FileHash -LiteralPath (Join-Path $config.StagedResources 'virtio-win-guest-tools.exe')).Hash -ne $actual){throw 'Config generation did not stage the validated ISO installer.'}
$configuration='passed; installer extracted automatically'
}
[PSCustomObject]@{BinaryExtraction='passed';MissingMember='rejected; original preserved';WrongFilename='rejected';VirtioSignature='not required';CacheReuse='passed';Cleanup='passed';ActualIsoInstallerSHA256=$actual;ConfigGeneration=$configuration}
# The expected missing-member failure leaves tar's nonzero native exit code behind.
# Report the completed assertions as successful to callers such as GitHub Actions.
exit 0
+141
View File
@@ -0,0 +1,141 @@
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
. (Join-Path $repo 'scripts\Project.Common.ps1')
Assert-PowerShell51
function Assert-ActivationTest { param([bool]$Condition,[string]$Message) if (-not $Condition) { throw $Message } }
$fixture = Join-Path $repo ('local\windows activation tests ' + [guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $fixture -Force | Out-Null
$scriptPath = Join-Path $fixture '30-WindowsActivation.ps1'
$settingsPath = Join-Path $fixture 'kms-settings.json'
Copy-Item -LiteralPath (Join-Path $repo 'resources\Activate-Windows.ps1') -Destination $scriptPath
$previousProgramData = $env:ProgramData
try {
$env:ProgramData = Join-Path $fixture 'ProgramData'
$reportPath = Join-Path $env:ProgramData 'PveImageBuilder\windows-activation.json'
& {
function New-TestLicense {
param([int]$Status=0,[string]$Description='Windows Operating System, VOLUME_KMSCLIENT channel',[bool]$Addon=$false)
[PSCustomObject]@{ID='00000000-0000-0000-0000-000000000001';Description=$Description;LicenseIsAddon=$Addon;LicenseStatus=$Status;LicenseStatusReason=0;PartialProductKey='ABCDE'}
}
function Reset-ActivationTest {
$global:PveActivationTest = @{
Products=@(New-TestLicense);CurrentProducts=@(New-TestLicense -Status 1)
Reads=0;Calls=(New-Object 'System.Collections.Generic.List[object]')
ReturnMode='Object';ReturnCode=0;MethodFailure=$false;ReadbackFailure=$false
Host='kms.example.com';Port=1689;DiagnosticFailure=$false
}
[IO.File]::WriteAllText($settingsPath,(@{SchemaVersion=1;Host='kms.example.com';Port=1689} | ConvertTo-Json))
}
function Get-CimInstance {
param($ClassName,$Filter,$ErrorAction)
if ($ClassName -eq 'SoftwareLicensingService') {
return [PSCustomObject]@{KeyManagementServiceMachine=$global:PveActivationTest.Host;KeyManagementServicePort=$global:PveActivationTest.Port}
}
Assert-ActivationTest ($ClassName -eq 'SoftwareLicensingProduct') 'Unexpected licensing query.'
Assert-ActivationTest ($Filter -eq "ApplicationID='55c92734-d682-4d71-983e-d6ec3f16059f' AND PartialProductKey IS NOT NULL") 'Activation query did not restrict installed Windows licenses.'
$global:PveActivationTest.Reads++
if ($global:PveActivationTest.Reads -eq 1) { return $global:PveActivationTest.Products }
if ($global:PveActivationTest.ReadbackFailure) { throw 'Mocked license readback failure.' }
$global:PveActivationTest.CurrentProducts
}
function Invoke-CimMethod {
param($InputObject,$MethodName,$OperationTimeoutSec,$ErrorAction)
Assert-ActivationTest ($MethodName -eq 'Activate' -and $OperationTimeoutSec -eq 30) 'Activation was not bounded or used an unexpected method.'
Assert-ActivationTest ($InputObject.Description -match '\bVOLUME_KMSCLIENT\b' -and -not $InputObject.LicenseIsAddon) 'Activation targeted a non-KMS or addon license.'
$global:PveActivationTest.Calls.Add($InputObject.ID)
if ($global:PveActivationTest.MethodFailure) { throw 'Mocked unavailable KMS server or network timeout.' }
switch ($global:PveActivationTest.ReturnMode) {
'Object' { [PSCustomObject]@{ReturnValue=$global:PveActivationTest.ReturnCode} }
'Scalar' { [uint32]$global:PveActivationTest.ReturnCode }
'Missing' { [PSCustomObject]@{} }
'NullValue' { [PSCustomObject]@{ReturnValue=$null} }
'Null' { $null }
}
}
function New-Item {
param($ItemType,$Path,[switch]$Force)
if ($global:PveActivationTest.DiagnosticFailure) { throw 'Mocked diagnostics write failure.' }
Microsoft.PowerShell.Management\New-Item -ItemType $ItemType -Path $Path -Force:$Force
}
function Invoke-ActivationTest {
& $scriptPath | Out-Null
Assert-ActivationTest ($LASTEXITCODE -eq 0) 'Activation failure blocked Cloudbase-Init or requested a reboot.'
$raw = Get-Content -Raw -LiteralPath $reportPath
Assert-ActivationTest ($raw -notmatch 'ABCDE' -and $raw -notmatch 'PartialProductKey') 'Activation diagnostics disclosed product key information.'
$report = $raw | ConvertFrom-Json
Assert-ActivationTest ($report.CompletedUtc -and $report.StartedUtc -and $report.KmsHost -eq 'kms.example.com' -and $report.KmsPort -eq 1689) 'Activation diagnostics are incomplete.'
$report
}
foreach ($mode in @('Object','Scalar','Missing','NullValue','Null')) {
Reset-ActivationTest
$global:PveActivationTest.ReturnMode = $mode
$report = Invoke-ActivationTest
Assert-ActivationTest ($report.Outcome -eq 'Activated' -and $report.Attempted -and $report.LicenseStatus -eq 1 -and $global:PveActivationTest.Calls.Count -eq 1) 'A licensed readback was not accepted after activation.'
}
Reset-ActivationTest
$global:PveActivationTest.Products = @(New-TestLicense -Status 1)
$report = Invoke-ActivationTest
Assert-ActivationTest ($report.Outcome -eq 'AlreadyActivated' -and -not $report.Attempted -and $global:PveActivationTest.Calls.Count -eq 0) 'An already activated license received another request.'
foreach ($products in @(@(),@(New-TestLicense -Description 'Windows Operating System, RETAIL channel'),@(New-TestLicense -Addon $true))) {
Reset-ActivationTest
$global:PveActivationTest.Products = $products
$report = Invoke-ActivationTest
Assert-ActivationTest ($report.Outcome -eq 'Skipped' -and -not $report.Attempted -and $global:PveActivationTest.Calls.Count -eq 0) 'An absent, retail, or addon license was activated.'
}
Reset-ActivationTest
$global:PveActivationTest.Products = @((New-TestLicense),(New-TestLicense))
$report = Invoke-ActivationTest
Assert-ActivationTest ($report.Outcome -eq 'Failed' -and -not $report.Attempted -and $global:PveActivationTest.Calls.Count -eq 0) 'Ambiguous KMS licenses were activated.'
foreach ($mode in @('Object','Scalar')) {
Reset-ActivationTest
$global:PveActivationTest.ReturnMode = $mode
$global:PveActivationTest.ReturnCode = 5
$report = Invoke-ActivationTest
Assert-ActivationTest ($report.Outcome -eq 'Failed' -and $report.ReturnValue -eq 5 -and $report.Message -match 'error 5') 'A CIM failure code was reported as successful activation.'
}
Reset-ActivationTest
$global:PveActivationTest.CurrentProducts = @(New-TestLicense -Status 5)
$global:PveActivationTest.CurrentProducts[0].LicenseStatusReason = 3221549092
$report = Invoke-ActivationTest
Assert-ActivationTest ($report.Outcome -eq 'Failed' -and $report.LicenseStatus -eq 5 -and $report.LicenseStatusReason -eq 3221549092) 'An unlicensed readback was reported as activation success.'
foreach ($failure in @('MethodFailure','ReadbackFailure')) {
Reset-ActivationTest
$global:PveActivationTest[$failure] = $true
$report = Invoke-ActivationTest
Assert-ActivationTest ($report.Outcome -eq 'Failed' -and $report.Attempted -and $report.Message -match 'Mocked' -and $global:PveActivationTest.Calls.Count -eq 1) 'Licensing/network errors were lost or caused another attempt.'
}
foreach ($field in @('Host','Port')) {
Reset-ActivationTest
if ($field -eq 'Host') { $global:PveActivationTest.Host = 'stale.example.com' }
else { $global:PveActivationTest.Port = 1688 }
$report = Invoke-ActivationTest
Assert-ActivationTest ($report.Outcome -eq 'Failed' -and -not $report.Attempted -and $global:PveActivationTest.Calls.Count -eq 0) 'A stale KMS endpoint received activation.'
}
foreach ($invalid in @('missing','malformed','invalid')) {
Reset-ActivationTest
switch ($invalid) {
'missing' { Remove-Item -LiteralPath $settingsPath }
'malformed' { [IO.File]::WriteAllText($settingsPath,'{broken') }
'invalid' { [IO.File]::WriteAllText($settingsPath,(@{SchemaVersion=1;Host='https://invalid';Port=1689} | ConvertTo-Json)) }
}
& $scriptPath | Out-Null
$report = Get-Content -Raw -LiteralPath $reportPath | ConvertFrom-Json
Assert-ActivationTest ($LASTEXITCODE -eq 0 -and $report.Outcome -eq 'Failed' -and -not $report.Attempted -and $global:PveActivationTest.Calls.Count -eq 0) 'Invalid settings blocked initialization or reached activation.'
}
Reset-ActivationTest
$global:PveActivationTest.MethodFailure = $true
$global:PveActivationTest.DiagnosticFailure = $true
& $scriptPath | Out-Null
Assert-ActivationTest ($LASTEXITCODE -eq 0) 'An unwritable diagnostic directory blocked Cloudbase-Init.'
}
}
finally {
$env:ProgramData = $previousProgramData
Remove-Variable -Name PveActivationTest -Scope Global -ErrorAction SilentlyContinue
}
[PSCustomObject]@{Activation='mocked; host never activated';SuccessfulReadback='passed';NetworkAndLicensingErrors='nonblocking';MissingOrOtherLicense='skipped';RequestTimeoutSeconds=30;RequestsPerRun=1;Diagnostics='passed';CloudbaseExitCode=0}
+241
View File
@@ -0,0 +1,241 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
Assert-PowerShell51
. (Join-Path $root 'scripts\Configuration.Common.ps1')
. (Join-Path $root 'scripts\Builder.Workflow.ps1')
. (Join-Path $root 'scripts\Software.Selection.ps1')
function Assert-Test { param([bool]$Condition,[string]$Message) if (-not $Condition) { throw $Message } }
function Assert-Throws {
param([scriptblock]$Action,[string]$Pattern)
$thrown = $false
try { & $Action | Out-Null } catch { $thrown=$true; if ($_.Exception.Message -notmatch $Pattern) { throw } }
Assert-Test $thrown ('Expected failure: ' + $Pattern)
}
$fixture = Join-Path $root ('local\wizard tests ' + [guid]::NewGuid().ToString('N').Substring(0,8))
foreach ($directory in @('scripts','resources','config','local','assets\iso','assets\custom-resources','assets\output')) { New-Item -ItemType Directory -Path (Join-Path $fixture $directory) -Force | Out-Null }
foreach ($name in @('New-ImageConfig.ps1','scripts\Project.Common.ps1','scripts\Build.Dependencies.psd1','scripts\Configuration.Common.ps1','scripts\Package.Validation.ps1','scripts\ProductKeys.Common.ps1','scripts\Software.Selection.ps1','config\Kms.ClientKeys.psd1','config\image.example.ini')) { Copy-Item -LiteralPath (Join-Path $root $name) -Destination (Join-Path $fixture $name) }
[IO.File]::WriteAllText((Join-Path $fixture 'resources\guest-marker.ps1'),'# fixture')
foreach ($name in @('chrome-enterprise64.msi','VSCodeSetup-test.exe','virtio-win-guest-tools.exe')) {
$path = Join-Path $fixture ('assets\custom-resources\' + $name)
[IO.File]::WriteAllText($path,('test data; never executed: ' + $name))
}
function Get-AuthenticodeSignature {
param($LiteralPath)
$publisher='Microsoft Corporation'
if ([IO.Path]::GetExtension($LiteralPath) -eq '.msi') { $publisher='Google LLC' }
$certificate=[PSCustomObject]@{Publisher=$publisher}
$certificate | Add-Member -MemberType ScriptMethod -Name GetNameInfo -Value {param($Type,$ForIssuer) $this.Publisher}
$status='Valid'
if ([IO.File]::ReadAllText($LiteralPath) -eq 'corrupted fixture') { $status='HashMismatch' }
[PSCustomObject]@{Status=$status;SignerCertificate=$certificate}
}
$fixtureSettings = [PSCustomObject]@{ProjectRoot=$fixture;AssetsRoot=(Join-Path $fixture 'assets');UpstreamRoot=(Join-Path $root 'vendor\windows-imaging-tools');QemuManifestPath=(Join-Path $fixture 'absent-tool.json')}
[IO.File]::WriteAllText((Join-Path $fixture 'local\settings.json'),($fixtureSettings|ConvertTo-Json))
foreach ($name in @('install.wim','Windows.iso','virtio-win.iso')) { [IO.File]::WriteAllText((Join-Path $fixture ('assets\iso\'+$name)),'Fixture; not a real ISO or WIM.') }
Assert-Test ((Find-WindowsInstallerIso -ProjectRoot $fixture -AssetsRoot $fixtureSettings.AssetsRoot) -eq (Join-Path $fixture 'assets\iso\Windows.iso')) 'Existing asset ISO fallback changed.'
New-Item -ItemType Directory -Path (Join-Path $fixture 'data\iso') -Force|Out-Null
[IO.File]::WriteAllText((Join-Path $fixture 'data\iso\Windows.iso'),'Source selection fixture.')
[IO.File]::WriteAllText((Join-Path $fixture 'data\iso\virtio-win.iso'),'Driver ISO selection fixture.')
Assert-Test ((Find-WindowsInstallerIso -ProjectRoot $fixture -AssetsRoot $fixtureSettings.AssetsRoot) -eq (Join-Path $fixture 'data\iso\Windows.iso')) 'The data Windows ISO was not preferred.'
Assert-Test ((Find-VirtioInstallerIso -ProjectRoot $fixture -AssetsRoot $fixtureSettings.AssetsRoot) -eq (Join-Path $fixture 'data\iso\virtio-win.iso')) 'The data VirtIO ISO was not preferred.'
Import-Module (Join-Path $root 'vendor\windows-imaging-tools\Config.psm1') -Force
$checked = 0
foreach ($skipChrome in @($false,$true)) {
foreach ($skipCode in @($false,$true)) {
$parameters = @{WimPath=(Join-Path $fixture 'assets\iso\Windows.iso');OutputPath=(Join-Path $fixture ('assets\output\disk $literal-' + $checked + '.qcow2'));SwitchName='external $literal';CpuCount=2;RamGiB=4;DiskGiB=96;TimeZone='UTC';ImageName='Windows 11 Pro';InstallUpdates=$true;SkipChrome=$skipChrome;SkipVSCode=$skipCode}
$result = & (Join-Path $fixture 'New-ImageConfig.ps1') @parameters
$selection = Get-GuestSoftwareSelection -ResourceRoot $result.StagedResources
Assert-Test ($selection.InstallChrome -eq (-not $skipChrome) -and $selection.InstallVSCode -eq (-not $skipCode)) 'Selection did not reach guest resources.'
Assert-Test ((Test-Path -LiteralPath (Join-Path $result.StagedResources 'chrome-enterprise64.msi')) -eq (-not $skipChrome)) 'Chrome staging is incorrect.'
Assert-Test ((Test-Path -LiteralPath (Join-Path $result.StagedResources 'VSCodeSetup-x64.exe')) -eq (-not $skipCode)) 'VSCode staging is incorrect.'
Assert-Test (Test-Path -LiteralPath (Join-Path $result.StagedResources 'Package.Validation.ps1')) 'Guest signature validator was not staged.'
Assert-Test (-not ($selection.Packages | Where-Object { $_.PSObject.Properties['Version'] -or $_.PSObject.Properties['SHA256'] })) 'Software selection still pins an application version or hash.'
Assert-Test (Test-Path -LiteralPath (Join-Path $result.StagedResources 'virtio-win-guest-tools.exe')) 'Required VirtIO package was omitted.'
$parsed = Get-WindowsImageConfig -ConfigFilePath $result.ConfigPath
Assert-Test ($parsed.msi_path -eq (Join-Path $fixtureSettings.AssetsRoot 'assets\CloudbaseInitSetup_Stable_x64.msi')) 'Cloudbase-Init cache path did not follow the selected asset directory.'
Assert-Test ($parsed.image_path -eq $parameters.OutputPath -and $parsed.external_switch -eq $parameters.SwitchName) 'Literal path characters were changed.'
Assert-Test ($parsed.wim_file_path -eq $parameters.WimPath -and $parsed.image_name -eq 'Windows 11 Pro') 'Source/edition did not reach the INI.'
Assert-Test ($parsed.disk_size -eq 96GB -and $parsed.ram_size -eq 4GB -and $parsed.install_updates -eq $true) 'Build parameters were not parsed.'
Assert-Test (([IO.File]::ReadAllBytes($result.ConfigPath))[0] -eq [byte][char]'[') 'Generated INI has a BOM.'
Assert-Test ($result.BuildPassword -notmatch 'Build!') 'Summary disclosed a build password.'
$checked++
}
}
$baseline = @{WimPath=(Join-Path $fixture 'assets\iso\install.wim');OutputPath=(Join-Path $fixture 'assets\output\negative.qcow2')}
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') -WimPath 'C:\does-not-exist.wim' } 'existing Windows'
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline -VirtioIsoPath 'C:\missing.iso' } 'VirtIO ISO'
$chromePath = Join-Path $fixture 'assets\custom-resources\chrome-enterprise64.msi'
Move-Item -LiteralPath $chromePath -Destination ($chromePath+'.held')
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline } 'Required installer'
$skipped = & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline -SkipChrome
Assert-Test (-not $skipped.InstallChrome) 'Skipping a missing optional installer failed.'
Move-Item -LiteralPath ($chromePath+'.held') -Destination $chromePath
$original = [IO.File]::ReadAllText($chromePath)
[IO.File]::WriteAllText($chromePath,'corrupted fixture')
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline } 'signature is not valid'
[IO.File]::WriteAllText($chromePath,$original)
[IO.File]::WriteAllText($baseline.OutputPath,'existing image; preserve')
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline } 'already exists'
Assert-Test ([IO.File]::ReadAllText($baseline.OutputPath) -eq 'existing image; preserve') 'Existing image was changed.'
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') -WimPath $baseline.WimPath -ConfigPath (Join-Path $fixture 'public.ini') } 'project local directory'
Assert-Throws { Set-ImageConfigValue -Text "image_name=old" -Key 'image_name' -Value "injected`nimage_path=x" } 'Invalid configuration'
$legacy = Get-GuestSoftwareSelection -ResourceRoot (Join-Path $fixture 'resources')
Assert-Test ($legacy.InstallChrome -and $legacy.InstallVSCode) 'Legacy selection default changed.'
$dataPackages=Join-Path $fixture 'data\custom-resources'
New-Item -ItemType Directory -Path $dataPackages -Force | Out-Null
$dataChrome=Join-Path $dataPackages 'downloaded chrome.msi'
$dataCode=Join-Path $dataPackages 'VSCodeSetup-new-version.exe'
$codePath=Join-Path $fixture 'assets\custom-resources\VSCodeSetup-test.exe'
Copy-Item -LiteralPath $chromePath -Destination $dataChrome
Copy-Item -LiteralPath $codePath -Destination $dataCode
Move-Item -LiteralPath $chromePath -Destination ($chromePath+'.held')
Move-Item -LiteralPath $codePath -Destination ($codePath+'.held')
try {
$fromData=& (Join-Path $fixture 'New-ImageConfig.ps1') -WimPath $baseline.WimPath -OutputPath (Join-Path $fixture 'assets\output\from-data.qcow2')
foreach ($entry in @(@{Source=$dataChrome;Staged='chrome-enterprise64.msi'},@{Source=$dataCode;Staged='VSCodeSetup-x64.exe'})) {
Assert-Test ((Get-FileHash -LiteralPath $entry.Source).Hash -eq (Get-FileHash -LiteralPath (Join-Path $fromData.StagedResources $entry.Staged)).Hash) 'An installer from data/custom-resources was not staged correctly.'
}
} finally {
Move-Item -LiteralPath ($chromePath+'.held') -Destination $chromePath
Move-Item -LiteralPath ($codePath+'.held') -Destination $codePath
}
$script:answers = New-Object 'System.Collections.Generic.Queue[string]'
function Read-Host { param($Prompt) if (-not $script:answers.Count) { throw 'Test input exhausted.' }; return $script:answers.Dequeue() }
$script:answers.Enqueue('bad')
$script:answers.Enqueue('7')
Assert-Test ((Read-BuilderNumber -Prompt 'Test number' -Default 4 -Minimum 1 -Maximum 8) -eq 7) 'Invalid input was not retried.'
$script:answers.Enqueue('Q')
Assert-Throws { Read-BuilderText -Prompt 'Cancel test' } '取消'
& {
$ProjectRoot=$fixture
$wimWithSpaces=Join-Path $fixture 'data\iso\Windows image with spaces.wim'
[IO.File]::WriteAllText($wimWithSpaces,'Source choice fixture; never used to build.')
$candidates=@(Get-BuilderSourceCandidates -AssetsRoot $fixtureSettings.AssetsRoot)
Assert-Test ($candidates[0].Path.StartsWith((Join-Path $fixture 'data\iso'))) 'Data sources were not listed first.'
Assert-Test (@($candidates | Where-Object Path -like '*virtio*').Count -eq 0) 'VirtIO was listed as a Windows installation source.'
Assert-Test (@($candidates | Where-Object Path -eq $wimWithSpaces).Count -eq 1) 'WIM with spaces was not listed.'
$duplicate=@(Get-BuilderSourceCandidates -AssetsRoot $fixtureSettings.AssetsRoot -SourcePath $wimWithSpaces)
Assert-Test ($duplicate.Count -eq $candidates.Count) 'Explicit source duplicated an existing list item.'
$script:answers.Enqueue('')
Assert-Test ((Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot -SourcePath $wimWithSpaces) -eq $wimWithSpaces) 'Explicit source was not the default numbered choice.'
foreach ($answer in @('0','999','2')) { $script:answers.Enqueue($answer) }
Assert-Test ((Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot) -eq $candidates[1].Path) 'Invalid source numbers were not retried.'
$script:answers.Enqueue([string]($candidates.Count+1))
$script:answers.Enqueue('C:\missing-source.iso')
$script:answers.Enqueue('"'+$wimWithSpaces+'"')
Assert-Test ((Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot) -eq $wimWithSpaces) 'Manual source choice or quoted path retry failed.'
$script:answers.Enqueue('Q')
Assert-Throws { Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot } '取消'
$script:answers.Enqueue([string]($candidates.Count+1))
$script:answers.Enqueue('Q')
Assert-Throws { Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot } '取消'
$ProjectRoot=Join-Path $fixture 'no source candidates'
$script:answers.Enqueue('')
$script:answers.Enqueue($wimWithSpaces)
Assert-Test ((Read-BuilderInstallationSource -AssetsRoot (Join-Path $ProjectRoot 'assets')) -eq $wimWithSpaces) 'Empty source list did not allow manual entry.'
}
Assert-BuilderCapacity -CpuCount '4' -RamBytes '8589934592' -HostCpuCount 32 -HostRamBytes 64GB
Assert-Throws { Assert-BuilderCapacity -CpuCount 33 -RamBytes 8GB -HostCpuCount 32 -HostRamBytes 64GB } 'CPU or memory'
Assert-Throws { Assert-BuilderCapacity -CpuCount 4 -RamBytes 65GB -HostCpuCount 32 -HostRamBytes 64GB } 'CPU or memory'
& {
$ProjectRoot=$fixture
$script:configurationCalls=0
$script:initializationCalls=0
$script:wizardConfigPaths=New-Object 'System.Collections.Generic.List[string]'
function Get-BuilderHost { [PSCustomObject]@{Switches=@([PSCustomObject]@{Name='external'});LogicalProcessors=8;MemoryGiB=32} }
function Initialize-BuilderProject { param($AssetsRoot) $script:initializationCalls++ }
function Get-ProjectSettings { $fixtureSettings }
function Get-BuilderSourceImages { param($SourcePath) [PSCustomObject]@{ImageName='Windows 11 Pro for Workstations';ImageIndex=10;ImageVersion='test'} }
function Initialize-VirtioGuestTools { param($Settings,$VirtioIsoPath,[switch]$RefreshFromIso) }
function Assert-BuilderDiskSpace { param($OutputPath,$DiskGiB) }
function Initialize-BuilderTools { throw 'Save-only must not initialize tools.' }
function Invoke-BuilderAction { throw 'Save-only must not start a build.' }
function New-BuilderConfiguration {
param($Parameters)
Assert-Test ($Parameters.KmsServer -ceq $script:expectedKmsServer) 'Wizard KMS selection did not reach configuration generation.'
$script:configurationCalls++;$script:wizardConfigPaths.Add($Parameters.ConfigPath)
[PSCustomObject]@{ConfigPath=$Parameters.ConfigPath}
}
foreach ($configureOnly in @($false,$true)) {
$script:answers.Clear()
# Assets/source/edition/key defaults, then the KMS choice.
foreach ($answer in @('','','','')) { $script:answers.Enqueue($answer) }
$script:expectedKmsServer=Get-PveDefaultKmsServer
if ($configureOnly) {
$script:answers.Enqueue('2');$script:answers.Enqueue('')
$script:expectedKmsServer=''
} else { $script:answers.Enqueue('') }
# VirtIO/switch/output/CPU/RAM/disk/timezone defaults, updates/apps false.
foreach ($answer in @('','','','','','','','N','N','N')) { $script:answers.Enqueue($answer) }
if (-not $configureOnly) { $script:answers.Enqueue('3') }
$wizard = Invoke-ImageBuilderWizard -SourcePath (Join-Path $fixture 'assets\iso\Windows.iso') -ConfigureOnly:$configureOnly
Assert-Test ($wizard.Action -eq '仅保存配置') 'Save-only action changed.'
}
Assert-Test ($script:configurationCalls -eq 2) 'Save-only did not generate exactly one configuration per run.'
Assert-Test (@($script:wizardConfigPaths | Select-Object -Unique).Count -eq 2) 'Separate wizard runs reused the same configuration file.'
Assert-Test (@($script:wizardConfigPaths | Where-Object { $_ -notlike (Join-Path $fixture 'local\configs\*.ini') }).Count -eq 0) 'Wizard configuration escaped its per-run folder.'
$script:answers.Clear()
foreach($answer in @('','','','','','','','','','','','','N','N','N','Q')){$script:answers.Enqueue($answer)}
Assert-Throws { Invoke-ImageBuilderWizard -SourcePath (Join-Path $fixture 'assets\iso\Windows.iso') } '取消'
Assert-Test ($script:configurationCalls -eq 2) 'Cancel at the final action still generated a configuration.'
$callsBefore=$script:initializationCalls
$script:answers.Clear(); $script:answers.Enqueue('Q')
Assert-Throws { Invoke-ImageBuilderWizard } '取消'
Assert-Test ($script:initializationCalls -eq $callsBefore -and $script:configurationCalls -eq 2) 'Cancel continued initialization or configuration.'
}
& {
$script:switchCreateCalls=0
$script:adapterQueries=0
$script:switchTestMode='success'
function Get-NetAdapter {
param([switch]$Physical,$ErrorAction)
$script:adapterQueries++
if($script:switchTestMode -eq 'no_adapter'){return}
[PSCustomObject]@{Name='Test Ethernet';InterfaceDescription='Mock physical NIC';LinkSpeed='1 Gbps';Status='Up'}
}
function Get-VMSwitch {
param($Name,$ErrorAction)
if($Name -eq 'taken'){[PSCustomObject]@{Name='taken';SwitchType='Internal'}}
}
function New-VMSwitch {
param($Name,$NetAdapterName,[bool]$AllowManagementOS,$ErrorAction)
$script:switchCreateCalls++
if(-not$AllowManagementOS -or $NetAdapterName -ne 'Test Ethernet'){throw 'Host NIC sharing or adapter selection is incorrect.'}
if($script:switchTestMode -eq 'failure'){throw 'Simulated switch creation error.'}
[PSCustomObject]@{Name=$Name;SwitchType='External'}
}
$script:answers.Clear()
$existing=@(Initialize-BuilderExternalSwitches -Switches @([PSCustomObject]@{Name='existing';SwitchType='External'}))
Assert-Test ($existing[0].Name -eq 'existing' -and $script:adapterQueries -eq 0 -and $script:switchCreateCalls -eq 0) 'An existing switch was modified or prompted.'
$script:answers.Enqueue('')
Assert-Throws { Initialize-BuilderExternalSwitches -Switches @() } '未创建'
Assert-Test ($script:switchCreateCalls -eq 0) 'Default choice created a switch.'
foreach($answer in @('Y','1','external','N')){$script:answers.Enqueue($answer)}
Assert-Throws { Initialize-BuilderExternalSwitches -Switches @() } '已取消'
Assert-Test ($script:switchCreateCalls -eq 0) 'Declining final confirmation created a switch.'
foreach($answer in @('Y','1','taken','build-external','Y')){$script:answers.Enqueue($answer)}
$created=Initialize-BuilderExternalSwitches -Switches @()
Assert-Test ($created.Name -eq 'build-external' -and $script:switchCreateCalls -eq 1) 'Switch creation or duplicate-name retry failed.'
$script:switchTestMode='no_adapter'
$script:answers.Enqueue('Y')
Assert-Throws { Initialize-BuilderExternalSwitches -Switches @() } '物理网卡'
Assert-Test ($script:switchCreateCalls -eq 1) 'A switch was created with no physical NIC.'
$script:switchTestMode='failure'
foreach($answer in @('Y','1','external','Y')){$script:answers.Enqueue($answer)}
Assert-Throws { Initialize-BuilderExternalSwitches -Switches @() } '创建外部交换机失败'
}
[PSCustomObject]@{SoftwareCombinations=$checked;InputRetry='passed';Cancellation='passed';SaveOnly='passed';NegativeCases='passed';LiteralPaths='passed';DataIsoDiscovery='passed';DataInstallerDiscovery='passed';ExternalSwitchPrompts='passed; network calls mocked';Fixture=$fixture}
& {
. (Join-Path $root 'scripts\Servicing.Adapter.ps1')
$script:PveServicingTools=[PSCustomObject]@{DismPath='C:\fixture\dism.exe'}
$script:PveRunLogDirectory=$fixture
function Invoke-PveServicingTool {
param($Executable,[string[]]$Arguments,$Stage)
Assert-Test ($Arguments.Count -eq 5 -and $Arguments[1] -eq '/Add-Driver') 'DISM argument boundaries were lost.'
Assert-Test ($Arguments[2] -eq '/driver:Z:\drivers with spaces') 'DISM driver path was split.'
}
Add-DriversToImage -winImagePath 'V:\' -driversPath 'Z:\drivers with spaces'
}