commit 2861ce551c022b0bf977d28038b6004ec2ab011d Author: Linvery <23356865+Linvery@users.noreply.github.com> Date: Fri Oct 9 03:15:29 2026 -0700 Initial Windows PVE image builder release diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcfd661 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/windows-checks.yml b/.github/workflows/windows-checks.yml new file mode 100644 index 0000000..9b69ca3 --- /dev/null +++ b/.github/workflows/windows-checks.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71f7994 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..45e8e4a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/windows-imaging-tools"] + path = vendor/windows-imaging-tools + url = https://github.com/cloudbase/windows-imaging-tools.git diff --git a/Build-Image.ps1 b/Build-Image.ps1 new file mode 100644 index 0000000..728ba8c --- /dev/null +++ b/Build-Image.ps1 @@ -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}} diff --git a/Compress-Image.ps1 b/Compress-Image.ps1 new file mode 100644 index 0000000..565d170 --- /dev/null +++ b/Compress-Image.ps1 @@ -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 diff --git a/Initialize-Project.ps1 b/Initialize-Project.ps1 new file mode 100644 index 0000000..68e654c --- /dev/null +++ b/Initialize-Project.ps1 @@ -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} diff --git a/Install-QemuImageTool.ps1 b/Install-QemuImageTool.ps1 new file mode 100644 index 0000000..e4f16cd --- /dev/null +++ b/Install-QemuImageTool.ps1 @@ -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} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..67db858 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/New-ImageConfig.ps1 b/New-ImageConfig.ps1 new file mode 100644 index 0000000..5db2f55 --- /dev/null +++ b/New-ImageConfig.ps1 @@ -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.'} diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4c0457 --- /dev/null +++ b/README.md @@ -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)。 diff --git a/Start-ImageBuilder.ps1 b/Start-ImageBuilder.ps1 new file mode 100644 index 0000000..61dbd1c --- /dev/null +++ b/Start-ImageBuilder.ps1 @@ -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 +} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..fd13ac2 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/Verify-BuiltImage.ps1 b/Verify-BuiltImage.ps1 new file mode 100644 index 0000000..dc10b7f --- /dev/null +++ b/Verify-BuiltImage.ps1 @@ -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}} + } +} diff --git a/config/Kms.ClientKeys.psd1 b/config/Kms.ClientKeys.psd1 new file mode 100644 index 0000000..4f7a980 --- /dev/null +++ b/config/Kms.ClientKeys.psd1 @@ -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'} + ) +} \ No newline at end of file diff --git a/config/cloudbase-init.conf b/config/cloudbase-init.conf new file mode 100644 index 0000000..d9209b6 --- /dev/null +++ b/config/cloudbase-init.conf @@ -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 \ No newline at end of file diff --git a/config/image.example.ini b/config/image.example.ini new file mode 100644 index 0000000..775bad6 --- /dev/null +++ b/config/image.example.ini @@ -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 diff --git a/data/custom-resources/.gitkeep b/data/custom-resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/iso/.gitkeep b/data/iso/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/account-bootstrap.md b/docs/account-bootstrap.md new file mode 100644 index 0000000..f2254d6 --- /dev/null +++ b/docs/account-bootstrap.md @@ -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 账号的处理策略。 diff --git a/hooks/RunAfterCloudbaseInitInstall.ps1 b/hooks/RunAfterCloudbaseInitInstall.ps1 new file mode 100644 index 0000000..7992101 --- /dev/null +++ b/hooks/RunAfterCloudbaseInitInstall.ps1 @@ -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 +} diff --git a/hooks/RunBeforeCloudbaseInitInstall.ps1 b/hooks/RunBeforeCloudbaseInitInstall.ps1 new file mode 100644 index 0000000..02f6f00 --- /dev/null +++ b/hooks/RunBeforeCloudbaseInitInstall.ps1 @@ -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 +} diff --git a/hooks/RunBeforeSysprep.ps1 b/hooks/RunBeforeSysprep.ps1 new file mode 100644 index 0000000..16cad7c --- /dev/null +++ b/hooks/RunBeforeSysprep.ps1 @@ -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 diff --git a/resources/Activate-Windows.ps1 b/resources/Activate-Windows.ps1 new file mode 100644 index 0000000..bf8a1fe --- /dev/null +++ b/resources/Activate-Windows.ps1 @@ -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 diff --git a/resources/Apply-GuestNetworkSettings.ps1 b/resources/Apply-GuestNetworkSettings.ps1 new file mode 100644 index 0000000..6e48d60 --- /dev/null +++ b/resources/Apply-GuestNetworkSettings.ps1 @@ -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 +} diff --git a/resources/Apply-KmsSettings.ps1 b/resources/Apply-KmsSettings.ps1 new file mode 100644 index 0000000..8388a4c --- /dev/null +++ b/resources/Apply-KmsSettings.ps1 @@ -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) diff --git a/resources/Builder.Specialize.ps1 b/resources/Builder.Specialize.ps1 new file mode 100644 index 0000000..6d0445e --- /dev/null +++ b/resources/Builder.Specialize.ps1 @@ -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 +} diff --git a/resources/Install-ManagedAdministrator.ps1 b/resources/Install-ManagedAdministrator.ps1 new file mode 100644 index 0000000..2133ce1 --- /dev/null +++ b/resources/Install-ManagedAdministrator.ps1 @@ -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.' diff --git a/resources/configure-managed-admin.py b/resources/configure-managed-admin.py new file mode 100644 index 0000000..dbe9457 --- /dev/null +++ b/resources/configure-managed-admin.py @@ -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.') diff --git a/resources/managed-admin-policy.json b/resources/managed-admin-policy.json new file mode 100644 index 0000000..36e06e0 --- /dev/null +++ b/resources/managed-admin-policy.json @@ -0,0 +1 @@ +{"disable_extra_admin": true} \ No newline at end of file diff --git a/resources/managedadmin.py b/resources/managedadmin.py new file mode 100644 index 0000000..415c33c --- /dev/null +++ b/resources/managedadmin.py @@ -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 diff --git a/scripts/Build.Cancellation.ps1 b/scripts/Build.Cancellation.ps1 new file mode 100644 index 0000000..92c55a8 --- /dev/null +++ b/scripts/Build.Cancellation.ps1 @@ -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(' output = new ConcurrentQueue(); + 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 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(); } + } +} diff --git a/scripts/Build.Resources.ps1 b/scripts/Build.Resources.ps1 new file mode 100644 index 0000000..584c988 --- /dev/null +++ b/scripts/Build.Resources.ps1 @@ -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 +} diff --git a/scripts/Build.Validation.ps1 b/scripts/Build.Validation.ps1 new file mode 100644 index 0000000..9720d76 --- /dev/null +++ b/scripts/Build.Validation.ps1 @@ -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} + } +} diff --git a/scripts/Builder.Workflow.ps1 b/scripts/Builder.Workflow.ps1 new file mode 100644 index 0000000..cdb2b37 --- /dev/null +++ b/scripts/Builder.Workflow.ps1 @@ -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 +} diff --git a/scripts/CloudbaseInit.Resources.ps1 b/scripts/CloudbaseInit.Resources.ps1 new file mode 100644 index 0000000..54da542 --- /dev/null +++ b/scripts/CloudbaseInit.Resources.ps1 @@ -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 } + } +} diff --git a/scripts/Configuration.Common.ps1 b/scripts/Configuration.Common.ps1 new file mode 100644 index 0000000..1f973ca --- /dev/null +++ b/scripts/Configuration.Common.ps1 @@ -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 + } +} diff --git a/scripts/Expand-ToolMsi.ps1 b/scripts/Expand-ToolMsi.ps1 new file mode 100644 index 0000000..1c80a21 --- /dev/null +++ b/scripts/Expand-ToolMsi.ps1 @@ -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 Extract(string path,string destination){ + uint db=0,view=0,record=0; var names=new Dictionary(); + // 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 diff --git a/scripts/Import-ImagingTools.ps1 b/scripts/Import-ImagingTools.ps1 new file mode 100644 index 0000000..74116e1 --- /dev/null +++ b/scripts/Import-ImagingTools.ps1 @@ -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 diff --git a/scripts/Offline.Registry.ps1 b/scripts/Offline.Registry.ps1 new file mode 100644 index 0000000..006af7f --- /dev/null +++ b/scripts/Offline.Registry.ps1 @@ -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 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;i100000) 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)) +} diff --git a/scripts/Package.Validation.ps1 b/scripts/Package.Validation.ps1 new file mode 100644 index 0000000..51164a1 --- /dev/null +++ b/scripts/Package.Validation.ps1 @@ -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 {} + } + } +} diff --git a/scripts/ProductKeys.Common.ps1 b/scripts/ProductKeys.Common.ps1 new file mode 100644 index 0000000..faf770b --- /dev/null +++ b/scripts/ProductKeys.Common.ps1 @@ -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 +} diff --git a/scripts/Project.Common.ps1 b/scripts/Project.Common.ps1 new file mode 100644 index 0000000..36ad4dd --- /dev/null +++ b/scripts/Project.Common.ps1 @@ -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.'} +} diff --git a/scripts/Servicing.Adapter.ps1 b/scripts/Servicing.Adapter.ps1 new file mode 100644 index 0000000..84a5b06 --- /dev/null +++ b/scripts/Servicing.Adapter.ps1 @@ -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' +} diff --git a/scripts/Software.Selection.ps1 b/scripts/Software.Selection.ps1 new file mode 100644 index 0000000..b5baf91 --- /dev/null +++ b/scripts/Software.Selection.ps1 @@ -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} +} diff --git a/scripts/Unattend.Adapter.ps1 b/scripts/Unattend.Adapter.ps1 new file mode 100644 index 0000000..7feba6e --- /dev/null +++ b/scripts/Unattend.Adapter.ps1 @@ -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() } +} diff --git a/scripts/WindowsPE.Servicing.ps1 b/scripts/WindowsPE.Servicing.ps1 new file mode 100644 index 0000000..bd8b39b --- /dev/null +++ b/scripts/WindowsPE.Servicing.ps1 @@ -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=@' + + + + + __UI_LANGUAGE__ + __UI_LANGUAGE____UI_LANGUAGE____UI_LANGUAGE____UI_LANGUAGE__ + + + + + 1 + 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 + Service only the attached PVE image disk + + + + + +'@ + $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}}} + } +} diff --git a/src/Compress-Qcow2Image.ps1 b/src/Compress-Qcow2Image.ps1 new file mode 100644 index 0000000..5980fda --- /dev/null +++ b/src/Compress-Qcow2Image.ps1 @@ -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 + } +} diff --git a/tests/Test-BootVerification.ps1 b/tests/Test-BootVerification.ps1 new file mode 100644 index 0000000..f927628 --- /dev/null +++ b/tests/Test-BootVerification.ps1 @@ -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'} diff --git a/tests/Test-BuildCancellation.ps1 b/tests/Test-BuildCancellation.ps1 new file mode 100644 index 0000000..590b433 --- /dev/null +++ b/tests/Test-BuildCancellation.ps1 @@ -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} +} diff --git a/tests/Test-CloudbaseInitResources.ps1 b/tests/Test-CloudbaseInitResources.ps1 new file mode 100644 index 0000000..cfe55b9 --- /dev/null +++ b/tests/Test-CloudbaseInitResources.ps1 @@ -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,'Download error') +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'} diff --git a/tests/Test-EnvironmentChecks.ps1 b/tests/Test-EnvironmentChecks.ps1 new file mode 100644 index 0000000..7bffa24 --- /dev/null +++ b/tests/Test-EnvironmentChecks.ps1 @@ -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'} +} diff --git a/tests/Test-GuestSoftware.ps1 b/tests/Test-GuestSoftware.ps1 new file mode 100644 index 0000000..48efddf --- /dev/null +++ b/tests/Test-GuestSoftware.ps1 @@ -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'} +} diff --git a/tests/Test-InstallerSignatures.ps1 b/tests/Test-InstallerSignatures.ps1 new file mode 100644 index 0000000..996d99c --- /dev/null +++ b/tests/Test-InstallerSignatures.ps1 @@ -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} diff --git a/tests/Test-KmsSettings.ps1 b/tests/Test-KmsSettings.ps1 new file mode 100644 index 0000000..011fb58 --- /dev/null +++ b/tests/Test-KmsSettings.ps1 @@ -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'} diff --git a/tests/Test-OfflineRegistry.ps1 b/tests/Test-OfflineRegistry.ps1 new file mode 100644 index 0000000..6b0c02b --- /dev/null +++ b/tests/Test-OfflineRegistry.ps1 @@ -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'}} diff --git a/tests/Test-ProductKeys.ps1 b/tests/Test-ProductKeys.ps1 new file mode 100644 index 0000000..55d7c40 --- /dev/null +++ b/tests/Test-ProductKeys.ps1 @@ -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'} diff --git a/tests/Test-Project.ps1 b/tests/Test-Project.ps1 new file mode 100644 index 0000000..79a9e29 --- /dev/null +++ b/tests/Test-Project.ps1 @@ -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 diff --git a/tests/Test-Specialize.ps1 b/tests/Test-Specialize.ps1 new file mode 100644 index 0000000..0c76678 --- /dev/null +++ b/tests/Test-Specialize.ps1 @@ -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'} +} diff --git a/tests/Test-Unattend.ps1 b/tests/Test-Unattend.ps1 new file mode 100644 index 0000000..e56ab13 --- /dev/null +++ b/tests/Test-Unattend.ps1 @@ -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'} diff --git a/tests/Test-VirtioResources.ps1 b/tests/Test-VirtioResources.ps1 new file mode 100644 index 0000000..1e1e3b9 --- /dev/null +++ b/tests/Test-VirtioResources.ps1 @@ -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 diff --git a/tests/Test-WindowsActivation.ps1 b/tests/Test-WindowsActivation.ps1 new file mode 100644 index 0000000..40bebe9 --- /dev/null +++ b/tests/Test-WindowsActivation.ps1 @@ -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} diff --git a/tests/Test-Wizard.ps1 b/tests/Test-Wizard.ps1 new file mode 100644 index 0000000..7e1b31a --- /dev/null +++ b/tests/Test-Wizard.ps1 @@ -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' +} diff --git a/vendor/windows-imaging-tools b/vendor/windows-imaging-tools new file mode 160000 index 0000000..342c678 --- /dev/null +++ b/vendor/windows-imaging-tools @@ -0,0 +1 @@ +Subproject commit 342c6788c51c23d6441cecd4013fd8c3165d8918