Initial Windows PVE image builder release
This commit is contained in:
commit
2861ce551c
66 files changed
+5176
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Windows PowerShell checks
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
GIT_CONFIG_COUNT: '1'
|
||||
GIT_CONFIG_KEY_0: core.longpaths
|
||||
GIT_CONFIG_VALUE_0: 'true'
|
||||
defaults:
|
||||
run:
|
||||
shell: powershell
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Initialize pinned dependency
|
||||
run: .\Initialize-Project.ps1 -SkipSubmodules
|
||||
- name: Test wizard and optional applications
|
||||
run: .\tests\Test-Wizard.ps1
|
||||
- name: Test environment checks and preflight
|
||||
run: .\tests\Test-EnvironmentChecks.ps1
|
||||
- name: Test build cancellation and resource ownership
|
||||
run: .\tests\Test-BuildCancellation.ps1
|
||||
- name: Test VirtIO resource extraction
|
||||
run: .\tests\Test-VirtioResources.ps1
|
||||
- name: Test Cloudbase-Init cache and image staging
|
||||
run: .\tests\Test-CloudbaseInitResources.ps1
|
||||
- name: Test installer signature policy
|
||||
run: .\tests\Test-InstallerSignatures.ps1
|
||||
- name: Test guest installation and reboot resume
|
||||
run: .\tests\Test-GuestSoftware.ps1
|
||||
- name: Test noninteractive specialize for client and server
|
||||
run: .\tests\Test-Specialize.ps1
|
||||
- name: Test unattended setup and Server 2025 setup keys
|
||||
run: .\tests\Test-Unattend.ps1
|
||||
- name: Test matching and manual product key choices
|
||||
run: .\tests\Test-ProductKeys.ps1
|
||||
- name: Test KMS server choices and guest configuration
|
||||
run: .\tests\Test-KmsSettings.ps1
|
||||
- name: Test optional first-boot Windows activation
|
||||
run: .\tests\Test-WindowsActivation.ps1
|
||||
- name: Test boot verification waits for Cloudbase-Init
|
||||
run: .\tests\Test-BootVerification.ps1
|
||||
- name: Reject malformed offline registry data
|
||||
run: .\tests\Test-OfflineRegistry.ps1
|
||||
- name: Check syntax, sanitization, and module import
|
||||
run: .\tests\Test-Project.ps1
|
||||
+28
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/windows-imaging-tools"]
|
||||
path = vendor/windows-imaging-tools
|
||||
url = https://github.com/cloudbase/windows-imaging-tools.git
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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.
|
||||
@@ -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.'}
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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}}
|
||||
}
|
||||
}
|
||||
@@ -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'}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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 账号的处理策略。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,39 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
if (-not (Test-Path -LiteralPath 'C:\UnattendResources\config.ini' -PathType Leaf)) { throw 'Specialize is intended for the imaging guest only.' }
|
||||
$directory=Join-Path $env:ProgramData 'PveImageBuilder'
|
||||
$state=[ordered]@{Phase='starting';StartedUtc=[DateTime]::UtcNow.ToString('o');InstallationType=$null;ClientPoliciesApplied=$false}
|
||||
function Write-SpecializeState {
|
||||
param([string]$Message,[switch]$Failed)
|
||||
[IO.File]::WriteAllText((Join-Path $directory 'specialize-status.json'),($state|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
|
||||
$kvp='HKLM:\SOFTWARE\Microsoft\Virtual Machine\Auto'
|
||||
if (Test-Path -LiteralPath $kvp) {
|
||||
$name='ImageGenerationLog-Specialize'
|
||||
if ($Failed) { $name='ImageGenerationLog-ERROR' }
|
||||
Set-ItemProperty -LiteralPath $kvp -Name $name -Value $Message -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host $Message
|
||||
}
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $directory -Force | Out-Null
|
||||
Write-SpecializeState -Message 'Specialize started.'
|
||||
$state.InstallationType=[string](Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name InstallationType -ErrorAction Stop).InstallationType
|
||||
if ($state.InstallationType -eq 'Client') {
|
||||
foreach ($policy in @(@{Path='HKLM:\SOFTWARE\Policies\Microsoft\WindowsStore';Name='AutoDownload';Value=2},@{Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent';Name='DisableWindowsConsumerFeatures';Value=1})) {
|
||||
New-Item -Path $policy.Path -Force | Out-Null
|
||||
New-ItemProperty -LiteralPath $policy.Path -Name $policy.Name -Value $policy.Value -PropertyType DWord -Force | Out-Null
|
||||
}
|
||||
$state.ClientPoliciesApplied=$true
|
||||
}
|
||||
# Network rules are configured by Logon and the project installation hooks.
|
||||
# Do not wait on Store services or keyboard input during Windows setup.
|
||||
$state.Phase='complete'
|
||||
$state.CompletedUtc=[DateTime]::UtcNow.ToString('o')
|
||||
Write-SpecializeState -Message ('Specialize complete: '+$state.InstallationType)
|
||||
exit 0
|
||||
} catch {
|
||||
$state.Phase='failed'
|
||||
$state.Error=$_.Exception.Message
|
||||
try { Write-SpecializeState -Message ('Specialize failed: '+$_.Exception.Message) -Failed } catch {}
|
||||
Write-Error $_ -ErrorAction Continue
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$root=Join-Path $env:ProgramFiles 'Cloudbase Solutions\Cloudbase-Init'
|
||||
if(-not(Test-Path -LiteralPath 'C:\UnattendResources\config.ini')){throw 'This installer is intended for the image build guest.'}
|
||||
$default=@(Get-ChildItem -LiteralPath $root -Filter 'default.py' -File -Recurse|Where-Object{$_.FullName -like '*\cloudbaseinit\conf\default.py'})
|
||||
if($default.Count -ne 1){throw 'Cloudbase-Init Python configuration module was not found uniquely.'}
|
||||
$package=Split-Path -Parent $default[0].DirectoryName
|
||||
$python=@(Get-ChildItem -LiteralPath $root -Filter 'python.exe' -File -Recurse|Where-Object{$_.DirectoryName -match '\\Python$'})
|
||||
if($python.Count -ne 1){throw 'Cloudbase-Init Python runtime was not found uniquely.'}
|
||||
$resources='C:\UnattendResources\CustomResources'
|
||||
Copy-Item -LiteralPath (Join-Path $resources 'managedadmin.py') -Destination (Join-Path $package 'plugins\common\managedadmin.py') -Force
|
||||
Copy-Item -LiteralPath (Join-Path $resources 'managed-admin-policy.json') -Destination (Join-Path $root 'conf\managed-admin-policy.json') -Force
|
||||
& $python[0].FullName (Join-Path $resources 'configure-managed-admin.py') $default[0].FullName (Join-Path $root 'conf\cloudbase-init.conf') 'C:\Windows\Temp\managed-admin-plugin-order.json'
|
||||
if($LASTEXITCODE -ne 0){throw 'Managed administrator plugin registration failed.'}
|
||||
Write-Output 'Managed administrator plugin installed after password injection.'
|
||||
@@ -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.')
|
||||
@@ -0,0 +1 @@
|
||||
{"disable_extra_admin": true}
|
||||
@@ -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
|
||||
@@ -0,0 +1,133 @@
|
||||
. (Join-Path $PSScriptRoot 'Build.Resources.ps1')
|
||||
function Read-PveCleanupChoice {
|
||||
while ($true) {
|
||||
$answer=([string](Read-Host '是否清理本轮构建的临时资源?(Y/N) [Y]')).Trim()
|
||||
if (-not $answer -or $answer -in @('y','yes')) { return $true }
|
||||
if ($answer -in @('n','no')) { return $false }
|
||||
Write-Host '请输入 Y 或 N,回车默认选择是。' -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
function Test-PveBuildCancellationKey {
|
||||
if ([Console]::IsInputRedirected) { return $false }
|
||||
while ([Console]::KeyAvailable) {
|
||||
$key=[Console]::ReadKey($true)
|
||||
if ([int]$key.KeyChar -eq 3 -or ($key.Key -eq [ConsoleKey]::C -and ($key.Modifiers -band [ConsoleModifiers]::Control))) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
function Write-PveBuildOutput {
|
||||
param([string]$Line)
|
||||
if ($Line -eq '#< CLIXML') { return }
|
||||
if ($Line.StartsWith('<Objs ') -and $Line.Contains('http://schemas.microsoft.com/powershell/2004/04')) {
|
||||
try {
|
||||
$document=[Xml.XmlDocument]::new()
|
||||
$document.XmlResolver=$null
|
||||
$document.LoadXml($Line)
|
||||
foreach ($node in $document.DocumentElement.ChildNodes) {
|
||||
if ($node.GetAttribute('S') -in @('error','warning','verbose','debug')) {
|
||||
$value=$node.InnerText
|
||||
$value=[regex]::Replace($value,'_x([0-9a-fA-F]{4})_',[Text.RegularExpressions.MatchEvaluator]{param($match) [string][char][Convert]::ToInt32($match.Groups[1].Value,16)})
|
||||
Write-Host $value
|
||||
}
|
||||
}
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
Write-Host $Line
|
||||
}
|
||||
function Set-PveCancelledBuildState {
|
||||
param([string]$ContextPath,[string]$Cleanup,[string]$Detail)
|
||||
$context=Get-PveBuildResources -ContextPath $ContextPath
|
||||
$context.Cleanup=$Cleanup
|
||||
Save-PveBuildResources -ContextPath $ContextPath -Context $context
|
||||
$path=Join-Path $context.LogDirectory 'status.json'
|
||||
$state=[ordered]@{Phase='cancelled';OutputPath=$context.OutputPath;LogDirectory=$context.LogDirectory;Cleanup=$Cleanup;CancelledUtc=[DateTime]::UtcNow.ToString('o')}
|
||||
if ($Detail) { $state.Error=$Detail }
|
||||
[IO.File]::WriteAllText($path,($state|ConvertTo-Json -Depth 5),[Text.UTF8Encoding]::new($false))
|
||||
return [PSCustomObject]$state
|
||||
}
|
||||
function Invoke-PveCancelledBuild {
|
||||
param([string]$ContextPath)
|
||||
try { Stop-PveOwnedBuildVms -ContextPath $ContextPath }
|
||||
catch { Write-Host ('停止本轮临时 VM 时遇到问题:'+$_.Exception.Message) -ForegroundColor Yellow }
|
||||
if (Read-PveCleanupChoice) {
|
||||
try {
|
||||
Remove-PveOwnedBuildResources -ContextPath $ContextPath
|
||||
Write-Host '构建已取消,本轮临时资源已清理。ISO、安装包和日志已保留。' -ForegroundColor Green
|
||||
return Set-PveCancelledBuildState -ContextPath $ContextPath -Cleanup 'complete'
|
||||
} catch {
|
||||
Write-Host ('构建已取消,部分资源未能清理:'+$_.Exception.Message) -ForegroundColor Yellow
|
||||
return Set-PveCancelledBuildState -ContextPath $ContextPath -Cleanup 'failed' -Detail $_.Exception.Message
|
||||
}
|
||||
}
|
||||
Write-Host '构建已取消,临时资源已保留。' -ForegroundColor Yellow
|
||||
return Set-PveCancelledBuildState -ContextPath $ContextPath -Cleanup 'declined'
|
||||
}
|
||||
function Invoke-PveControlledBuild {
|
||||
param([string]$ScriptPath,[string]$ConfigPath,$Settings,$Config,[string]$ServicingIsoPath,[switch]$SkipBootVerification)
|
||||
if (-not ('PveImageBuilder.BuildProcess' -as [type])) { Add-Type -Path (Join-Path $PSScriptRoot 'Build.Process.cs') }
|
||||
$outputLock=$null
|
||||
try {
|
||||
$lockDirectory=Join-Path $Settings.ProjectRoot 'local\build-locks'
|
||||
New-Item -ItemType Directory -Path $lockDirectory -Force | Out-Null
|
||||
$hasher=[Security.Cryptography.SHA256]::Create()
|
||||
try { $lockName=([BitConverter]::ToString($hasher.ComputeHash([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($Config.image_path).ToLowerInvariant())))).Replace('-','')+'.lock' }
|
||||
finally { $hasher.Dispose() }
|
||||
try { $outputLock=[IO.File]::Open((Join-Path $lockDirectory $lockName),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) }
|
||||
catch { throw '另一个构建正在使用同一输出路径,请选择新的输出文件名。' }
|
||||
$runId=(Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N')
|
||||
$logDirectory=Join-Path $Settings.AssetsRoot ('logs\builds\wizard-'+$runId)
|
||||
New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null
|
||||
$contextPath=Join-Path $logDirectory 'resources.json'
|
||||
$context=[PSCustomObject]@{SchemaVersion=1;RunId=$runId;AssetsRoot=[IO.Path]::GetFullPath($Settings.AssetsRoot);OutputPath=[IO.Path]::GetFullPath($Config.image_path);LogDirectory=[IO.Path]::GetFullPath($logDirectory);Completed=$false;Cleanup='not_requested';Files=@();Directories=@();VMs=@();SourceIsos=@()}
|
||||
foreach ($iso in @($Config.wim_file_path,$Config.virtio_iso_path,$ServicingIsoPath) | Select-Object -Unique) {
|
||||
if ([IO.Path]::GetExtension($iso) -ieq '.iso') {
|
||||
$image=Get-DiskImage -ImagePath $iso -ErrorAction Stop
|
||||
$context.SourceIsos+= [PSCustomObject]@{Path=[IO.Path]::GetFullPath($iso);AttachedBefore=[bool]$image.Attached}
|
||||
}
|
||||
}
|
||||
Save-PveBuildResources -ContextPath $contextPath -Context $context
|
||||
Register-PveBuildFile -ContextPath $contextPath -Path $context.OutputPath
|
||||
Register-PveBuildFile -ContextPath $contextPath -Path ([IO.Path]::ChangeExtension($context.OutputPath,'.vhdx')) -Kind Disk
|
||||
Register-PveBuildFile -ContextPath $contextPath -Path ([IO.Path]::ChangeExtension($context.OutputPath,'.raw')) -Kind Disk
|
||||
$quote={param($value) "'"+$value.Replace("'","''")+"'"}
|
||||
$snapshot=Join-Path $logDirectory 'build.ini'
|
||||
Register-PveBuildFile -ContextPath $contextPath -Path $snapshot
|
||||
Register-PveBuildFile -ContextPath $contextPath -Path ($snapshot+'.offline')
|
||||
Copy-Item -LiteralPath ([IO.Path]::GetFullPath($ConfigPath)) -Destination $snapshot
|
||||
$command='[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $ProgressPreference=''SilentlyContinue''; try { & '+(& $quote $ScriptPath)+' -ConfigPath '+(& $quote $snapshot)+' -InternalWorker -ResourceContextPath '+(& $quote $contextPath)+' -Confirm:$false'
|
||||
if ($SkipBootVerification) { $command+=' -SkipBootVerification' }
|
||||
$command+=' } catch { [Console]::Error.WriteLine($_.ToString()); exit 1 }; if ($?) { exit 0 } else { exit 1 }'
|
||||
$encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
|
||||
$worker=$null
|
||||
$consoleChanged=$false
|
||||
$previous=$false
|
||||
$cancelled=$false
|
||||
try {
|
||||
if (-not [Console]::IsInputRedirected) {
|
||||
$previous=[Console]::TreatControlCAsInput
|
||||
[Console]::TreatControlCAsInput=$true
|
||||
$consoleChanged=$true
|
||||
}
|
||||
$worker=[PveImageBuilder.BuildProcess]::new((Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe'),('-NoProfile -ExecutionPolicy Bypass -OutputFormat Text -EncodedCommand '+$encoded),(Split-Path -Parent $ScriptPath))
|
||||
Write-Host '构建已启动。按 Ctrl+C 中断后,可选择是否清理本轮临时资源(默认是)。' -ForegroundColor Cyan
|
||||
while (-not $worker.HasExited) {
|
||||
foreach ($line in $worker.ReadOutput()) { Write-PveBuildOutput -Line $line }
|
||||
if (Test-PveBuildCancellationKey) { $cancelled=$true; $worker.Cancel(); break }
|
||||
Start-Sleep -Milliseconds 150
|
||||
}
|
||||
$worker.Wait()
|
||||
foreach ($line in $worker.ReadOutput()) { Write-PveBuildOutput -Line $line }
|
||||
$exitCode=$worker.ExitCode
|
||||
} finally {
|
||||
if ($consoleChanged) { [Console]::TreatControlCAsInput=$previous }
|
||||
if ($worker) { $worker.Dispose() }
|
||||
}
|
||||
if ($cancelled) {
|
||||
$latest=Get-PveBuildResources -ContextPath $contextPath
|
||||
if (-not $latest.Completed) { return Invoke-PveCancelledBuild -ContextPath $contextPath }
|
||||
}
|
||||
if ($exitCode -ne 0 -and -not $cancelled) { throw ('构建失败,日志:'+$logDirectory) }
|
||||
return Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $logDirectory 'status.json') | ConvertFrom-Json
|
||||
} finally { if ($outputLock) { $outputLock.Dispose() } }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@{
|
||||
UpstreamCommit = '342c6788c51c23d6441cecd4013fd8c3165d8918'
|
||||
Qemu = @{
|
||||
Version = '11.1.0'
|
||||
InstallerUrl = 'https://qemu.weilnetz.de/w64/qemu-w64-setup-20260811.exe'
|
||||
SHA512 = '5BCF9EED634E8575A37B74F445AF41A2FE4106DA512D0C30C368301D4C105037FDFAB40A5287367A28A957624CDDEBBC8C07E16C88AB6634F554CDF3D16BF543'
|
||||
ExecutableSHA256 = '9D930582D0D574BB106D89982C547688E10B8F356192D302CC3D0AE4E8674534'
|
||||
SevenZipMsiUrl = 'https://github.com/ip7z/7zip/releases/download/26.04/7z2604-x64.msi'
|
||||
SevenZipMsiSHA256 = '0B01334A418654293513449F61D6BFC99E5196CA371E3C3DC961EDA57CD535C6'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace PveImageBuilder {
|
||||
public sealed class BuildProcess : IDisposable {
|
||||
[StructLayout(LayoutKind.Sequential)] struct BasicLimits {
|
||||
public long PerProcessTime, PerJobTime;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinWorkingSet, MaxWorkingSet;
|
||||
public uint ActiveProcessLimit;
|
||||
public UIntPtr Affinity;
|
||||
public uint PriorityClass, SchedulingClass;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential)] struct IoCounters { public ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; }
|
||||
[StructLayout(LayoutKind.Sequential)] struct ExtendedLimits {
|
||||
public BasicLimits Basic;
|
||||
public IoCounters Io;
|
||||
public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory;
|
||||
}
|
||||
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name);
|
||||
[DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimits info, uint length);
|
||||
[DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
||||
[DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint exitCode);
|
||||
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
|
||||
readonly ConcurrentQueue<string> output = new ConcurrentQueue<string>();
|
||||
readonly Process process = new Process();
|
||||
IntPtr job;
|
||||
public BuildProcess(string executable, string arguments, string directory) {
|
||||
job=CreateJobObject(IntPtr.Zero,null);
|
||||
if (job==IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try {
|
||||
var limits=new ExtendedLimits();
|
||||
limits.Basic.LimitFlags=0x2000; // Kill only this job's processes on close.
|
||||
if (!SetInformationJobObject(job,9,ref limits,(uint)Marshal.SizeOf(typeof(ExtendedLimits)))) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
process.StartInfo=new ProcessStartInfo(executable,arguments) { UseShellExecute=false, CreateNoWindow=true, RedirectStandardOutput=true, RedirectStandardError=true, WorkingDirectory=directory, StandardOutputEncoding=new System.Text.UTF8Encoding(false), StandardErrorEncoding=new System.Text.UTF8Encoding(false) };
|
||||
process.OutputDataReceived += (sender,e) => { if(e.Data!=null) output.Enqueue(e.Data); };
|
||||
process.ErrorDataReceived += (sender,e) => { if(e.Data!=null) output.Enqueue(e.Data); };
|
||||
process.Start();
|
||||
if (!AssignProcessToJobObject(job,process.Handle)) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
} catch {
|
||||
try { if(!process.HasExited) process.Kill(); } catch {}
|
||||
CloseHandle(job); job=IntPtr.Zero;
|
||||
process.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
public string[] ReadOutput() { var lines=new System.Collections.Generic.List<string>(); string line; while(output.TryDequeue(out line)) lines.Add(line); return lines.ToArray(); }
|
||||
public bool HasExited { get { return process.HasExited; } }
|
||||
public int ExitCode { get { return process.ExitCode; } }
|
||||
public int Id { get { return process.Id; } }
|
||||
public void Wait() { process.WaitForExit(); }
|
||||
public void Cancel() {
|
||||
if (!TerminateJobObject(job,130)) throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
if (!process.WaitForExit(10000)) throw new TimeoutException("Build worker did not stop.");
|
||||
process.WaitForExit();
|
||||
}
|
||||
public void Dispose() { if(job!=IntPtr.Zero) { CloseHandle(job); job=IntPtr.Zero; } process.Dispose(); }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
param([string]$Package,[string]$Destination)
|
||||
$ErrorActionPreference='Stop'
|
||||
New-Item -ItemType Directory -Path $Destination -Force|Out-Null
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class ReadOnlyMsiTools {
|
||||
[DllImport("msi.dll",CharSet=CharSet.Unicode)] static extern uint MsiOpenDatabase(string path,IntPtr mode,out uint db);
|
||||
[DllImport("msi.dll",CharSet=CharSet.Unicode)] static extern uint MsiDatabaseOpenView(uint db,string sql,out uint view);
|
||||
[DllImport("msi.dll")] static extern uint MsiViewExecute(uint view,uint record);
|
||||
[DllImport("msi.dll")] static extern uint MsiViewFetch(uint view,out uint record);
|
||||
[DllImport("msi.dll",CharSet=CharSet.Unicode)] static extern uint MsiRecordGetString(uint record,uint field,StringBuilder value,ref uint size);
|
||||
[DllImport("msi.dll")] static extern uint MsiRecordReadStream(uint record,uint field,byte[] value,ref uint size);
|
||||
[DllImport("msi.dll")] static extern uint MsiCloseHandle(uint handle);
|
||||
static void Check(uint code){if(code!=0)throw new Exception("MSI read failed: "+code);}
|
||||
static string Str(uint record,uint field){uint n=1024;var b=new StringBuilder(1025);Check(MsiRecordGetString(record,field,b,ref n));return b.ToString();}
|
||||
public static Dictionary<string,string> Extract(string path,string destination){
|
||||
uint db=0,view=0,record=0; var names=new Dictionary<string,string>();
|
||||
// MSIDBOPEN_READONLY (0): no installation and no writes to the package.
|
||||
Check(MsiOpenDatabase(path,IntPtr.Zero,out db));
|
||||
try{
|
||||
Check(MsiDatabaseOpenView(db,"SELECT `Name`, `Data` FROM `_Streams`",out view));Check(MsiViewExecute(view,0));
|
||||
while(true){uint c=MsiViewFetch(view,out record);if(c==259)break;Check(c);
|
||||
try{string name=Str(record,1);if(!name.EndsWith(".cab",StringComparison.OrdinalIgnoreCase))continue;
|
||||
if(Path.GetFileName(name)!=name)throw new Exception("Unexpected cabinet name");
|
||||
using(var output=File.Create(Path.Combine(destination,name))){byte[] buffer=new byte[65536];while(true){uint n=(uint)buffer.Length;Check(MsiRecordReadStream(record,2,buffer,ref n));if(n==0)break;output.Write(buffer,0,(int)n);}}
|
||||
}finally{MsiCloseHandle(record);record=0;}
|
||||
}
|
||||
MsiCloseHandle(view);view=0;
|
||||
Check(MsiDatabaseOpenView(db,"SELECT `File`, `FileName` FROM `File`",out view));Check(MsiViewExecute(view,0));
|
||||
while(true){uint c=MsiViewFetch(view,out record);if(c==259)break;Check(c);try{string[] parts=Str(record,2).Split('|');names[Str(record,1)]=parts[parts.Length-1];}finally{MsiCloseHandle(record);record=0;}}
|
||||
}finally{if(record!=0)MsiCloseHandle(record);if(view!=0)MsiCloseHandle(view);MsiCloseHandle(db);}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
'@
|
||||
$files=[ReadOnlyMsiTools]::Extract([IO.Path]::GetFullPath($Package),[IO.Path]::GetFullPath($Destination))
|
||||
$cabinetFiles=@(Get-ChildItem -LiteralPath $Destination -Filter '*.cab' -File)
|
||||
if(-not$cabinetFiles.Count){throw 'No embedded cabinet found.'}
|
||||
foreach($cab in $cabinetFiles){& 'C:\Windows\System32\expand.exe' $cab.FullName '-F:*' $Destination|Out-Null;if($LASTEXITCODE -ne 0){throw 'Cabinet extraction failed.'}}
|
||||
foreach($id in $files.Keys){if($files[$id] -in @('7z.exe','7z.dll')){Copy-Item -LiteralPath (Join-Path $Destination $id) -Destination (Join-Path $Destination $files[$id]) -Force}}
|
||||
Get-Item -LiteralPath (Join-Path $Destination '7z.exe'),(Join-Path $Destination '7z.dll')|Select-Object FullName,Length
|
||||
@@ -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
|
||||
@@ -0,0 +1,93 @@
|
||||
function Get-OfflineImageState {
|
||||
param([Parameter(Mandatory=$true)][string]$HivePath)
|
||||
if (-not ('PveOffline.HiveFileReader' -as [type])) {
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Collections.Generic;
|
||||
namespace PveOffline {
|
||||
public sealed class HiveFileReader {
|
||||
private readonly byte[] data;
|
||||
private HiveFileReader(string file) {
|
||||
var info = new FileInfo(file);
|
||||
if (info.Length < 4096 || info.Length > 512L*1024*1024) throw new InvalidDataException("Unsupported registry hive size.");
|
||||
data = File.ReadAllBytes(file);
|
||||
if (Encoding.ASCII.GetString(data, 0, 4) != "regf") throw new InvalidDataException("Not a registry hive.");
|
||||
if (U32(20) != 1 || U32(24) > 6) throw new InvalidDataException("Unsupported registry hive format.");
|
||||
}
|
||||
private void Bounds(int at, int size) {
|
||||
if (at < 0 || size < 0 || at > data.Length-size) throw new InvalidDataException("Registry cell is outside the hive.");
|
||||
}
|
||||
private uint U32(int at) { Bounds(at,4); return BitConverter.ToUInt32(data,at); }
|
||||
private ushort U16(int at) { Bounds(at,2); return BitConverter.ToUInt16(data,at); }
|
||||
private int Cell(uint relative) {
|
||||
long at = 4096L + relative;
|
||||
if (at > int.MaxValue) throw new InvalidDataException("Invalid registry cell offset.");
|
||||
int offset = (int)at;
|
||||
Bounds(offset,4);
|
||||
int size = BitConverter.ToInt32(data,offset);
|
||||
if (size >= -4 || size == int.MinValue) throw new InvalidDataException("Registry cell is not allocated.");
|
||||
Bounds(offset,-size);
|
||||
return offset+4;
|
||||
}
|
||||
private string Signature(int at) { Bounds(at,2); return Encoding.ASCII.GetString(data,at,2); }
|
||||
private string Name(int at, int size, bool ascii) {
|
||||
Bounds(at,size);
|
||||
return (ascii ? Encoding.GetEncoding(1252) : Encoding.Unicode).GetString(data,at,size);
|
||||
}
|
||||
private string KeyName(int at) {
|
||||
if (Signature(at) != "nk") throw new InvalidDataException("Expected a registry key cell.");
|
||||
return Name(at+76,U16(at+72),(U16(at+2)&0x20)!=0);
|
||||
}
|
||||
private IEnumerable<uint> Subkeys(uint index, int depth) {
|
||||
if (depth > 32) throw new InvalidDataException("Registry subkey index is too deep.");
|
||||
int at=Cell(index); string kind=Signature(at); int count=U16(at+2);
|
||||
if (kind=="lf" || kind=="lh") {
|
||||
Bounds(at+4,count*8);
|
||||
for(int i=0;i<count;i++) yield return U32(at+4+i*8);
|
||||
} else if(kind=="li" || kind=="ri") {
|
||||
Bounds(at+4,count*4);
|
||||
for(int i=0;i<count;i++) {
|
||||
uint next=U32(at+4+i*4);
|
||||
if(kind=="li") yield return next;
|
||||
else foreach(uint child in Subkeys(next,depth+1)) yield return child;
|
||||
}
|
||||
} else throw new InvalidDataException("Unsupported registry subkey index.");
|
||||
}
|
||||
private uint FindChild(uint parent,string name) {
|
||||
int at=Cell(parent);
|
||||
if (Signature(at)!="nk" || U32(at+20)==0) throw new InvalidDataException("Offline registry key is missing: "+name);
|
||||
foreach(uint child in Subkeys(U32(at+28),0))
|
||||
if(string.Equals(KeyName(Cell(child)),name,StringComparison.OrdinalIgnoreCase)) return child;
|
||||
throw new InvalidDataException("Offline registry key is missing: "+name);
|
||||
}
|
||||
private string StringValue(uint key,string name) {
|
||||
int at=Cell(key); uint count=U32(at+36);
|
||||
if(count>100000) throw new InvalidDataException("Invalid registry value count.");
|
||||
int values=Cell(U32(at+40)); Bounds(values,checked((int)count*4));
|
||||
for(int i=0;i<(int)count;i++) {
|
||||
int value=Cell(U32(values+i*4));
|
||||
if(Signature(value)!="vk") throw new InvalidDataException("Invalid registry value cell.");
|
||||
string valueName=Name(value+20,U16(value+2),(U16(value+16)&1)!=0);
|
||||
if(!string.Equals(valueName,name,StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if(U32(value+12)!=1) throw new InvalidDataException("Expected a REG_SZ registry value.");
|
||||
uint length=U32(value+4); bool inline=(length&0x80000000)!=0; length&=0x7fffffff;
|
||||
if(length>4096 || (inline && length>4) || length%2!=0) throw new InvalidDataException("Invalid registry string size.");
|
||||
int text=inline ? value+8 : Cell(U32(value+8));
|
||||
return Name(text,(int)length,false).TrimEnd((char)0);
|
||||
}
|
||||
throw new InvalidDataException("Offline registry value is missing: "+name);
|
||||
}
|
||||
public static string ReadImageState(string file) {
|
||||
var hive=new HiveFileReader(file);
|
||||
uint key=hive.U32(36);
|
||||
foreach(string segment in new[]{"Microsoft","Windows","CurrentVersion","Setup","State"}) key=hive.FindChild(key,segment);
|
||||
return hive.StringValue(key,"ImageState");
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
||||
}
|
||||
[PveOffline.HiveFileReader]::ReadImageState([IO.Path]::GetFullPath($HivePath))
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.'}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
function Invoke-WindowsPeServicing {
|
||||
param([string]$VhdPath,[string]$WindowsIsoPath,[string]$LogDirectory)
|
||||
$ErrorActionPreference='Stop'
|
||||
. (Join-Path $PSScriptRoot 'Build.Resources.ps1')
|
||||
if((Get-VHD -Path $VhdPath).Attached){throw 'Windows PE servicing requires a detached build VHDX.'}
|
||||
if(-not(Test-Path -LiteralPath $WindowsIsoPath -PathType Leaf)){throw 'A Windows installer ISO is required for Windows PE servicing.'}
|
||||
$mediaRoot=Join-Path $LogDirectory 'media'
|
||||
Register-PveBuildDirectory -ContextPath $script:PveResourceContextPath -Path $mediaRoot
|
||||
$scripts=Join-Path $mediaRoot 'PveServicing'
|
||||
New-Item -ItemType Directory -Path $scripts -Force|Out-Null
|
||||
$batch=@'
|
||||
@echo off
|
||||
setlocal
|
||||
wpeinit
|
||||
set "OS="
|
||||
for %%L in (C D E F G H I J K L M N O P Q R S T U V W Y Z) do if exist %%L:\UnattendResources\pve-servicing-marker.txt set "OS=%%L:"
|
||||
if not defined OS goto no_os
|
||||
ver > "%OS%\UnattendResources\winpe-version.txt"
|
||||
set /p EFI_PARTITION=< "%OS%\UnattendResources\pve-efi-partition.txt"
|
||||
if not defined EFI_PARTITION goto failed
|
||||
> X:\pve-assign-efi.txt echo select disk 0
|
||||
>> X:\pve-assign-efi.txt echo select partition %EFI_PARTITION%
|
||||
>> X:\pve-assign-efi.txt echo assign letter=S
|
||||
>> X:\pve-assign-efi.txt echo exit
|
||||
diskpart /s X:\pve-assign-efi.txt > "%OS%\UnattendResources\winpe-diskpart.log" 2>&1
|
||||
if errorlevel 1 goto failed
|
||||
if not exist S:\ goto failed
|
||||
dism.exe /Image:%OS%\ /Add-Driver /Driver:%OS%\UnattendResources\PveDrivers /Recurse /LogPath:%OS%\UnattendResources\winpe-dism.log > "%OS%\UnattendResources\winpe-dism-output.txt" 2>&1
|
||||
set "DISMEXIT=%ERRORLEVEL%"
|
||||
if not "%DISMEXIT%"=="0" if not "%DISMEXIT%"=="3010" goto failed
|
||||
bcdboot.exe "%OS%\Windows" /s S: /f UEFI /v > "%OS%\UnattendResources\winpe-bcdboot-output.txt" 2>&1
|
||||
if errorlevel 1 goto failed
|
||||
if not exist S:\EFI\Microsoft\Boot\BCD goto failed
|
||||
echo {"Phase":"complete","Disk":0,"BootMode":"UEFI"} > "%OS%\UnattendResources\winpe-servicing-status.json"
|
||||
wpeutil shutdown
|
||||
exit /b 0
|
||||
:failed
|
||||
echo {"Phase":"failed","Disk":0,"BootMode":"UEFI"} > "%OS%\UnattendResources\winpe-servicing-status.json"
|
||||
wpeutil shutdown
|
||||
exit /b 1
|
||||
:no_os
|
||||
wpeutil shutdown
|
||||
exit /b 2
|
||||
'@
|
||||
[IO.File]::WriteAllText((Join-Path $scripts 'run.cmd'),($batch -replace '\r?\n',"`r`n"),[Text.Encoding]::ASCII)
|
||||
$answer=@'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||
<settings pass="windowsPE">
|
||||
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<SetupUILanguage><UILanguage>__UI_LANGUAGE__</UILanguage></SetupUILanguage>
|
||||
<InputLocale>__UI_LANGUAGE__</InputLocale><SystemLocale>__UI_LANGUAGE__</SystemLocale><UILanguage>__UI_LANGUAGE__</UILanguage><UserLocale>__UI_LANGUAGE__</UserLocale>
|
||||
</component>
|
||||
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<RunSynchronous>
|
||||
<RunSynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<Path>cmd.exe /c for %d in (C D E F G H I J K L M N O P Q R S T U V W Y Z) do @if exist %d:\PveServicing\run.cmd call %d:\PveServicing\run.cmd</Path>
|
||||
<Description>Service only the attached PVE image disk</Description>
|
||||
</RunSynchronousCommand>
|
||||
</RunSynchronous>
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
'@
|
||||
$mountedIsoHere=$false
|
||||
try {
|
||||
$sourceDisk=Get-DiskImage -ImagePath $WindowsIsoPath
|
||||
if(-not$sourceDisk.Attached){Mount-DiskImage -ImagePath $WindowsIsoPath -StorageType ISO|Out-Null;$mountedIsoHere=$true}
|
||||
$sourceVolumes=@(Get-DiskImage -ImagePath $WindowsIsoPath|Get-Volume|Where-Object DriveLetter)
|
||||
if($sourceVolumes.Count -ne 1){throw 'Could not identify the Windows installer ISO volume.'}
|
||||
$sourceRoot=$sourceVolumes[0].DriveLetter+':\'
|
||||
foreach($name in @('boot','efi')){Copy-Item -LiteralPath (Join-Path $sourceRoot $name) -Destination (Join-Path $mediaRoot $name) -Recurse}
|
||||
New-Item -ItemType Directory -Path (Join-Path $mediaRoot 'sources') -Force|Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot 'sources\boot.wim') -Destination (Join-Path $mediaRoot 'sources\boot.wim')
|
||||
foreach($name in @('bootmgr','bootmgr.efi','setup.exe')){if(Test-Path -LiteralPath (Join-Path $sourceRoot $name)){Copy-Item -LiteralPath (Join-Path $sourceRoot $name) -Destination (Join-Path $mediaRoot $name)}}
|
||||
$uiLanguage='en-US'
|
||||
$langPath=Join-Path $sourceRoot 'sources\lang.ini'
|
||||
if(Test-Path -LiteralPath $langPath){$match=[regex]::Match([IO.File]::ReadAllText($langPath),'(?im)^([a-z]{2,3}-[a-z0-9]{2,8})\s*=');if($match.Success){$uiLanguage=$match.Groups[1].Value}}
|
||||
$answer=$answer.Replace('__UI_LANGUAGE__',$uiLanguage)
|
||||
} finally {if($mountedIsoHere){Dismount-DiskImage -ImagePath $WindowsIsoPath|Out-Null}}
|
||||
[IO.File]::WriteAllText((Join-Path $mediaRoot 'Autounattend.xml'),$answer,[Text.UTF8Encoding]::new($false))
|
||||
$oscdimg=Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe'
|
||||
if(-not(Test-Path -LiteralPath $oscdimg)){throw 'Microsoft ADK Deployment Tools (oscdimg) is required for Windows PE servicing.'}
|
||||
$mediaIso=Join-Path $LogDirectory 'servicing.iso'
|
||||
Register-PveBuildFile -ContextPath $script:PveResourceContextPath -Path $mediaIso
|
||||
$efiBoot=Join-Path $mediaRoot 'efi\microsoft\boot\efisys_noprompt.bin'
|
||||
if(-not(Test-Path -LiteralPath $efiBoot)){$efiBoot=Join-Path (Split-Path -Parent $oscdimg) 'efisys_noprompt.bin'}
|
||||
if(-not(Test-Path -LiteralPath $efiBoot)){throw 'Microsoft efisys_noprompt.bin is required for unattended Windows PE boot.'}
|
||||
$priorErrorAction=$ErrorActionPreference
|
||||
try{$ErrorActionPreference='Continue';$output=@(& $oscdimg -m -o -u2 -udfver102 ('-bootdata:1#pEF,e,b'+$efiBoot) '-lPVE-SERVICE' $mediaRoot $mediaIso 2>&1|ForEach-Object{$_.ToString()});$exitCode=$LASTEXITCODE}
|
||||
finally{$ErrorActionPreference=$priorErrorAction}
|
||||
[IO.File]::WriteAllText((Join-Path $LogDirectory 'oscdimg.txt'),($output -join [Environment]::NewLine),[Text.UTF8Encoding]::new($false))
|
||||
if($exitCode -ne 0){throw 'Could not create the Windows PE servicing answer ISO.'}
|
||||
$vmName='PveBuilder-WinPE-'+[guid]::NewGuid().ToString('N').Substring(0,12)
|
||||
$vm=$null;$mountedHere=$false;$successful=$false
|
||||
$state=[ordered]@{Phase='starting';VMName=$vmName;VhdPath=$VhdPath;WindowsIsoPath=$WindowsIsoPath;StartedUtc=[DateTime]::UtcNow.ToString('o')}
|
||||
function Save-WinPeState{[IO.File]::WriteAllText((Join-Path $LogDirectory 'status.json'),($state|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false))}
|
||||
try{
|
||||
Save-WinPeState
|
||||
# Exactly one hard disk, with no network or shared host drives.
|
||||
Register-PveBuildVm -ContextPath $script:PveResourceContextPath -Name $vmName -VhdPath $VhdPath
|
||||
$vm=New-VM -Name $vmName -Generation 2 -MemoryStartupBytes 4GB -VHDPath $VhdPath
|
||||
Register-PveBuildVm -ContextPath $script:PveResourceContextPath -Name $vmName -VhdPath $VhdPath -Id ([string]$vm.Id)
|
||||
Set-VM -VM $vm -AutomaticCheckpointsEnabled $false -CheckpointType Disabled -AutomaticStartAction Nothing -AutomaticStopAction ShutDown
|
||||
Set-VMProcessor -VM $vm -Count 2
|
||||
Set-VMMemory -VM $vm -DynamicMemoryEnabled $false
|
||||
$installerDvd=Add-VMDvdDrive -VM $vm -Path $mediaIso -Passthru
|
||||
Add-VMDvdDrive -VM $vm -Path $WindowsIsoPath|Out-Null
|
||||
Set-VMFirmware -VM $vm -EnableSecureBoot On -SecureBootTemplate MicrosoftWindows -FirstBootDevice $installerDvd
|
||||
Start-VM -VM $vm
|
||||
$state.Phase='servicing';Save-WinPeState
|
||||
$deadline=[DateTime]::UtcNow.AddMinutes(20)
|
||||
Start-Sleep -Seconds 10
|
||||
while((Get-VM -Id $vm.Id).State -ne 'Off'){
|
||||
if([DateTime]::UtcNow -gt $deadline){throw ('Windows PE servicing timed out; inspect '+$vmName)}
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
Mount-VHD -Path $VhdPath -ReadOnly|Out-Null;$mountedHere=$true
|
||||
$windowsVolumes=@(Get-VHD -Path $VhdPath|Get-Disk|Get-Partition|Get-Volume|Where-Object{ $_.DriveLetter -and (Test-Path -LiteralPath ($_.DriveLetter+':\UnattendResources\pve-servicing-marker.txt')) })
|
||||
if($windowsVolumes.Count -ne 1){throw 'Could not find the serviced Windows partition.'}
|
||||
$resources=$windowsVolumes[0].DriveLetter+':\UnattendResources'
|
||||
foreach($name in @('winpe-servicing-status.json','winpe-version.txt','winpe-diskpart.log','winpe-dism.log','winpe-dism-output.txt','winpe-bcdboot-output.txt')){
|
||||
$path=Join-Path $resources $name
|
||||
if(Test-Path -LiteralPath $path){Copy-Item -LiteralPath $path -Destination (Join-Path $LogDirectory $name)}
|
||||
}
|
||||
$resultPath=Join-Path $LogDirectory 'winpe-servicing-status.json'
|
||||
if(-not(Test-Path -LiteralPath $resultPath)){throw 'Windows Setup did not execute the Windows PE servicing answer file.'}
|
||||
$result=Get-Content -Raw -Encoding UTF8 -LiteralPath $resultPath|ConvertFrom-Json
|
||||
if($result.Phase -ne 'complete'){throw ('Isolated Windows PE servicing failed; inspect '+$LogDirectory)}
|
||||
$state.Phase='complete';$state.CompletedUtc=[DateTime]::UtcNow.ToString('o');Save-WinPeState
|
||||
$successful=$true
|
||||
}catch{$state.Phase='failed';$state.Error=$_.Exception.Message;Save-WinPeState;throw}
|
||||
finally{
|
||||
if($mountedHere){Dismount-VHD -Path $VhdPath}
|
||||
if($vm){$own=Get-VM -Id $vm.Id -ErrorAction SilentlyContinue;if($own){if($own.State -ne 'Off'){Stop-VM -VM $own -TurnOff -Force};if($successful){Remove-VM -VM $own -Force}}}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$ErrorActionPreference='Stop'
|
||||
$root=Split-Path -Parent $PSScriptRoot
|
||||
. (Join-Path $root 'scripts\Project.Common.ps1')
|
||||
Assert-PowerShell51
|
||||
. (Join-Path $root 'scripts\CloudbaseInit.Resources.ps1')
|
||||
function Assert-CloudbaseTest {
|
||||
param([bool]$Condition,[string]$Message)
|
||||
if (-not $Condition) { throw $Message }
|
||||
}
|
||||
function Assert-CloudbaseFailure {
|
||||
param([scriptblock]$Action,[string]$Pattern)
|
||||
$failed=$false
|
||||
try { & $Action | Out-Null } catch { $failed=$true; if ($_.Exception.Message -notmatch $Pattern) { throw } }
|
||||
if (-not $failed) { throw ('Expected failure: '+$Pattern) }
|
||||
}
|
||||
function New-CloudbaseTestMsi {
|
||||
param([string]$Path,[string]$Product='Cloudbase-Init 1.1.8',[string]$Platform='x64')
|
||||
$installer=$null;$database=$null;$view=$null;$summary=$null
|
||||
try {
|
||||
$installer=New-Object -ComObject WindowsInstaller.Installer
|
||||
$database=$installer.GetType().InvokeMember('OpenDatabase','InvokeMethod',$null,$installer,@($Path,3))
|
||||
foreach ($sql in @('CREATE TABLE `Property` (`Property` CHAR(72) NOT NULL, `Value` CHAR(0) LOCALIZABLE PRIMARY KEY `Property`)',('INSERT INTO `Property` (`Property`, `Value`) VALUES (''ProductName'', '''+$Product+''')'))) {
|
||||
$view=$database.GetType().InvokeMember('OpenView','InvokeMethod',$null,$database,@($sql))
|
||||
$view.GetType().InvokeMember('Execute','InvokeMethod',$null,$view,$null) | Out-Null
|
||||
[Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) | Out-Null
|
||||
$view=$null
|
||||
}
|
||||
$summary=$database.GetType().InvokeMember('SummaryInformation','GetProperty',$null,$database,@(1))
|
||||
$summary.GetType().InvokeMember('Property','SetProperty',$null,$summary,@(7,($Platform+';1033'))) | Out-Null
|
||||
$summary.GetType().InvokeMember('Persist','InvokeMethod',$null,$summary,$null) | Out-Null
|
||||
$database.GetType().InvokeMember('Commit','InvokeMethod',$null,$database,$null) | Out-Null
|
||||
} finally {
|
||||
foreach ($comObject in @($summary,$view,$database,$installer)) {
|
||||
if ($null -ne $comObject) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($comObject) | Out-Null }
|
||||
}
|
||||
}
|
||||
}
|
||||
$fixture=Join-Path $root ('local\cloudbase cache tests '+[guid]::NewGuid().ToString('N').Substring(0,8))
|
||||
New-Item -ItemType Directory -Path $fixture -Force | Out-Null
|
||||
$valid=Join-Path $fixture 'valid.msi'
|
||||
$x86=Join-Path $fixture 'x86.msi'
|
||||
$wrong=Join-Path $fixture 'wrong.msi'
|
||||
$html=Join-Path $fixture 'error page.msi'
|
||||
New-CloudbaseTestMsi -Path $valid
|
||||
New-CloudbaseTestMsi -Path $x86 -Platform 'Intel'
|
||||
New-CloudbaseTestMsi -Path $wrong -Product 'Other installer'
|
||||
[IO.File]::WriteAllText($html,'<html>Download error</html>')
|
||||
Assert-PveCloudbaseInitInstaller -Path $valid -OsArch AMD64
|
||||
Assert-PveCloudbaseInitInstaller -Path $x86 -OsArch i386
|
||||
Assert-CloudbaseFailure { Assert-PveCloudbaseInitInstaller -Path $wrong } 'Unexpected MSI product'
|
||||
Assert-CloudbaseFailure { Assert-PveCloudbaseInitInstaller -Path $html } 'not a valid matching MSI'
|
||||
Assert-CloudbaseFailure { Assert-PveCloudbaseInitInstaller -Path $x86 } 'Incorrect MSI architecture'
|
||||
$assets=Join-Path $fixture 'chosen assets $literal'
|
||||
& {
|
||||
$script:downloadCalls=0
|
||||
$script:downloadMode='valid'
|
||||
function Invoke-PveCloudbaseInitDownload {
|
||||
param($Uri,$Destination)
|
||||
$script:downloadCalls++
|
||||
if ($script:downloadMode -eq 'failure') { [IO.File]::WriteAllText($Destination,'partial download'); throw 'Simulated download failure.' }
|
||||
if ($script:downloadMode -eq 'html') { Copy-Item -LiteralPath $html -Destination $Destination; return }
|
||||
$source=$valid
|
||||
if ($Uri -like '*_x86.msi') { $source=$x86 }
|
||||
Copy-Item -LiteralPath $source -Destination $Destination
|
||||
}
|
||||
$cache=Get-PveCloudbaseInitCachePath -AssetsRoot $assets
|
||||
$script:downloadMode='failure'
|
||||
Assert-CloudbaseFailure { Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets } 'Simulated download failure'
|
||||
Assert-CloudbaseTest (-not (Test-Path -LiteralPath $cache)) 'A failed download was published as a cache.'
|
||||
$script:downloadMode='valid'
|
||||
$first=Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets
|
||||
Assert-CloudbaseTest ($first -eq $cache) 'Cache path differs from the selected asset directory.'
|
||||
$originalHash=(Get-FileHash -LiteralPath $valid).Hash
|
||||
Assert-CloudbaseTest ((Get-FileHash -LiteralPath $cache).Hash -eq $originalHash) 'Caching changed the MSI bytes.'
|
||||
$before=$script:downloadCalls
|
||||
$script:downloadMode='failure'
|
||||
$second=Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets
|
||||
Assert-CloudbaseTest ($second -eq $cache -and $script:downloadCalls -eq $before) 'A valid cache required network access.'
|
||||
[IO.File]::WriteAllText($cache,'damaged cache')
|
||||
$script:downloadMode='html'
|
||||
Assert-CloudbaseFailure { Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets } 'not a valid matching MSI'
|
||||
Assert-CloudbaseTest ([IO.File]::ReadAllText($cache) -eq 'damaged cache') 'An invalid download overwrote the previous file.'
|
||||
$script:downloadMode='valid'
|
||||
Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets | Out-Null
|
||||
Assert-CloudbaseTest ((Get-FileHash -LiteralPath $cache).Hash -eq $originalHash) 'The corrupt cache was not repaired.'
|
||||
$beta=Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets -BetaRelease
|
||||
$otherArch=Initialize-PveCloudbaseInitInstaller -AssetsRoot $assets -OsArch i386
|
||||
Assert-CloudbaseTest ($beta -ne $cache -and $otherArch -ne $cache -and $otherArch -ne $beta) 'Architecture/release caches overlap.'
|
||||
Assert-CloudbaseTest (-not @(Get-ChildItem -LiteralPath (Split-Path -Parent $cache) -Filter '*.partial.msi').Count) 'Partial downloads were left behind.'
|
||||
}
|
||||
$settings=[PSCustomObject]@{ProjectRoot=$root;AssetsRoot=$assets;UpstreamRoot=(Join-Path $root 'vendor\windows-imaging-tools');QemuManifestPath=(Join-Path $fixture 'unused-tool-manifest.json')}
|
||||
. (Join-Path $root 'scripts\Import-ImagingTools.ps1') -Settings $settings
|
||||
$module=Get-Module WinImageBuilder
|
||||
& $module {
|
||||
function script:Invoke-PveCloudbaseInitDownload { throw 'Image staging must reuse the validated cache.' }
|
||||
} | Out-Null
|
||||
foreach ($mode in @('generated','legacy','custom')) {
|
||||
$imageResources=Join-Path $fixture ('image '+$mode+'\UnattendResources')
|
||||
New-Item -ItemType Directory -Path $imageResources -Force | Out-Null
|
||||
$msi=''
|
||||
if ($mode -eq 'generated') { $msi=Get-PveCloudbaseInitCachePath -AssetsRoot $assets }
|
||||
if ($mode -eq 'custom') { $msi=$valid }
|
||||
& $module {
|
||||
param($Resources,$Msi,$Config)
|
||||
Download-CloudbaseInit -resourcesDir $Resources -osArch AMD64 -MsiPath $Msi -CloudbaseInitConfigPath $Config
|
||||
} $imageResources $msi (Join-Path $root 'config\cloudbase-init.conf')
|
||||
Assert-CloudbaseTest ((Get-FileHash -LiteralPath (Join-Path $imageResources 'CloudbaseInit.msi')).Hash -eq (Get-FileHash -LiteralPath $valid).Hash) ('Image MSI staging failed: '+$mode)
|
||||
Assert-CloudbaseTest ((Get-FileHash -LiteralPath (Join-Path $imageResources 'cloudbase-init.conf')).Hash -eq (Get-FileHash -LiteralPath (Join-Path $root 'config\cloudbase-init.conf')).Hash) 'Cloudbase-Init configuration staging changed.'
|
||||
}
|
||||
Assert-CloudbaseFailure {
|
||||
& $module { param($Resources,$Missing); Download-CloudbaseInit -resourcesDir $Resources -osArch AMD64 -MsiPath $Missing } $imageResources (Join-Path $fixture 'missing custom.msi')
|
||||
} 'does not exist'
|
||||
Assert-CloudbaseTest (Test-Path -LiteralPath (Get-PveCloudbaseInitCachePath -AssetsRoot $assets)) 'Staging removed the host cache.'
|
||||
[PSCustomObject]@{MsiValidation='passed';FailedDownload='not published';CacheReuse='offline';CorruptCache='repaired';InvalidDownload='previous file preserved';ArchitectureAndRelease='separate';PartialCleanup='passed';ImageStaging='generated, legacy and custom';PathsWithSpaces='passed'}
|
||||
@@ -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'}
|
||||
}
|
||||
@@ -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'}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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'}
|
||||
@@ -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'}}
|
||||
@@ -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'}
|
||||
@@ -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
|
||||
@@ -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'}
|
||||
}
|
||||
@@ -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'}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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'
|
||||
}
|
||||
+1
Submodule vendor/windows-imaging-tools added at 342c6788c5.
Reference in new issue
Block a user