Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 90 additions & 5 deletions builds/_shared/windows/Finalize.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ $ProgressPreference = "SilentlyContinue"

function Write-Step($Message) { Write-Host "==> $Message" }

function Find-FileOnMedia($FileName) {
foreach ($drive in Get-PSDrive -PSProvider FileSystem) {
$candidate = Join-Path $drive.Root $FileName
if (Test-Path $candidate) { return $candidate }
}
return $null
}

function Zero-FreeSpace($DriveLetter) {
$root = "${DriveLetter}:\"
$target = Join-Path $root "zero.fill"
Expand Down Expand Up @@ -51,6 +59,60 @@ $ErrorActionPreference = "SilentlyContinue"
wevtutil el | ForEach-Object { wevtutil cl $_ 2>&1 | Out-Null }
$ErrorActionPreference = "Stop"

Write-Step "install Cloudbase-Init"
# Installed here -- after the last Windows Update pass, right before sysprep --
# rather than in Install.ps1. On Server 2025 the monthly checkpoint cumulative
# is applied via UpdateAgent as a full OS re-deploy (creates C:\Windows.old),
# and software installed before the WU passes does not reliably survive it.
# At this point nothing destructive runs between the install and the vzdump.
$cloudbaseMsi = Find-FileOnMedia "CloudbaseInitSetup_x64.msi"
if (-not $cloudbaseMsi) {
$cloudbaseMsi = "C:\Windows\Temp\CloudbaseInitSetup_x64.msi"
$msiUrl = "https://github.com/cloudbase/cloudbase-init/releases/latest/download/CloudbaseInitSetup_x64.msi"
Write-Step "downloading Cloudbase-Init from $msiUrl"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
(New-Object System.Net.WebClient).DownloadFile($msiUrl, $cloudbaseMsi)
}
$p = Start-Process -FilePath "msiexec.exe" `
-ArgumentList "/i", $cloudbaseMsi, "/qn", "/norestart", "RUN_SERVICE_AS_LOCAL_SYSTEM=1" `
-Wait -PassThru
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
throw "Cloudbase-Init MSI exited $($p.ExitCode)"
}

Write-Step "verify Cloudbase-Init"
$svc = Get-Service -Name "cloudbase-init" -ErrorAction SilentlyContinue
if (-not $svc) { throw "cloudbase-init service not found after install" }
# Keep the service enabled for clones (it applies the cloud-init password on
# first boot) but make sure it is not running during the remaining build steps.
Stop-Service -Name cloudbase-init -Force -ErrorAction SilentlyContinue
Set-Service -Name cloudbase-init -StartupType Automatic -ErrorAction SilentlyContinue

$cloudbaseConfDir = "C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf"
New-Item -ItemType Directory -Force -Path $cloudbaseConfDir | Out-Null
@"
[DEFAULT]
username=Administrator
groups=Administrators
inject_user_password=true
first_logon_behaviour=no
check_latest_version=false
bsdtar_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\bsdtar.exe
mtools_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\
verbose=true
debug=false
logdir=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\
logfile=cloudbase-init.log
default_log_levels=comtypes=INFO,suds=INFO,iso8601=WARN,requests=WARN
local_scripts_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\LocalScripts\
metadata_services=cloudbaseinit.metadata.services.configdrive.ConfigDriveService,cloudbaseinit.metadata.services.nocloudservice.NoCloudConfigDriveService
plugins=cloudbaseinit.plugins.common.mtu.MTUPlugin,cloudbaseinit.plugins.windows.ntpclient.NTPClientPlugin,cloudbaseinit.plugins.common.sethostname.SetHostNamePlugin,cloudbaseinit.plugins.windows.createuser.CreateUserPlugin,cloudbaseinit.plugins.common.setuserpassword.SetUserPasswordPlugin,cloudbaseinit.plugins.common.networkconfig.NetworkConfigPlugin,cloudbaseinit.plugins.windows.licensing.WindowsLicensingPlugin,cloudbaseinit.plugins.common.sshpublickeys.SetUserSSHPublicKeysPlugin,cloudbaseinit.plugins.windows.extendvolumes.ExtendVolumesPlugin,cloudbaseinit.plugins.common.userdata.UserDataPlugin,cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin

