diff --git a/README.md b/README.md index 904142b..541c708 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ you supply, and minimizes the size of the installation disk once setup is complete. You can then upload the installation disk to an Oxide rack and attach it to a VM or use it as the source disk for a new disk image. +> [!NOTE] +> On Linux hosts, this repository also provides a [Packer +> template](packer/README.md) that reproduces the `wimsy` build flow using +> HashiCorp Packer. Building images on illumos hosts is still done with +> `wimsy`. + `wimsy` runs on Linux (tested on Ubuntu 20.04) and illumos systems and supports creating Windows Server 2019 and Windows Server 2022 images. Windows Server 2016 is not yet fully supported (but it's on the roadmap). Earlier versions of diff --git a/packer/.gitignore b/packer/.gitignore new file mode 100644 index 0000000..4a41dc6 --- /dev/null +++ b/packer/.gitignore @@ -0,0 +1,6 @@ +output/ +output-*/ +efivars.fd +packer_cache/ +crash.log +*.auto.pkrvars.hcl diff --git a/packer/README.md b/packer/README.md new file mode 100644 index 0000000..6287428 --- /dev/null +++ b/packer/README.md @@ -0,0 +1,86 @@ +# Packer template for Oxide Windows images + +This directory contains a [Packer](https://developer.hashicorp.com/packer) +template that builds a generalized Windows Server image suitable for import +into an Oxide rack. It is the successor to the `wimsy` Rust tool in this +repository and reproduces its build flow: + +1. Create a blank raw disk and boot Windows Setup in a QEMU/KVM guest that + matches what an Oxide rack presents: an NVMe boot disk (512-byte sectors) + and a virtio-net NIC. +2. Drive an unattended installation via a rendered `Autounattend.xml` + (delivered on a virtual floppy), staging the virtio NetKVM/viostor drivers + from the driver ISO during the `offlineServicing` pass. +3. Provision over WinRM: enable the EMS serial console, ping, and RDP; + install OpenSSH and the Oxide fork of cloudbase-init; run disk cleanup and + shrink the OS partition. +4. De-provision (reset WinRM to defaults, remove autologon credentials, + scramble the build password) and generalize with sysprep, which disables + the Administrator account on first boot and hands configuration over to + cloudbase-init. +5. Trim the raw image down to the end of the OS partition, rebuild the + secondary GPT, and sparsify. The result is `output/windows-server.raw` + plus a `.sha256` checksum ready for `oxide disk import`. + +## Prerequisites + +The build must run on a Linux host with KVM. Required tools: + +* `packer` (>= 1.10) with the QEMU plugin (installed by `packer init`, + which `build.sh` runs for you) +* `qemu-system-x86_64` and `qemu-img` +* `sgdisk` (from `gdisk`) to trim the output image +* OVMF UEFI firmware (`edk2-ovmf` / `ovmf` package) + +You also need: + +* A Windows Server ISO (2016/2019/2022/2025). See the repository README for + ISO requirements. +* A virtio driver ISO using the Fedora directory layout + (`NetKVM//amd64`, `viostor//amd64`), e.g. + [virtio-win.iso](https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/). + +The guest needs outbound Internet access (Packer's user-mode networking) to +download OpenSSH and cloudbase-init. + +## Usage + +```sh +./build.sh \ + -var windows_iso_path=/path/to/windows_server_2022.iso \ + -var virtio_iso_path=/path/to/virtio-win.iso +``` + +Commonly overridden variables (see [variables.pkr.hcl](variables.pkr.hcl) for +the full list): + +| Variable | Default | Purpose | +| --- | --- | --- | +| `windows_iso_path` | (required) | Windows Server installation ISO | +| `virtio_iso_path` | (required) | virtio driver ISO | +| `windows_iso_checksum` | `none` | ISO checksum verification (`sha256:...`) | +| `windows_version` | `2k22` | virtio driver directory (`2k16`/`2k19`/`2k22`/`2k25`) | +| `image_index` | `2` | Windows edition index in the ISO | +| `ovmf_code_path` / `ovmf_vars_path` | Arch paths | OVMF firmware location | +| `headless` | `true` | Set `false` to watch the installer | +| `winrm_password` | `Packer!build0` | Build-time Administrator password (scrambled before capture) | + +To debug a failing build, pass `-var headless=false` to watch the console, +and add `-on-error=ask` to keep the VM around on failure. EMS serial output +is forwarded to Packer's stdout during the build. + +## Notes and differences from wimsy + +* The final image contains no build credentials or build machinery: the + Administrator password is scrambled, autologon/WinRM build settings are + reset, and the sysprep scheduled task and provisioner temp files are + removed by [scripts/sysprep.ps1](scripts/sysprep.ps1) before sysprep runs. + A `C:\Users\Administrator` profile folder exists in the image, as it did + in wimsy-built images (wimsy's audit-mode session signed in as + Administrator too); the account itself is disabled on first boot in both + flows. +* The unattend collateral in [../unattend](../unattend) (`specialize-unattend.xml`, + cloudbase-init configs) is shared with wimsy and delivered on the build + floppy. +* Building images on illumos hosts (wimsy's `build-installation-disk` / + Propolis flow) is not supported by this template; use wimsy for that. diff --git a/packer/answer_files/Autounattend.pkrtpl.hcl b/packer/answer_files/Autounattend.pkrtpl.hcl new file mode 100644 index 0000000..e77e2f5 --- /dev/null +++ b/packer/answer_files/Autounattend.pkrtpl.hcl @@ -0,0 +1,171 @@ + + + + + + en-US + + en-US + en-US + en-US + en-US + + + + + + + 1 + 500 + Primary + + + 2 + 100 + EFI + + + 3 + 128 + MSR + + + 4 + true + Primary + + + + + 1 + 1 + NTFS + + DE94BBA4-06D1-4D40-A16A-BFD50179D6AC + + + 2 + 2 + FAT32 + + + + 3 + 3 + + + 4 + 4 + NTFS + C + + + + 0 + true + + + + + + + /IMAGE/INDEX + ${image_index} + + + + 0 + 4 + + + + + true + + Never + + + + + + + + + D:\NetKVM\${windows_version}\amd64 + + + D:\viostor\${windows_version}\amd64 + + + E:\NetKVM\${windows_version}\amd64 + + + E:\viostor\${windows_version}\amd64 + + + F:\NetKVM\${windows_version}\amd64 + + + F:\viostor\${windows_version}\amd64 + + + + + + + * + + + + + en-US + en-US + en-US + en-US + + + + true + true + true + true + Work + 1 + + + + ${admin_password} + true</PlainText> + </AdministratorPassword> + </UserAccounts> + <AutoLogon> + <Password> + <Value>${admin_password}</Value> + <PlainText>true</PlainText> + </Password> + <Enabled>true</Enabled> + <LogonCount>1</LogonCount> + <Username>Administrator</Username> + </AutoLogon> + <FirstLogonCommands> + <SynchronousCommand wcm:action="add"> + <CommandLine>cmd /c powershell.exe -NoProfile -ExecutionPolicy Bypass -File A:\setup-winrm.ps1</CommandLine> + <Order>1</Order> + <Description>Configure WinRM for Packer provisioning</Description> + </SynchronousCommand> + </FirstLogonCommands> + </component> + </settings> +</unattend> diff --git a/packer/build.sh b/packer/build.sh new file mode 100755 index 0000000..d0a4fb2 --- /dev/null +++ b/packer/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +packer init . + +# Forward all arguments to packer build. +exec packer build "$@" . diff --git a/packer/scripts/install-cloudbase-init.ps1 b/packer/scripts/install-cloudbase-init.ps1 new file mode 100644 index 0000000..fa2634b --- /dev/null +++ b/packer/scripts/install-cloudbase-init.ps1 @@ -0,0 +1,49 @@ +$ErrorActionPreference = 'Stop' + +function RetryWithBackoff { + param ( + [Parameter(Mandatory=$True)] + [scriptblock]$ScriptBlock, + + [Parameter(Mandatory=$False)] + [int]$MaxAttempts = 5, + + [Parameter(Mandatory=$False)] + [int]$InitialBackoffDelayMs = 1000, + + [Parameter(Mandatory=$False)] + [int]$MaxBackoffDelayMs = 30000 + ) + + $cmd = $ScriptBlock.ToString() + $cnt = 0 + $delay = $InitialBackoffDelayMs + do { + $cnt++ + try { + Invoke-Command -Command $ScriptBlock + return + } catch { + Write-Host "Command $cmd failed, will retry after $delay ms; error: " $_.Exception.InnerException.Message + Start-Sleep -Milliseconds $delay + $delay = [math]::Min($delay * 2, $MaxBackoffDelayMs) + } + } while ($cnt -lt $MaxAttempts) + + Write-Error -Message "Command $cmd failed after $MaxAttempts attempts" -ErrorAction Stop +} + +Write-Host "Installing cloudbase-init" +RetryWithBackoff -ScriptBlock { Invoke-WebRequest -Uri https://oxide-omicron-build.s3.amazonaws.com/CloudbaseInitSetup.msi -OutFile C:\Windows\Temp\CloudbaseInitSetup.msi | Out-Null } +Start-Process msiexec.exe -ArgumentList "/i C:\Windows\Temp\CloudbaseInitSetup.msi /qn /norestart RUN_SERVICE_AS_LOCAL_SYSTEM=1" -Wait +Remove-Item C:\Windows\Temp\CloudbaseInitSetup.msi + +# Copy cloudbase-init configuration from the floppy drive (A:\). +$confPath = "C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\" +Copy-Item "A:\cloudbase-init.conf" -Destination "$confPath\cloudbase-init.conf" +Copy-Item "A:\cloudbase-init-unattend.conf" -Destination "$confPath\cloudbase-init-unattend.conf" +Remove-Item "$confPath\Unattend.xml" + +# Disable the service so it doesn't run on first boot and contend with the +# unattend first pass. Re-enabled during the specialize phase. +Set-Service -Name cloudbase-init -StartupType Disabled diff --git a/packer/scripts/install-ssh.ps1 b/packer/scripts/install-ssh.ps1 new file mode 100644 index 0000000..05626f9 --- /dev/null +++ b/packer/scripts/install-ssh.ps1 @@ -0,0 +1,88 @@ +$ErrorActionPreference = 'Stop' + +function RetryWithBackoff { + param ( + [Parameter(Mandatory=$True)] + [scriptblock]$ScriptBlock, + + [Parameter(Mandatory=$False)] + [int]$MaxAttempts = 5, + + [Parameter(Mandatory=$False)] + [int]$InitialBackoffDelayMs = 1000, + + [Parameter(Mandatory=$False)] + [int]$MaxBackoffDelayMs = 30000 + ) + + $cmd = $ScriptBlock.ToString() + $cnt = 0 + $delay = $InitialBackoffDelayMs + do { + $cnt++ + try { + Invoke-Command -Command $ScriptBlock + return + } catch { + Write-Host "Command $cmd failed, will retry after $delay ms; error: " $_.Exception.InnerException.Message + Start-Sleep -Milliseconds $delay + $delay = [math]::Min($delay * 2, $MaxBackoffDelayMs) + } + } while ($cnt -lt $MaxAttempts) + + Write-Error -Message "Command $cmd failed after $MaxAttempts attempts" -ErrorAction Stop +} + +function DownloadLatestSshArchive { + param ( + $ArchivePath + ) + + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls13, [Net.SecurityProtocolType]::Tls12 + } catch { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + } + + $url = 'https://github.com/PowerShell/Win32-OpenSSH/releases/latest/' + $request = [System.Net.WebRequest]::Create($url) + $request.AllowAutoRedirect=$false + $response = $request.GetResponse() + $downloadPath = $([String]$response.GetResponseHeader("Location")).Replace('tag','download') + '/OpenSSH-Win64.zip' + Write-Host "Downloading OpenSSH release from" $downloadPath + Invoke-WebRequest -Uri $downloadPath -OutFile $ArchivePath | Out-Null +} + +function InstallSshFromArchive { + param ( + $ArchivePath + ) + + Expand-Archive -Path $ArchivePath -DestinationPath "C:\Program Files" + Rename-Item -Path "C:\Program Files\OpenSSH-Win64" -NewName "C:\Program Files\OpenSSH" + & "C:\Program Files\OpenSSH\install-sshd.ps1" + New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 +} + +Write-Host "Enabling SSH" + +# Try the Windows capability first (Server 2019+). +Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 -ErrorAction SilentlyContinue +if ($?) { + $sshCap = Get-Service -Name sshd -ErrorAction SilentlyContinue +} + +if ($?) { + Write-Host "SSH service installed via Add-WindowsCapability" +} else { + Write-Host "SSH capability not present in image, will download from GitHub" + $sshPath = "C:\Windows\Temp\OpenSSH-Win64.zip" + RetryWithBackoff -ScriptBlock { DownloadLatestSshArchive -ArchivePath $sshPath } + InstallSshFromArchive -ArchivePath $sshPath +} + +Set-Service -Name sshd -StartupType Automatic +Start-Service sshd + +$content = [System.IO.File]::ReadAllText("C:\ProgramData\ssh\sshd_config").Replace("Match Group administrators", "#Match Group administrators").Replace("AuthorizedKeysFile __PROGRAMDATA__", "#AuthorizedKeysFile __PROGRAMDATA__") +[System.IO.File]::WriteAllText("C:\ProgramData\ssh\sshd_config", $content) diff --git a/packer/scripts/setup-winrm.ps1 b/packer/scripts/setup-winrm.ps1 new file mode 100644 index 0000000..016be99 --- /dev/null +++ b/packer/scripts/setup-winrm.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = 'Stop' + +# Ensure PowerShell execution policy allows scripts. +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force + +# Enable and configure WinRM for Packer communication. +winrm quickconfig -quiet +Start-Service WinRM + +# Allow unencrypted traffic and basic auth so Packer can connect. +# Use the WSMan provider instead of the winrm CLI to avoid @{} quoting issues. +Set-Item -Path WSMan:\localhost\Service\AllowUnencrypted -Value $true +Set-Item -Path WSMan:\localhost\Service\Auth\Basic -Value $true + +# Open firewall for WinRM HTTP. +New-NetFirewallRule -DisplayName 'WinRM HTTP' -Direction Inbound -LocalPort 5985 -Protocol TCP -Action Allow + +Restart-Service WinRM + +Write-Host 'WinRM configured for Packer.' diff --git a/packer/scripts/sysprep.ps1 b/packer/scripts/sysprep.ps1 new file mode 100644 index 0000000..054c3f4 --- /dev/null +++ b/packer/scripts/sysprep.ps1 @@ -0,0 +1,37 @@ +$ErrorActionPreference = 'Stop' + +# De-provision the build-time access paths before generalizing so none of them +# ship in the final image. This runs as an elevated scheduled task created by +# the Packer shutdown_command, after all provisioners have finished. + +Write-Host 'Resetting WinRM configuration to defaults' +Set-Item -Path WSMan:\localhost\Service\AllowUnencrypted -Value $false +Set-Item -Path WSMan:\localhost\Service\Auth\Basic -Value $false +Remove-NetFirewallRule -DisplayName 'WinRM HTTP' -ErrorAction SilentlyContinue + +Write-Host 'Removing autologon credentials' +$winlogon = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' +Set-ItemProperty -Path $winlogon -Name AutoAdminLogon -Value '0' +foreach ($name in 'DefaultPassword', 'DefaultUserName', 'DefaultDomainName', 'AutoLogonCount') { + Remove-ItemProperty -Path $winlogon -Name $name -ErrorAction SilentlyContinue +} + +# Scramble the well-known build password. The Administrator account itself is +# disabled during the specialize pass on first boot (see +# specialize-unattend.xml); this ensures the build credential is gone even if +# the account is ever re-enabled. +Write-Host 'Scrambling Administrator password' +$password = [guid]::NewGuid().ToString() + [guid]::NewGuid().ToString() +net user Administrator "$password" | Out-Null + +# Remove build residue: the scheduled task that launched this script (the +# definition would otherwise ship in the image) and temp files left by the +# Packer provisioners. This script runs from A:\, so cleaning C:\ temp +# directories is safe. +Write-Host 'Removing build residue' +schtasks /delete /tn packer-sysprep /f | Out-Null +Remove-Item "$env:SystemRoot\Temp\*" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue + +Write-Host 'Generalizing image with sysprep' +& "$env:SystemRoot\System32\Sysprep\sysprep.exe" /generalize /oobe /shutdown /unattend:A:\specialize-unattend.xml diff --git a/packer/variables.pkr.hcl b/packer/variables.pkr.hcl new file mode 100644 index 0000000..ba4e8b5 --- /dev/null +++ b/packer/variables.pkr.hcl @@ -0,0 +1,81 @@ +variable "windows_iso_path" { + type = string + description = "Path to the Windows Server ISO." +} + +variable "windows_iso_checksum" { + type = string + default = "none" + description = "Checksum for the Windows Server ISO (e.g. 'sha256:abc123'). Set to 'none' to skip verification." +} + +variable "virtio_iso_path" { + type = string + description = "Path to the VirtIO drivers ISO (e.g. virtio-win.iso from Fedora). Must use the Fedora directory layout: NetKVM/<version>/amd64 and viostor/<version>/amd64." +} + +variable "ovmf_code_path" { + type = string + default = "/usr/share/edk2/x64/OVMF_CODE.4m.fd" + description = "Path to OVMF UEFI firmware code. Common paths: /usr/share/edk2/x64/OVMF_CODE.4m.fd (Arch), /usr/share/OVMF/OVMF_CODE.fd (Ubuntu), /usr/share/edk2/ovmf/OVMF_CODE.fd (Fedora)." +} + +variable "ovmf_vars_path" { + type = string + default = "/usr/share/edk2/x64/OVMF_VARS.4m.fd" + description = "Path to OVMF UEFI firmware vars. A writable per-build copy is placed in the output directory automatically. Common paths: /usr/share/edk2/x64/OVMF_VARS.4m.fd (Arch), /usr/share/OVMF/OVMF_VARS.fd (Ubuntu), /usr/share/edk2/ovmf/OVMF_VARS.fd (Fedora)." +} + +variable "disk_size" { + type = string + default = "30G" + description = "Size of the installation disk. The output image is trimmed down to the end of the OS partition after the build." +} + +variable "memory" { + type = number + default = 4096 + description = "Memory in MB for the build VM." +} + +variable "cpus" { + type = number + default = 4 + description = "Number of vCPUs for the build VM." +} + +variable "output_directory" { + type = string + default = "output" + description = "Directory for the output image." +} + +variable "windows_version" { + type = string + default = "2k22" + description = "Windows version for VirtIO driver selection (e.g. 2k16, 2k19, 2k22, 2k25). Substituted into the driver paths in Autounattend.xml." + + validation { + condition = contains(["2k16", "2k19", "2k22", "2k25"], var.windows_version) + error_message = "The windows_version must be one of 2k16, 2k19, 2k22, or 2k25." + } +} + +variable "image_index" { + type = string + default = "2" + description = "Windows image index to install (e.g. 2 = Standard Desktop Experience). Substituted into ImageInstall in Autounattend.xml." +} + +variable "headless" { + type = bool + default = true + description = "Run the build VM without a GUI. Set to false to see the Windows installer." +} + +variable "winrm_password" { + type = string + default = "Packer!build0" + sensitive = true + description = "Password for the built-in Administrator account during the build; also used by the WinRM communicator. Scrambled before the image is generalized, so it does not ship in the image. Avoid XML special characters (& < > ' \") — the value is substituted into Autounattend.xml verbatim." +} diff --git a/packer/windows-server.pkr.hcl b/packer/windows-server.pkr.hcl new file mode 100644 index 0000000..1158922 --- /dev/null +++ b/packer/windows-server.pkr.hcl @@ -0,0 +1,215 @@ +packer { + required_plugins { + qemu = { + version = "~> 1" + source = "github.com/hashicorp/qemu" + } + } +} + +source "qemu" "windows-server" { + # ISO configuration. The Windows ISO is also referenced directly in + # qemuargs below; iso_url is still used so Packer verifies its checksum. + iso_url = var.windows_iso_path + iso_checksum = var.windows_iso_checksum + + # UEFI firmware. efi_boot makes the plugin copy efi_firmware_vars to + # <output_directory>/efivars.fd before launching QEMU, giving each build a + # private writable copy of the EFI variable store. The plugin's own pflash + # arguments are overridden by the -drive entries in qemuargs, so equivalent + # entries are re-specified there. + efi_boot = true + efi_firmware_code = var.ovmf_code_path + efi_firmware_vars = var.ovmf_vars_path + efi_drop_efivars = true + + # When qemuargs includes -drive entries, the plugin drops all of its + # auto-generated drives (main disk, CD-ROMs, EFI pflash) — everything must + # be specified here. The plugin re-adds its own -netdev (with the WinRM + # host-forward) and appends the virtio-net device automatically because no + # -device entry below names it. + # + # The VM configuration mirrors what wimsy used, and what an Oxide rack + # presents to guests: the boot disk is an NVMe device with 512-byte + # logical/physical sectors, and the NIC is virtio-net. + qemuargs = [ + # Match wimsy's CPU configuration, including the Hyper-V enlightenments + # that speed up Windows considerably. + ["-cpu", "host,kvm=off,hv_relaxed,hv_spinlocks=0x1fff,hv_vapic,hv_time"], + # Windows expects the RTC to be in local time. + ["-rtc", "base=localtime"], + # UEFI firmware. + ["-drive", "if=pflash,format=raw,readonly=on,file=${var.ovmf_code_path}"], + ["-drive", "if=pflash,format=raw,file={{ .OutputDir }}/efivars.fd"], + # Output disk, attached as NVMe exactly as wimsy attached it. discard + # passthrough lets the guest's Optimize-Volume punch holes in the file. + ["-device", "nvme,drive=drivec,serial=01de01de,physical_block_size=512,logical_block_size=512,discard_granularity=512,bootindex=1"], + ["-drive", "if=none,id=drivec,file={{ .OutputDir }}/{{ .Name }},format=raw,discard=unmap"], + # Windows Server ISO (boot CD-ROM). + ["-device", "ide-cd,drive=win-disk,bus=ide.0,unit=0,bootindex=2"], + ["-drive", "file=${var.windows_iso_path},if=none,id=win-disk,media=cdrom"], + # VirtIO drivers ISO (second CD-ROM); drivers are staged into the image + # during the offlineServicing pass of Autounattend.xml. + ["-device", "ide-cd,drive=virtio-disk,bus=ide.1,unit=0"], + ["-drive", "file=${var.virtio_iso_path},if=none,id=virtio-disk,media=cdrom"], + # Serial port for EMS console output during the build. + ["-serial", "stdio"], + ] + + # Disk configuration. The plugin still creates the backing file referenced + # by the -drive entry above. + disk_size = var.disk_size + format = "raw" + output_directory = var.output_directory + vm_name = "windows-server" + + # VM configuration. wimsy used the i440fx ("pc") machine type; it is also + # what provides the floppy controller used below (q35 has none). + accelerator = "kvm" + machine_type = "pc" + cpus = var.cpus + memory = var.memory + headless = var.headless + net_device = "virtio-net" + + # Windows Setup reads Autounattend.xml from the floppy (A:\) automatically. + # The other files are used by the provisioners and the sysprep task. + # Autounattend.xml is rendered from a template so the Windows version + # (virtio driver paths), image index, and build password are configurable. + floppy_content = { + "Autounattend.xml" = templatefile("${path.root}/answer_files/Autounattend.pkrtpl.hcl", { + windows_version = var.windows_version + image_index = var.image_index + admin_password = var.winrm_password + }) + } + floppy_files = [ + "${path.root}/scripts/setup-winrm.ps1", + "${path.root}/scripts/sysprep.ps1", + "${path.root}/../unattend/specialize-unattend.xml", + "${path.root}/../unattend/cloudbase-init.conf", + "${path.root}/../unattend/cloudbase-init-unattend.conf", + ] + + # Boot: press Enter to boot from CD-ROM when prompted by the firmware. + boot_wait = "5s" + boot_command = ["<enter><wait><enter><wait><enter>"] + + # WinRM communicator — Packer uses this to run provisioners. Credentials + # match what the rendered Autounattend.xml configures. + communicator = "winrm" + winrm_username = "Administrator" + winrm_password = var.winrm_password + winrm_timeout = "60m" + winrm_use_ssl = false + winrm_insecure = true + + # De-provision and sysprep via an elevated scheduled task. WinRM basic auth + # gets a filtered (non-elevated) token due to UAC, so a scheduled task is + # used to run with full privileges; the command returns immediately and + # Packer waits for the sysprep-initiated shutdown. + shutdown_command = "cmd /c schtasks /create /tn packer-sysprep /tr \"powershell.exe -NoProfile -ExecutionPolicy Bypass -File A:\\sysprep.ps1\" /sc once /st 00:00 /rl highest /f && schtasks /run /tn packer-sysprep" + shutdown_timeout = "30m" +} + +build { + sources = ["source.qemu.windows-server"] + + # Enable serial console (EMS on COM1). + provisioner "powershell" { + elevated_user = "Administrator" + elevated_password = var.winrm_password + inline = [ + "Write-Host 'Enabling Serial Console'", + "bcdedit /ems on", + "bcdedit /emssettings EMSPORT:1 EMSBAUDRATE:115200", + ] + } + + # Enable ping. + provisioner "powershell" { + elevated_user = "Administrator" + elevated_password = var.winrm_password + inline = [ + "Write-Host 'Enabling Ping'", + "New-NetFirewallRule -DisplayName 'Allow Inbound ICMPv4' -Direction Inbound -Protocol ICMPv4 -IcmpType 8 -RemoteAddress Any -Action Allow", + ] + } + + # Enable RDP. + provisioner "powershell" { + elevated_user = "Administrator" + elevated_password = var.winrm_password + inline = [ + "Write-Host 'Enabling RDP'", + "Set-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Terminal Server\\' -Name 'fDenyTSConnections' -Value 0", + "Enable-NetFirewallRule -DisplayGroup 'Remote Desktop'", + ] + } + + # Install OpenSSH. + provisioner "powershell" { + elevated_user = "Administrator" + elevated_password = var.winrm_password + script = "${path.root}/scripts/install-ssh.ps1" + } + + # Install Cloudbase-init (Oxide fork). + provisioner "powershell" { + elevated_user = "Administrator" + elevated_password = var.winrm_password + script = "${path.root}/scripts/install-cloudbase-init.ps1" + } + + # Cleanup and defrag. + provisioner "powershell" { + elevated_user = "Administrator" + elevated_password = var.winrm_password + inline = [ + "Write-Host 'Cleaning up disk'", + "Dism.exe /online /Cleanup-Image /StartComponentCleanup /ResetBase", + "Optimize-Volume -DriveLetter C", + ] + } + + # Shrink OS partition to minimize output image size. + provisioner "powershell" { + elevated_user = "Administrator" + elevated_password = var.winrm_password + inline = [ + "Write-Host 'Shrinking OS partition'", + "$osPartition = Get-Partition -DriveLetter C", + "$resizeInfo = Get-PartitionSupportedSize -DriveLetter C", + "$minSz = $resizeInfo.SizeMin", + "$maxSz = $resizeInfo.SizeMax", + "$curSz = $osPartition.Size", + "$newSz = $minSz + 3GB", + "$diff = $curSz - $newSz", + "if ($newSz -lt $maxSz) { Resize-Partition -DriveLetter C -Size $newSz; Write-Host \"New Partition Size: $newSz\"; Write-Host \"Free'd $diff\" }", + ] + } + + # Trim the unused tail of the output image, as wimsy did: resize the raw + # file down to the end of the OS partition (plus room for the secondary + # GPT), rebuild the secondary GPT at the new end of disk, then sparsify. + post-processor "shell-local" { + inline = [ + "set -e", + "command -v sgdisk >/dev/null 2>&1 || { echo 'ERROR: sgdisk is required to shrink the output image' >&2; exit 1; }", + "img='${var.output_directory}/windows-server'", + "out='${var.output_directory}/windows-server.raw'", + "echo 'Trimming unused sectors from output image...'", + "sector_size=$(sgdisk -p \"$img\" | awk '/^Sector size/ {print $4}')", + "last_sector=$(sgdisk -i 4 \"$img\" | awk '/^Last sector/ {print $3}')", + "new_size=$(( (last_sector + 34) * sector_size ))", + "echo \"Sector size: $sector_size, OS partition last sector: $last_sector, new size: $new_size bytes\"", + "qemu-img resize --shrink -f raw \"$img\" \"$new_size\"", + "sgdisk -e \"$img\"", + "echo 'Sparsifying output image...'", + "qemu-img convert -f raw -O raw \"$img\" \"$out\"", + "rm \"$img\"", + "if command -v sha256sum >/dev/null 2>&1; then sha256sum \"$out\" > \"$out.sha256\"; else shasum -a 256 \"$out\" > \"$out.sha256\"; fi", + "echo \"Done. Output image: $out\"", + ] + } +}