feat: add integration test infra and bootstrapper updates

This commit is contained in:
2026-06-03 19:27:34 -05:00
parent 1392a503f1
commit 7f0176cbf5
45 changed files with 5751 additions and 589 deletions
+26
View File
@@ -0,0 +1,26 @@
# Copy this file to .env (same directory) and fill in your local values.
# `.env` itself is gitignored. Load it before running `packer build` with:
#
# . .\load-env.ps1
#
# All recognized variables map to Packer inputs via the PKR_VAR_<name>
# convention, so every name below is also a valid -var flag.
# Required. Absolute path to the Windows 11 installer ISO on this host.
PKR_VAR_iso_path=C:/path/to/Win11_25H2_English_x64_v2.iso
# Optional. SHA-256 of the ISO. Compute with:
# Get-FileHash <iso> -Algorithm SHA256
# Leave as "none" to skip validation (not recommended for shared boxes).
PKR_VAR_iso_checksum=none
# Optional overrides. Defaults are in windows-11.pkr.hcl. The build VM is
# throwaway and intentionally claims most of the host (24 vCPU, 32 GB RAM);
# the runtime integration VM uses a smaller footprint set in
# test/integration/Vagrantfile.
# PKR_VAR_cpus=24
# PKR_VAR_memory=32768
# PKR_VAR_disk_size=81920
# PKR_VAR_switch_name=Default Switch
# PKR_VAR_vm_name=bootstrap-win11
# PKR_VAR_box_output=bootstrap-win11.box
+167
View File
@@ -0,0 +1,167 @@
# Vagrant box build (Packer + Hyper-V + Windows 11 25H2)
Builds a `bootstrap-win11.box` Vagrant box from a stock Windows 11 25H2 ISO,
ready to use with `test/integration/Vagrantfile`.
## Prerequisites
On the host (must be Windows with Hyper-V):
- Hyper-V role enabled; current user in **Hyper-V Administrators**.
- [HashiCorp Packer](https://developer.hashicorp.com/packer/install) 1.15+ in
`PATH`. HashiCorp does not publish a winget manifest, so use the helper:
```powershell
# from an elevated PowerShell
cd test\integration\packer
.\install-packer.ps1 # installs the pinned default version
.\install-packer.ps1 -Version 1.14.0 # or pick a specific release
```
The script downloads
`https://releases.hashicorp.com/packer/<Version>/packer_<Version>_windows_amd64.zip`,
extracts `packer.exe` to `C:\Program Files\Packer\`, and adds that directory
to the machine-scoped `PATH`. It is idempotent — re-running with the same
version just re-asserts the `PATH` entry. See the `param()` block in
`install-packer.ps1` for available flags.
By default the same script also installs **Windows ADK Deployment Tools**
to get `oscdimg.exe`, which Packer's `hyperv-iso` builder needs to build the
unattended-install secondary ISO from `cd_files`. Only the
`OptionId.DeploymentTools` feature is selected, so the install is small
(~70 MB) and unattended. Pass `-SkipOscdimg` to opt out (e.g. if you already
have `xorriso`/`mkisofs` on `PATH`). After install you may need to open a
fresh shell — or run
`$env:Path = [Environment]::GetEnvironmentVariable('Path','Machine')` — for
`oscdimg.exe` to resolve in the current session.
- [HashiCorp Vagrant](https://developer.hashicorp.com/vagrant/install) 2.4+ in
`PATH` (used for the `vagrant` post-processor and, later, the integration
test itself).
- A Windows 11 client ISO somewhere on disk. Its path is read from `.env`
(see below) and is **not** hardcoded in the Packer config.
- Roughly 90 GB free disk and ~60 minutes of patience for a first build.
- An elevated PowerShell session for the build (`packer build` shells out to
Hyper-V management APIs that require admin).
## Configure the ISO path via `.env`
The Packer config has no default ISO path. Copy the template, fill it in, and
load it into the current shell before building:
```powershell
cd test\integration\packer
Copy-Item .env.example .env
# edit .env so PKR_VAR_iso_path points at your Windows 11 ISO
. .\load-env.ps1
```
`load-env.ps1` reads `.env` line-by-line and exports each `KEY=VALUE` into the
process environment. `.env` is gitignored at the repo root, so your local path
never gets committed.
Packer picks the values up automatically through its `PKR_VAR_<name>` env-var
convention; nothing about the `packer build` invocation changes.
## One-time setup
```powershell
packer init .\windows-11.pkr.hcl
```
That downloads the `hyperv` and `vagrant` Packer plugins.
## Compute and pin the ISO checksum (recommended)
```powershell
Get-FileHash $env:PKR_VAR_iso_path -Algorithm SHA256
```
Set `PKR_VAR_iso_checksum=sha256:<digest>` in `.env` and re-run `load-env.ps1`.
Leaving it as `none` skips validation; fine for local builds but loses the "my
ISO has not been swapped" guarantee.
## Build
```powershell
packer build .\windows-11.pkr.hcl
```
Roughly what happens:
1. Packer creates a Gen 2 Hyper-V VM (Secure Boot + TPM + 24 vCPU + 32 GB RAM
+ 80 GB dynamic VHDX) attached to `Default Switch`. The build VM is
throwaway and intentionally claims most of the host for speed; the
runtime integration VM uses smaller defaults set in
`test/integration/Vagrantfile` (8 vCPU / 16 GB).
2. It mounts the Windows ISO and a tiny `PROVISION` ISO containing
`cd/autounattend.xml` and `cd/scripts/oobe-enable-winrm.ps1`.
3. Windows Setup performs an unattended install (Pro edition, generic
activation key) and creates user `vagrant`:`vagrant`.
4. FirstLogonCommands enable WinRM (HTTP, basic auth) and set
`LocalAccountTokenFilterPolicy=1` so remote admin tokens are not filtered.
5. Packer connects over WinRM and runs the provisioners in `scripts/`:
- `configure-os.ps1` — UAC token policy, DiagTrack off, NTP resync.
- `disable-windows-updates.ps1` — fully disables WU and friends.
- `disable-defender-cloud.ps1` — disables MAPS / cloud lookups.
- `install-vagrant-key.ps1` — drops the vagrant insecure public key.
- `compact.ps1` — cleans caches, zeros free space.
6. Packer shuts the guest down cleanly and the `vagrant` post-processor
packages the VHDX into `bootstrap-win11.box`.
## Register the resulting box with Vagrant
```powershell
vagrant box add bootstrap-win11 .\bootstrap-win11.box
```
Then in `test/integration/Vagrantfile` set:
```powershell
$env:BOOTSTRAP_BOX = "bootstrap-win11"
go run ..\..\test\integration
```
(or just edit the default in the Vagrantfile to `bootstrap-win11`).
## Layout
```
test/integration/packer/
├── README.md ← this file
├── .env.example ← template; copy to .env (gitignored)
├── load-env.ps1 ← dot-source to export PKR_VAR_* into shell
├── install-packer.ps1 ← elevated installer for packer.exe
├── windows-11.pkr.hcl ← Packer HCL2 build definition
├── vagrantfile-template.rb ← baked into the output box
├── cd/
│ ├── autounattend.xml ← Windows Setup answer file
│ └── scripts/
│ └── oobe-enable-winrm.ps1
└── scripts/ ← provisioners run after WinRM is up
├── configure-os.ps1
├── disable-windows-updates.ps1
├── disable-defender-cloud.ps1
├── install-vagrant-key.ps1
└── compact.ps1
```
## Notes and gotchas
- **Run elevated.** `packer build` opens Hyper-V management APIs; non-elevated
shells fail with confusing "access denied" messages midway through.
- **First build is slow.** Windows Setup + updates trimming + cipher /w on a
60 GB volume can take 45–90 min. Subsequent rebuilds reuse the parent VHDX
via linked clone and are much faster.
- **License compliance.** The box uses the public KMS client setup key
(W269N-WFGWX-YVC9B-4J6C9-T83GX) to pick the Pro edition during install. The
resulting VM is not activated; for short-lived integration runs the eval
period is more than sufficient, but redistribute the box only under your
own licensing terms.
- **25H2 OOBE.** The autounattend sets `BypassNRO=1` during `specialize`
because 24H2/25H2 removed the `BypassNRO.cmd` helper. If a future Windows
update changes the OOBE flow again, the symptom will be a hung
`vagrant up` waiting for WinRM; check by opening Hyper-V Manager and
looking for an OOBE screen on the VM console.
- **Secure Boot.** Enabled (`MicrosoftWindows` template). Switch off in the
Packer source if you ever need to install unsigned kernel-mode drivers in
the box.
+200
View File
@@ -0,0 +1,200 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Unattend file for Windows 11 25H2 (Pro), Hyper-V Gen 2, UEFI + Secure Boot.
Goals:
- Fully unattended install on the only VHDX presented by the VM.
- Create local user `vagrant` (password `vagrant`) in Administrators.
- Skip every OOBE wizard page (including the network-required screen).
- Bring WinRM online with basic auth and unencrypted transport so Packer
and Vagrant can talk to the guest.
The BypassNRO trick is set in `specialize` rather than relying on the
removed BypassNRO.cmd script, so this works on 24H2/25H2.
-->
<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" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<SetupUILanguage>
<UILanguage>en-US</UILanguage>
</SetupUILanguage>
<InputLocale>0409:00000409</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</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">
<DiskConfiguration>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Type>EFI</Type>
<Size>300</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>2</Order>
<Type>MSR</Type>
<Size>128</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>3</Order>
<Type>Primary</Type>
<Extend>true</Extend>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Format>FAT32</Format>
<Label>System</Label>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>2</Order>
<PartitionID>2</PartitionID>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>3</Order>
<PartitionID>3</PartitionID>
<Format>NTFS</Format>
<Label>Windows</Label>
<Letter>C</Letter>
</ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallTo>
<DiskID>0</DiskID>
<PartitionID>3</PartitionID>
</InstallTo>
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/NAME</Key>
<Value>Windows 11 Pro</Value>
</MetaData>
</InstallFrom>
</OSImage>
</ImageInstall>
<UserData>
<!-- KMS client setup key for Windows 11 Pro; lets the installer pick
the right edition without prompting. Replaced at activation. -->
<ProductKey>
<Key>W269N-WFGWX-YVC9B-4J6C9-T83GX</Key>
<WillShowUI>OnError</WillShowUI>
</ProductKey>
<AcceptEula>true</AcceptEula>
<FullName>vagrant</FullName>
<Organization>vagrant</Organization>
</UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-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">
<ComputerName>bootstrap-win11</ComputerName>
<TimeZone>UTC</TimeZone>
</component>
<component name="Microsoft-Windows-Deployment" 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>
<!-- BypassNRO: allows OOBE without a network/Microsoft account.
24H2/25H2 removed the BypassNRO.cmd helper, so we set the
registry key directly during specialize. -->
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE /v BypassNRO /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<!-- Allow WinRM remote-elevated tokens (#3 mitigation). -->
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System /v LocalAccountTokenFilterPolicy /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-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">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>vagrant</Name>
<Group>Administrators</Group>
<DisplayName>vagrant</DisplayName>
<Description>Vagrant Box Build</Description>
<Password>
<Value>vagrant</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Enabled>true</Enabled>
<Username>vagrant</Username>
<LogonCount>3</LogonCount>
<Password>
<Value>vagrant</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<!-- Allow PowerShell scripts unsigned (machine scope). -->
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-ExecutionPolicy -Scope LocalMachine -ExecutionPolicy Bypass -Force"</CommandLine>
<Description>Set ExecutionPolicy Bypass</Description>
</SynchronousCommand>
<!-- Enable WinRM inline so we don't depend on the PROVISION ISO
still being mounted at first logon (Windows Setup sometimes
detaches secondary DVDs after install). Mirrors the contents
of scripts/oobe-enable-winrm.ps1 (now unused) and writes a
transcript to C:\Windows\Temp\firstlogon.log for debugging. -->
<SynchronousCommand wcm:action="add">
<Order>2</Order>
<CommandLine>cmd.exe /c winrm quickconfig -force -q &gt; C:\Windows\Temp\firstlogon.log 2&gt;&amp;1</CommandLine>
<Description>WinRM quickconfig</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-NetConnectionProfile -NetworkCategory Private -ErrorAction SilentlyContinue; Set-Item WSMan:\localhost\Service\Auth\Basic $true; Set-Item WSMan:\localhost\Service\AllowUnencrypted $true; Set-Item WSMan:\localhost\Service\MaxConcurrentOperationsPerUser 12000; Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 1024" &gt;&gt; C:\Windows\Temp\firstlogon.log 2&gt;&amp;1</CommandLine>
<Description>Configure WinRM auth and limits</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>4</Order>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "New-NetFirewallRule -Name WinRM-HTTP -DisplayName 'WinRM HTTP' -Protocol TCP -LocalPort 5985 -Action Allow -Profile Any -ErrorAction SilentlyContinue" &gt;&gt; C:\Windows\Temp\firstlogon.log 2&gt;&amp;1</CommandLine>
<Description>Open firewall for WinRM HTTP</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>5</Order>
<CommandLine>cmd.exe /c sc config WinRM start= auto &amp;&amp; net stop WinRM &amp;&amp; net start WinRM &gt;&gt; C:\Windows\Temp\firstlogon.log 2&gt;&amp;1</CommandLine>
<Description>Restart WinRM service</Description>
</SynchronousCommand>
</FirstLogonCommands>
<TimeZone>UTC</TimeZone>
</component>
</settings>
</unattend>
+206
View File
@@ -0,0 +1,206 @@
<#
.SYNOPSIS
Installs HashiCorp Packer on Windows by downloading the release zip,
extracting it to Program Files, and adding that directory to the system
PATH.
.DESCRIPTION
Winget does not publish HashiCorp Packer, so this helper does the install
by hand. It is idempotent: if the requested version is already installed
at the target location it just re-asserts the PATH entry and exits.
Requires an elevated PowerShell session (writes under Program Files and
modifies the machine-scoped PATH environment variable).
.PARAMETER Version
Packer release to install (e.g. "1.15.3"). The download URL is computed
as https://releases.hashicorp.com/packer/<Version>/packer_<Version>_windows_amd64.zip.
.PARAMETER InstallRoot
Directory under which packer.exe is placed. Defaults to
"$env:ProgramFiles\Packer". This directory is what gets added to the
system PATH.
.PARAMETER SkipOscdimg
Skip installing the Windows ADK Deployment Tools (which provide
oscdimg.exe). Packer's hyperv-iso builder needs an ISO-creation tool
(xorriso, mkisofs, hdiutil, or oscdimg) on PATH when using cd_files,
so by default this script also installs oscdimg via the Windows ADK
bootstrapper with only the DeploymentTools feature selected (~70 MB
on disk).
.PARAMETER AdkSetupUrl
Override the URL used to download the Windows ADK setup bootstrapper.
Defaults to Microsoft's stable fwlink for the Windows 11 ADK.
.EXAMPLE
# Install the default version into "C:\Program Files\Packer".
.\install-packer.ps1
.EXAMPLE
# Install a specific version.
.\install-packer.ps1 -Version 1.14.0
.EXAMPLE
# Install Packer only; skip the Windows ADK Deployment Tools install.
.\install-packer.ps1 -SkipOscdimg
#>
[CmdletBinding()]
param(
[string] $Version = '1.15.3',
[string] $InstallRoot = (Join-Path $env:ProgramFiles 'Packer'),
[switch] $SkipOscdimg,
[string] $AdkSetupUrl = 'https://go.microsoft.com/fwlink/?linkid=2289980'
)
$ErrorActionPreference = 'Stop'
function Assert-Elevated {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "install-packer.ps1 must be run from an elevated PowerShell session (writes to Program Files and the machine PATH)."
}
}
function Get-InstalledPackerVersion {
param([string] $Root)
$exe = Join-Path $Root 'packer.exe'
if (-not (Test-Path -LiteralPath $exe)) { return $null }
try {
$output = & $exe version 2>$null
} catch {
return $null
}
foreach ($line in $output) {
if ($line -match 'Packer\s+v?([0-9]+\.[0-9]+\.[0-9]+)') {
return $Matches[1]
}
}
return $null
}
function Add-ToMachinePath {
param([string] $Directory)
$current = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$parts = @()
if ($current) {
$parts = $current.Split(';') | Where-Object { $_ -ne '' }
}
if ($parts -contains $Directory) {
Write-Host "system PATH already contains $Directory"
return
}
$newPath = ($parts + $Directory) -join ';'
[Environment]::SetEnvironmentVariable('Path', $newPath, 'Machine')
# Make the change visible in the current session as well so the caller
# can immediately run `packer` without opening a new shell.
if (-not (($env:Path -split ';') -contains $Directory)) {
$env:Path = "$env:Path;$Directory"
}
Write-Host "added $Directory to the system PATH (machine scope)."
}
function Get-OscdimgPath {
$candidates = @(
(Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe'),
(Join-Path ${env:ProgramFiles} 'Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe')
)
foreach ($c in $candidates) {
if ($c -and (Test-Path -LiteralPath $c)) { return $c }
}
return $null
}
function Install-Oscdimg {
param([string] $SetupUrl)
$existing = Get-OscdimgPath
if ($existing) {
Write-Host "oscdimg.exe already present at $existing"
Add-ToMachinePath -Directory (Split-Path -Parent $existing)
return
}
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("install-adk-" + [Guid]::NewGuid())
$setup = Join-Path $tmp 'adksetup.exe'
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
try {
Write-Host "downloading Windows ADK bootstrapper from $SetupUrl"
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Uri $SetupUrl -OutFile $setup
Write-Host "installing Windows ADK Deployment Tools (this may take several minutes)"
# /features OptionId.DeploymentTools => oscdimg only (~70 MB)
# /quiet /norestart /ceip off => unattended, no telemetry prompt
$args = @('/features','OptionId.DeploymentTools','/quiet','/norestart','/ceip','off')
$proc = Start-Process -FilePath $setup -ArgumentList $args -Wait -PassThru
if ($proc.ExitCode -ne 0) {
throw "Windows ADK setup exited with code $($proc.ExitCode)."
}
}
finally {
Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
$installed = Get-OscdimgPath
if (-not $installed) {
throw "Windows ADK setup completed but oscdimg.exe was not found in the expected location."
}
Write-Host "installed oscdimg.exe at $installed"
Add-ToMachinePath -Directory (Split-Path -Parent $installed)
}
Assert-Elevated
# Idempotency: if the target version is already installed, just make sure the
# PATH entry is in place and return.
$existing = Get-InstalledPackerVersion -Root $InstallRoot
if ($existing -eq $Version) {
Write-Host "packer $Version already installed at $InstallRoot."
Add-ToMachinePath -Directory $InstallRoot
if (-not $SkipOscdimg) { Install-Oscdimg -SetupUrl $AdkSetupUrl }
return
}
if ($existing) {
Write-Host "replacing packer $existing at $InstallRoot with $Version."
}
$url = "https://releases.hashicorp.com/packer/$Version/packer_${Version}_windows_amd64.zip"
$tmpRoot = Join-Path ([IO.Path]::GetTempPath()) ("install-packer-" + [Guid]::NewGuid())
$zipPath = Join-Path $tmpRoot "packer_${Version}.zip"
New-Item -ItemType Directory -Path $tmpRoot -Force | Out-Null
try {
Write-Host "downloading $url"
# TLS 1.2 is required to talk to releases.hashicorp.com on stock
# Windows 10/Server 2019 PowerShell sessions.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zipPath
Write-Host "extracting to $tmpRoot"
Expand-Archive -LiteralPath $zipPath -DestinationPath $tmpRoot -Force
$srcExe = Join-Path $tmpRoot 'packer.exe'
if (-not (Test-Path -LiteralPath $srcExe)) {
throw "packer.exe not found inside $zipPath after extraction."
}
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
Copy-Item -LiteralPath $srcExe -Destination (Join-Path $InstallRoot 'packer.exe') -Force
Write-Host "installed packer.exe to $InstallRoot"
Add-ToMachinePath -Directory $InstallRoot
$installed = Get-InstalledPackerVersion -Root $InstallRoot
if ($installed -ne $Version) {
Write-Warning "post-install version check returned '$installed' (expected $Version)."
} else {
Write-Host "verified: packer $installed available at $(Join-Path $InstallRoot 'packer.exe')."
}
}
finally {
Remove-Item -LiteralPath $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue
}
if (-not $SkipOscdimg) { Install-Oscdimg -SetupUrl $AdkSetupUrl }
+34
View File
@@ -0,0 +1,34 @@
# Loads variables from .env (in this directory) into the current PowerShell
# session as environment variables. Dot-source it so the variables stick:
#
# . .\load-env.ps1
#
# Format:
# - One KEY=VALUE per line.
# - Lines starting with '#' and blank lines are ignored.
# - Values are taken verbatim (no shell quoting / interpolation). Surround
# with quotes only if you actually want quotes in the value.
$ErrorActionPreference = 'Stop'
$envFile = Join-Path $PSScriptRoot '.env'
if (-not (Test-Path -LiteralPath $envFile)) {
throw ".env not found at $envFile. Copy .env.example to .env and fill in PKR_VAR_iso_path."
}
$loaded = 0
foreach ($line in Get-Content -LiteralPath $envFile) {
$trimmed = $line.Trim()
if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue }
$idx = $trimmed.IndexOf('=')
if ($idx -lt 1) {
Write-Warning "skipping malformed line: $line"
continue
}
$name = $trimmed.Substring(0, $idx).Trim()
$value = $trimmed.Substring($idx + 1)
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
$loaded++
}
Write-Host "loaded $loaded variable(s) from $envFile into the current session."
View File
+78
View File
@@ -0,0 +1,78 @@
Warning: A checksum of 'none' was specified. Since ISO files are so big,
a checksum is highly recommended.
on .\windows-11.pkr.hcl line 83:
(source code not available)
hyperv-iso.win11: output will be in this color.
==> hyperv-iso.win11: Creating build directory...
==> hyperv-iso.win11: Retrieving ISO
==> hyperv-iso.win11: Trying C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2(1).iso
==> hyperv-iso.win11: Trying file://C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2%281%29.iso
==> hyperv-iso.win11: file://C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2%281%29.iso => C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2(1).iso
==> hyperv-iso.win11: Creating switch 'Default Switch' if required...
==> hyperv-iso.win11: switch 'Default Switch' already exists. Will not delete on cleanup...
==> hyperv-iso.win11: Creating virtual machine...
==> hyperv-iso.win11: Enabling Integration Service...
==> hyperv-iso.win11: Setting boot drive to os dvd drive C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2(1).iso ...
==> hyperv-iso.win11: Mounting os dvd drive C:/Users/jasonross/Downloads/Win11_25H2_English_x64_v2(1).iso ...
==> hyperv-iso.win11: Skipping mounting Integration Services Setup Disk...
==> hyperv-iso.win11: Creating CD disk...
==> hyperv-iso.win11: OSCDIMG 2.56 CD-ROM and DVD-ROM Premastering Utility
==> hyperv-iso.win11: Copyright (C) Microsoft, 1993-2012. All rights reserved.
==> hyperv-iso.win11: Licensed only for producing Microsoft authorized content.
==> hyperv-iso.win11: Scanning source tree
==> hyperv-iso.win11: Scanning source tree complete (2 files in 1 directories)
==> hyperv-iso.win11: Computing directory information complete
==> hyperv-iso.win11: Image file is 61440 bytes (before optimization)
==> hyperv-iso.win11: Writing 2 files in 1 directories to C:\Users\jasonross\AppData\Local\Temp\packer2666332811.iso
==> hyperv-iso.win11: Storage optimization saved 0 files, 0 bytes (0% of image)
==> hyperv-iso.win11: After optimization, image file is 61440 bytes
==> hyperv-iso.win11: Done.
==> hyperv-iso.win11: 100% complete
==> hyperv-iso.win11: Done copying paths from CD_dirs
==> hyperv-iso.win11: Mounting secondary DVD images...
==> hyperv-iso.win11: Mounting secondary dvd drive C:\Users\jasonross\AppData\Local\Temp\packer2666332811.iso ...
==> hyperv-iso.win11: Configuring vlan...
==> hyperv-iso.win11: Determine Host IP for HyperV machine...
==> hyperv-iso.win11: Host IP for the HyperV machine: 172.25.16.1
==> hyperv-iso.win11: Attempting to connect with vmconnect...
==> hyperv-iso.win11: Starting the virtual machine...
==> hyperv-iso.win11: Waiting 1s for boot...
==> hyperv-iso.win11: Typing the boot command...
==> hyperv-iso.win11: Waiting for WinRM to become available...
==> hyperv-iso.win11: WinRM connected.
==> hyperv-iso.win11: Connected to WinRM!
==> hyperv-iso.win11: Provisioning with Powershell...
==> hyperv-iso.win11: Provisioning with powershell script: ./scripts/configure-os.ps1
==> hyperv-iso.win11: Script exited with non-zero exit status: 2147943462. Allowed exit codes are: [0]
==> hyperv-iso.win11: Step "StepProvision" failed, aborting...
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepConnect"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepTypeBootCommand"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepRun"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepSetFirstBootDevice"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepSetBootOrder"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepConfigureVlan"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepMountSecondaryDvdImages"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateCD"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepMountGuestAdditions"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepMountFloppydrive"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepMountDvdDrive"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepEnableIntegrationService"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateVM"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateSwitch"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepHTTPServer"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateFloppy"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepDownload"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepOutputDir"
==> hyperv-iso.win11: aborted: skipping cleanup of step "StepCreateBuildDir"
Build 'hyperv-iso.win11' errored after 16 minutes 59 seconds: Script exited with non-zero exit status: 2147943462. Allowed exit codes are: [0]
==> Wait completed after 16 minutes 59 seconds
==> Some builds didn't complete successfully and had errors:
--> hyperv-iso.win11: Script exited with non-zero exit status: 2147943462. Allowed exit codes are: [0]
==> Builds finished but no artifacts were created.
+1
View File
@@ -0,0 +1 @@
51192
@@ -0,0 +1,25 @@
# Zero out free space so the resulting .box file is small.
#
# Runs last, just before Packer initiates shutdown. Cleans caches and the WU
# component store, which directly shrinks the on-disk footprint of the VHDX.
# We intentionally do not call `cipher /w:C:\` here: it costs 15–30 minutes
# and its only payoff is better tarball compression of unused space, which
# only matters when shipping the box. Re-add it before publishing publicly.
$ErrorActionPreference = 'Continue'
# Clean Windows Update download cache and component store.
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
Remove-Item -Path "$env:WINDIR\SoftwareDistribution\Download\*" -Recurse -Force -ErrorAction SilentlyContinue
& dism /Online /Cleanup-Image /StartComponentCleanup /ResetBase /Quiet
# Empty Recycle Bin and temp dirs.
Get-ChildItem -Path "$env:TEMP" -Force -ErrorAction SilentlyContinue |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Clear-RecycleBin -Force -ErrorAction SilentlyContinue
# Light defrag pass to consolidate free extents. Quick on an SSD-backed VHDX.
& defrag C: /U /V
# Drop hibernation file (Windows 11 has it on by default).
& powercfg /h off
@@ -0,0 +1,28 @@
# Generic OS tweaks applied after WinRM is reachable.
#
# Idempotent; safe to rerun.
$ErrorActionPreference = 'Stop'
# Make sure the remote-elevated token policy survives any unattended changes.
$policy = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
New-ItemProperty -Path $policy -Name 'LocalAccountTokenFilterPolicy' `
-PropertyType DWord -Value 1 -Force | Out-Null
# Disable telemetry "Connected User Experiences" service to avoid background
# network noise that competes with the bootstrapper.
$svc = Get-Service -Name DiagTrack -ErrorAction SilentlyContinue
if ($svc) {
Set-Service -Name DiagTrack -StartupType Disabled
Stop-Service -Name DiagTrack -Force -ErrorAction SilentlyContinue
}
# Disable SmartScreen network checks (slows winget installs in the lab).
$ss = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
New-Item -Path $ss -Force | Out-Null
New-ItemProperty -Path $ss -Name 'EnableSmartScreen' -PropertyType DWord -Value 0 -Force | Out-Null
# Force NTP resync; Hyper-V time-sync after sysprep can drift, which breaks
# TLS handshakes against winget / GitHub.
Start-Service -Name w32time -ErrorAction SilentlyContinue
& w32tm /resync /force 2>&1 | Out-Null
@@ -0,0 +1,12 @@
# Turn off Defender cloud-delivered protection and sample submission.
#
# Cloud lookups add tens of seconds per winget install on a cold box and have
# no value in an ephemeral integration VM.
$ErrorActionPreference = 'Continue'
Set-MpPreference -MAPSReporting Disabled -ErrorAction SilentlyContinue
Set-MpPreference -SubmitSamplesConsent NeverSend -ErrorAction SilentlyContinue
Set-MpPreference -DisableBlockAtFirstSeen $true -ErrorAction SilentlyContinue
Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue
Set-MpPreference -ScanAvgCPULoadFactor 5 -ErrorAction SilentlyContinue
@@ -0,0 +1,26 @@
# Disable Windows Update on the gold image so it doesn't fight with the
# bootstrapper's own WindowsUpdateCommand step at integration-test time.
#
# This is the precise mitigation for the 6-hour CI hang investigation: by
# the time the bootstrapper runs, no other process is holding the WU agent.
$ErrorActionPreference = 'Stop'
$au = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU'
New-Item -Path $au -Force | Out-Null
New-ItemProperty -Path $au -Name 'NoAutoUpdate' -PropertyType DWord -Value 1 -Force | Out-Null
New-ItemProperty -Path $au -Name 'AUOptions' -PropertyType DWord -Value 1 -Force | Out-Null
New-ItemProperty -Path $au -Name 'ScheduledInstallDay' -PropertyType DWord -Value 0 -Force | Out-Null
New-ItemProperty -Path $au -Name 'NoAutoRebootWithLoggedOnUsers' -PropertyType DWord -Value 1 -Force | Out-Null
$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
New-Item -Path $wu -Force | Out-Null
New-ItemProperty -Path $wu -Name 'DoNotConnectToWindowsUpdateInternetLocations' -PropertyType DWord -Value 1 -Force | Out-Null
foreach ($svc in 'wuauserv','UsoSvc','WaaSMedicSvc') {
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
if ($s) {
Set-Service -Name $svc -StartupType Disabled -ErrorAction SilentlyContinue
Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,15 @@
# Install the Vagrant insecure SSH key marker so `vagrant ssh-config` and
# tooling that expects the Vagrant-flavoured user account is happy. WinRM is
# the actual communicator, but several Vagrant plugins probe for this layout.
$ErrorActionPreference = 'Continue'
$sshDir = "$env:USERPROFILE\.ssh"
New-Item -ItemType Directory -Path $sshDir -Force | Out-Null
$keyUrl = 'https://raw.githubusercontent.com/hashicorp/vagrant/main/keys/vagrant.pub'
try {
Invoke-WebRequest -UseBasicParsing -Uri $keyUrl -OutFile "$sshDir\authorized_keys"
} catch {
Write-Host "warning: could not fetch vagrant insecure public key: $($_.Exception.Message)"
}
@@ -0,0 +1,25 @@
# Vagrantfile fragment baked into the output .box file by the
# `packer-post-processor-vagrant` plugin. This is what every `vagrant up`
# of a derived box inherits before our top-level Vagrantfile is applied.
Vagrant.configure("2") do |config|
config.vm.communicator = "winrm"
config.vm.guest = :windows
config.winrm.username = "vagrant"
config.winrm.password = "vagrant"
config.winrm.transport = :plaintext
config.winrm.basic_auth_only = true
config.vm.boot_timeout = 1800
config.vm.provider "hyperv" do |hv|
hv.enable_virtualization_extensions = false
hv.linked_clone = true
hv.vm_integration_services = {
guest_service_interface: true,
heartbeat: true,
shutdown: true,
time_synchronization: true,
vss: true,
}
end
end
+157
View File
@@ -0,0 +1,157 @@
packer {
required_plugins {
hyperv = {
version = ">= 1.1.4"
source = "github.com/hashicorp/hyperv"
}
vagrant = {
version = ">= 1.1.5"
source = "github.com/hashicorp/vagrant"
}
}
}
# ─── Inputs ───────────────────────────────────────────────────────────────────
variable "iso_path" {
type = string
description = "Absolute path to the Windows 11 installer ISO. Set via PKR_VAR_iso_path (load .env first; see README)."
}
variable "iso_checksum" {
type = string
description = "Checksum of the ISO (e.g. 'sha256:<hex>'); 'none' skips validation. Set via PKR_VAR_iso_checksum."
default = "none"
}
variable "vm_name" {
type = string
default = "bootstrap-win11"
}
variable "output_directory" {
type = string
default = "output-bootstrap-win11"
}
variable "box_output" {
type = string
default = "bootstrap-win11.box"
}
variable "cpus" {
type = number
default = 24
description = "vCPUs allocated to the build VM. The Packer guest is throwaway and gets the full host budget for speed; the runtime integration VM keeps a smaller footprint in test/integration/Vagrantfile."
}
variable "memory" {
type = number
default = 32768
description = "Build-VM memory (MB). Throwaway VM, full host budget."
}
variable "disk_size" {
type = number
default = 81920
description = "Guest disk size in MB."
}
variable "switch_name" {
type = string
default = "Default Switch"
}
variable "winrm_username" {
type = string
default = "vagrant"
}
variable "winrm_password" {
type = string
default = "vagrant"
sensitive = true
}
# ─── Source ───────────────────────────────────────────────────────────────────
#
# Hyper-V Gen 2 + Secure Boot is required for modern Windows 11. The
# unattend payload lives on a small ISO that Packer builds on the fly from
# `cd_files` and attaches as a secondary drive; Windows Setup auto-discovers
# `autounattend.xml` on any attached media at the root.
source "hyperv-iso" "win11" {
vm_name = var.vm_name
iso_url = var.iso_path
iso_checksum = var.iso_checksum
cpus = var.cpus
memory = var.memory
disk_size = var.disk_size
generation = 2
enable_secure_boot = true
secure_boot_template = "MicrosoftWindows"
enable_tpm = true
enable_dynamic_memory = false
enable_virtualization_extensions = false
guest_additions_mode = "disable"
switch_name = var.switch_name
output_directory = var.output_directory
communicator = "winrm"
winrm_username = var.winrm_username
winrm_password = var.winrm_password
winrm_timeout = "2h"
winrm_use_ssl = false
winrm_insecure = true
# Gen 2 UEFI shows "Press any key to boot from CD or DVD..." for ~5s.
# Default boot_wait is 10s (already past the prompt) and default
# boot_command is empty, so without these the firmware falls through to
# PXE and Windows Setup never starts. Hammer ENTER early to catch it.
boot_wait = "1s"
boot_command = ["<enter><wait><enter><wait><enter>"]
shutdown_command = "shutdown /s /t 10 /f /d p:4:1 /c \"Packer Shutdown\""
shutdown_timeout = "30m"
# Build a tiny "PROVISION" ISO containing the unattend file. The
# FirstLogonCommands in autounattend.xml enable WinRM inline (no helper
# script needed) since Windows Setup may detach secondary DVDs after
# install, leaving the PROVISION volume unreachable at first logon.
cd_files = [
"./cd/autounattend.xml",
]
cd_label = "PROVISION"
}
# ─── Build ────────────────────────────────────────────────────────────────────
#
# Provisioners run *after* WinRM is reachable, which is after OOBE has finished
# and the autounattend's FirstLogonCommands have brought WinRM online. Each
# script is small and single-purpose; see comments in each .ps1.
build {
sources = ["source.hyperv-iso.win11"]
provisioner "powershell" {
elevated_user = var.winrm_username
elevated_password = var.winrm_password
scripts = [
"./scripts/configure-os.ps1",
"./scripts/disable-windows-updates.ps1",
"./scripts/disable-defender-cloud.ps1",
"./scripts/install-vagrant-key.ps1",
"./scripts/compact.ps1",
]
}
post-processor "vagrant" {
output = var.box_output
keep_input_artifact = false
provider_override = "hyperv"
vagrantfile_template = "./vagrantfile-template.rb"
}
}