[config_drive]
types=vfat,iso
locations=cdrom,hdd,partition
"@ | Set-Content -Path (Join-Path $cloudbaseConfDir "cloudbase-init.conf") -Encoding ASCII

Write-Step "zero free space"
Zero-FreeSpace "C"
Optimize-Volume -DriveLetter C -ReTrim -ErrorAction SilentlyContinue
Expand All @@ -69,22 +131,45 @@ Remove-Item "C:\Windows\System32\packer-winrm-keepalive.ps1" -Force -ErrorAction
# Remove the Group Policy registry keys that pinned Basic auth / AllowUnencrypted
# during the build so the sysprep'd template ships with WinRM in its secure default state.
Remove-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WinRM\Service" -Force -ErrorAction SilentlyContinue
winrm set winrm/config/service @{AllowUnencrypted="false"} 2>&1 | Out-Null
winrm set winrm/config/service/auth @{Basic="false"} 2>&1 | Out-Null
# Must go through cmd.exe: from PowerShell the @{...} argument is parsed as a
# hashtable and winrm.cmd receives "System.Collections.Hashtable".
# Best-effort - the authoritative unpin is the policy-key removal.
cmd.exe /c 'winrm set winrm/config/service @{AllowUnencrypted="false"} >nul 2>&1'
cmd.exe /c 'winrm set winrm/config/service/auth @{Basic="false"} >nul 2>&1'

