diff --git a/builds/_shared/windows/Finalize.ps1 b/builds/_shared/windows/Finalize.ps1 index 93f63ba..c268c65 100644 --- a/builds/_shared/windows/Finalize.ps1 +++ b/builds/_shared/windows/Finalize.ps1 @@ -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" @@ -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 @@ -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 diff --git a/builds/_shared/windows/Install.ps1 b/builds/_shared/windows/Install.ps1 index f74d504..1417519 100644 --- a/builds/_shared/windows/Install.ps1 +++ b/builds/_shared/windows/Install.ps1 @@ -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" @@ -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`"" @@ -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 diff --git a/builds/_shared/windows/PreFinalize.ps1 b/builds/_shared/windows/PreFinalize.ps1 index 99eb8b1..5f86260 100644 --- a/builds/_shared/windows/PreFinalize.ps1 +++ b/builds/_shared/windows/PreFinalize.ps1 @@ -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 diff --git a/builds/_shared/windows/WU.ps1 b/builds/_shared/windows/WU.ps1 index ec3d901..6956756 100644 --- a/builds/_shared/windows/WU.ps1 +++ b/builds/_shared/windows/WU.ps1 @@ -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 { @@ -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 diff --git a/builds/windows-server-2019.pkr.hcl b/builds/windows-server-2019.pkr.hcl index 70b852f..f396373 100644 --- a/builds/windows-server-2019.pkr.hcl +++ b/builds/windows-server-2019.pkr.hcl @@ -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" { @@ -157,7 +159,8 @@ 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" { @@ -165,7 +168,9 @@ build { } 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" @@ -173,7 +178,9 @@ build { } 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" @@ -181,14 +188,18 @@ build { } 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" { diff --git a/builds/windows-server-2022.pkr.hcl b/builds/windows-server-2022.pkr.hcl index a98e08f..7cf50fd 100644 --- a/builds/windows-server-2022.pkr.hcl +++ b/builds/windows-server-2022.pkr.hcl @@ -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" { @@ -162,7 +164,8 @@ 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" { @@ -170,7 +173,9 @@ build { } 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" @@ -178,7 +183,9 @@ build { } 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" @@ -186,14 +193,18 @@ build { } 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" { diff --git a/builds/windows-server-2025.pkr.hcl b/builds/windows-server-2025.pkr.hcl index 382e9c6..0393b53 100644 --- a/builds/windows-server-2025.pkr.hcl +++ b/builds/windows-server-2025.pkr.hcl @@ -56,6 +56,8 @@ locals { build_vmid = 2002 recipe_name = "windows-server-2025" recipe_display = "Windows Server 2025 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-2025" { @@ -164,15 +166,17 @@ build { sources = ["source.proxmox-iso.windows-server-2025"] 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 = "30m" } provisioner "powershell" { - pause_before = "30s" - 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" @@ -180,8 +184,9 @@ build { } provisioner "powershell" { - pause_before = "30s" - 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" @@ -189,14 +194,18 @@ build { } 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" {