Write-Step "sysprep and shutdown"
# Pass cloudbase-init's bundled Unattend.xml so OOBE on the cloned VM auto-
# completes (accepts EULA, sets a placeholder Administrator password, skips
# all OOBE screens). Without this, first boot blocks in noVNC waiting for an
# operator to set the Administrator password, and the cloudbase-init service
# can't start until OOBE finishes which defeats unattended cloning.
# can't start until OOBE finishes -- which defeats unattended cloning.
$sysprepUnattend = "C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\Unattend.xml"
if (-not (Test-Path $sysprepUnattend)) {
throw "cloudbase-init Unattend.xml not found at $sysprepUnattend was Cloudbase-Init installed?"
throw "cloudbase-init Unattend.xml not found at $sysprepUnattend - was Cloudbase-Init installed?"
}
# Copy to a space-free path: with the "Program Files" path, Start-Process's
# argument joining mangles the quoting and sysprep aborts with "Unable to
# parse command-line arguments" -- while still exiting 0, so the build
# "succeeds" with a non-generalized image. (Sysprep caches the answer file
# into C:\Windows\Panther at generalize, so the temp source path is fine.)
$unattendCopy = "C:\Windows\Temp\cb-sysprep-unattend.xml"
Copy-Item $sysprepUnattend $unattendCopy -Force
$p = Start-Process -FilePath "C:\Windows\System32\Sysprep\Sysprep.exe" `
-ArgumentList "/generalize", "/oobe", "/shutdown", "/quiet", "/unattend:`"$sysprepUnattend`"" `
-ArgumentList "/generalize", "/oobe", "/shutdown", "/quiet", "/unattend:$unattendCopy" `
-Wait -PassThru
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
throw "Sysprep exited $($p.ExitCode)"
}
# Sysprep writes this tag only when generalize actually succeeded -- its exit
# code alone is unreliable (a command-line parse failure also exits 0).
$deadline = [DateTime]::Now.AddMinutes(2)
$tagPath = "C:\Windows\System32\Sysprep\Sysprep_succeeded.tag"
while (-not (Test-Path $tagPath) -and [DateTime]::Now -lt $deadline) { Start-Sleep 5 }
if (-not (Test-Path $tagPath)) {
throw "Sysprep did not generalize (Sysprep_succeeded.tag missing) - check C:\Windows\System32\Sysprep\Panther\setupact.log"
}

# All failure paths above throw; reaching here is success. Explicit exit 0 so a
# stale $LastExitCode from an earlier native command can't fail the provisioner
# (the machine is about to power off from sysprep /shutdown).
exit 0
61 changes: 16 additions & 45 deletions builds/_shared/windows/Install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -39,48 +39,13 @@ if (-not (Test-Path "\\.\Global\org.qemu.guest_agent.0")) {
}
Write-Step "QEMU Guest Agent: running, channel open"

Write-Step "install Cloudbase-Init"
$cloudbaseMsi = Find-FileOnMedia "CloudbaseInitSetup_x64.msi"
if (-not $cloudbaseMsi) {
$cloudbaseMsi = "$env:TEMP\CloudbaseInitSetup_x64.msi"
$msiUrl = "https://github.com/cloudbase/cloudbase-init/releases/latest/download/CloudbaseInitSetup_x64.msi"
Write-Step "downloading Cloudbase-Init from $msiUrl"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
(New-Object System.Net.WebClient).DownloadFile($msiUrl, $cloudbaseMsi)
}
$p = Start-Process -FilePath "msiexec.exe" `
-ArgumentList "/i", $cloudbaseMsi, "/qn", "/norestart", "RUN_SERVICE_AS_LOCAL_SYSTEM=1" `
-Wait -PassThru
if ($p.ExitCode -ne 0 -and $p.ExitCode -ne 3010) {
throw "Cloudbase-Init MSI exited $($p.ExitCode)"
}

Write-Step "verify Cloudbase-Init"
$svc = Get-Service -Name "cloudbase-init" -ErrorAction SilentlyContinue
if (-not $svc) { throw "cloudbase-init service not found after install" }

$cloudbaseConfDir = "C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf"
New-Item -ItemType Directory -Force -Path $cloudbaseConfDir | Out-Null
@"
[DEFAULT]
username=Administrator
groups=Administrators
inject_user_password=true
first_logon_behaviour=no
config_drive_raw_hhd=true
config_drive_cdrom=true
bsdtar_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\bsdtar.exe
mtools_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\
verbose=true
debug=false
logdir=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\
logfile=cloudbase-init.log
metadata_services=cloudbaseinit.metadata.services.configdrive.ConfigDriveService,cloudbaseinit.metadata.services.nocloudservice.NoCloudConfigDriveService
plugins=cloudbaseinit.plugins.common.mtu.MTUPlugin,cloudbaseinit.plugins.windows.ntpclient.NTPClientPlugin,cloudbaseinit.plugins.common.sethostname.SetHostNamePlugin,cloudbaseinit.plugins.windows.createuser.CreateUserPlugin,cloudbaseinit.plugins.common.networkconfig.NetworkConfigPlugin,cloudbaseinit.plugins.windows.licensing.WindowsLicensingPlugin,cloudbaseinit.plugins.common.sshpublickeys.SetUserSSHPublicKeysPlugin,cloudbaseinit.plugins.windows.extendvolumes.ExtendVolumesPlugin,cloudbaseinit.plugins.common.userdata.UserDataPlugin,cloudbaseinit.plugins.common.localscripts.LocalScriptsPlugin
"@ | Set-Content -Path (Join-Path $cloudbaseConfDir "cloudbase-init.conf") -Encoding ASCII

Write-Step "enable services"
Set-Service -Name cloudbase-init -StartupType Automatic -ErrorAction SilentlyContinue
# Cloudbase-Init is deliberately NOT installed here. Install.ps1 runs before
# the Windows Update passes, and on Server 2025 the monthly checkpoint
# cumulative (applied via UpdateAgent) re-deploys the OS -- software installed
# this early does not reliably survive to the final image. Cloudbase-Init is
# installed in Finalize.ps1 instead, after the last WU pass and right before
# sysprep, where nothing destructive can run after it.
Set-Service -Name QEMU-GA -StartupType Automatic -ErrorAction SilentlyContinue

Write-Step "pin WinRM Basic/unencrypted via Group Policy registry"
Expand All @@ -94,16 +59,18 @@ New-Item -Path $wmPolicyPath -Force | Out-Null
Set-ItemProperty -Path $wmPolicyPath -Name "AllowBasic" -Value 1 -Type DWord -Force
Set-ItemProperty -Path $wmPolicyPath -Name "AllowUnencrypted" -Value 1 -Type DWord -Force
# Immediately re-apply so the current session sees the new policy values.
winrm set winrm/config/service @{AllowUnencrypted="true"} 2>&1 | Out-Null
winrm set winrm/config/service/auth @{Basic="true"} 2>&1 | Out-Null
# Must go through cmd.exe: from PowerShell the @{...} argument is parsed as a
# hashtable and winrm.cmd receives "System.Collections.Hashtable".
cmd.exe /c 'winrm set winrm/config/service @{AllowUnencrypted="true"} >nul 2>&1'
cmd.exe /c 'winrm set winrm/config/service/auth @{Basic="true"} >nul 2>&1'

Write-Step "register WinRM keepalive startup task"
# Belt-and-suspenders: run the winrm set commands at every subsequent startup
# as well, in case a future Defender version learns to clear the Policies hive.
$winrmFixPath = "C:\Windows\System32\packer-winrm-keepalive.ps1"
@'
winrm set winrm/config/service @{AllowUnencrypted="true"} 2>&1 | Out-Null
winrm set winrm/config/service/auth @{Basic="true"} 2>&1 | Out-Null
cmd.exe /c 'winrm set winrm/config/service @{AllowUnencrypted="true"} >nul 2>&1'
cmd.exe /c 'winrm set winrm/config/service/auth @{Basic="true"} >nul 2>&1'
'@ | Set-Content $winrmFixPath -Encoding UTF8
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-ExecutionPolicy Bypass -NonInteractive -File `"$winrmFixPath`""
Expand All @@ -130,3 +97,7 @@ foreach ($block in $blocks) {
}
}
bcdedit.exe /set '{fwbootmgr}' displayorder '{bootmgr}' /addfirst 2>&1 | Out-Null

# All failure paths above throw; reaching here is success. Explicit exit 0 so a
# stale $LastExitCode from an earlier native command can't fail the provisioner.
exit 0
4 changes: 4 additions & 0 deletions builds/_shared/windows/PreFinalize.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ if ($cs.AutomaticManagedPagefile) {
}
Get-CimInstance -ClassName Win32_PageFileSetting -ErrorAction SilentlyContinue |
Remove-CimInstance -ErrorAction SilentlyContinue

# All failure paths above throw; reaching here is success. Explicit exit 0 so a
# stale $LastExitCode from an earlier native command can't fail the provisioner.
exit 0
6 changes: 5 additions & 1 deletion builds/_shared/windows/WU.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ for ($iter = 1; $iter -le $maxIterations; $iter++) {
try {
# IsInstalled=0: not yet installed. Type=Software: skip drivers (template should
# stay generic). IsHidden=0: skip anything an admin would have hidden. We do NOT
# filter on BrowseOnly here that excludes legitimate optional updates that
# filter on BrowseOnly here -- that excludes legitimate optional updates that
# may be required for cumulative chains.
$found = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
} catch {
Expand Down Expand Up @@ -171,3 +171,7 @@ if (Test-Path $WURebootFlag) {
} else {
Write-Step "no reboot required - conditional windows-restart will skip"
}

# All failure paths above throw; reaching here is success. Explicit exit 0 so a
# stale $LastExitCode from an earlier native command can't fail the provisioner.
exit 0
21 changes: 16 additions & 5 deletions builds/windows-server-2019.pkr.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ locals {
build_vmid = 2000
recipe_name = "windows-server-2019"
recipe_display = "Windows Server 2019 Datacenter"

ps_execute = "powershell -executionpolicy bypass \"& { $ErrorActionPreference='Stop'; $_p='{{.Path}}'; $_dl=[DateTime]::Now.AddSeconds(120); while (-not (Test-Path $_p) -and [DateTime]::Now -lt $_dl) { Start-Sleep 2 }; . {{.Vars}}; & $_p; exit $LastExitCode }\""
}

source "proxmox-iso" "windows-server-2019" {
Expand Down Expand Up @@ -157,38 +159,47 @@ build {
sources = ["source.proxmox-iso.windows-server-2019"]

provisioner "powershell" {
script = "${path.root}/_shared/windows/Install.ps1"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/Install.ps1"
}

provisioner "windows-restart" {
restart_timeout = "15m"
}

provisioner "powershell" {
script = "${path.root}/_shared/windows/WU.ps1"
pause_before = "30s"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/WU.ps1"
}
provisioner "windows-restart" {
restart_timeout = "90m"
restart_command = "powershell -Command \"if (Test-Path 'C:/Windows/Temp/tb-wu-reboot.flag') { Remove-Item 'C:/Windows/Temp/tb-wu-reboot.flag' -Force; shutdown /r /f /t 5 /c 'packer wu reboot' } else { exit 0 }\""
}

provisioner "powershell" {
script = "${path.root}/_shared/windows/WU.ps1"
pause_before = "30s"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/WU.ps1"
}
provisioner "windows-restart" {
restart_timeout = "90m"
restart_command = "powershell -Command \"if (Test-Path 'C:/Windows/Temp/tb-wu-reboot.flag') { Remove-Item 'C:/Windows/Temp/tb-wu-reboot.flag' -Force; shutdown /r /f /t 5 /c 'packer wu reboot' } else { exit 0 }\""
}

provisioner "powershell" {
script = "${path.root}/_shared/windows/PreFinalize.ps1"
pause_before = "30s"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/PreFinalize.ps1"
}
provisioner "windows-restart" {
restart_timeout = "15m"
}

provisioner "powershell" {
script = "${path.root}/_shared/windows/Finalize.ps1"
pause_before = "30s"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/Finalize.ps1"
}

post-processor "shell-local" {
Expand Down
21 changes: 16 additions & 5 deletions builds/windows-server-2022.pkr.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ locals {
build_vmid = 2001
recipe_name = "windows-server-2022"
recipe_display = "Windows Server 2022 Datacenter"

ps_execute = "powershell -executionpolicy bypass \"& { $ErrorActionPreference='Stop'; $_p='{{.Path}}'; $_dl=[DateTime]::Now.AddSeconds(120); while (-not (Test-Path $_p) -and [DateTime]::Now -lt $_dl) { Start-Sleep 2 }; . {{.Vars}}; & $_p; exit $LastExitCode }\""
}

source "proxmox-iso" "windows-server-2022" {
Expand Down Expand Up @@ -162,38 +164,47 @@ build {
sources = ["source.proxmox-iso.windows-server-2022"]

provisioner "powershell" {
script = "${path.root}/_shared/windows/Install.ps1"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/Install.ps1"
}

provisioner "windows-restart" {
restart_timeout = "15m"
}

provisioner "powershell" {
script = "${path.root}/_shared/windows/WU.ps1"
pause_before = "30s"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/WU.ps1"
}
provisioner "windows-restart" {
restart_timeout = "90m"
restart_command = "powershell -Command \"if (Test-Path 'C:/Windows/Temp/tb-wu-reboot.flag') { Remove-Item 'C:/Windows/Temp/tb-wu-reboot.flag' -Force; shutdown /r /f /t 5 /c 'packer wu reboot' } else { exit 0 }\""
}

provisioner "powershell" {
script = "${path.root}/_shared/windows/WU.ps1"
pause_before = "30s"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/WU.ps1"
}
provisioner "windows-restart" {
restart_timeout = "90m"
restart_command = "powershell -Command \"if (Test-Path 'C:/Windows/Temp/tb-wu-reboot.flag') { Remove-Item 'C:/Windows/Temp/tb-wu-reboot.flag' -Force; shutdown /r /f /t 5 /c 'packer wu reboot' } else { exit 0 }\""
}

provisioner "powershell" {
script = "${path.root}/_shared/windows/PreFinalize.ps1"
pause_before = "30s"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/PreFinalize.ps1"
}
provisioner "windows-restart" {
restart_timeout = "15m"
}

provisioner "powershell" {
script = "${path.root}/_shared/windows/Finalize.ps1"
pause_before = "30s"
execute_command = local.ps_execute
script = "${path.root}/_shared/windows/Finalize.ps1"
}

post-processor "shell-local" {
Expand Down
Loading
Loading