From a43730ee13341d8afabbff7fe0aceb4fd9e8777c Mon Sep 17 00:00:00 2001 From: Alexey Artamonov Date: Fri, 21 Aug 2026 21:25:54 +0300 Subject: [PATCH 1/5] docs(virtualization): guide for building a Windows golden image with Packer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Virtualization guide covering how to build a customized Windows Server golden image with Packer against the KubeVirt builder, harden it, capture the disk, and register it in Cozystack — either as a cloneable vm-disk (copy-clone) or, for a bare URL-reachable base, via vm-default-images. Complements the manual "Running Windows VMs" guide and cross-links Cloneable Virtual Machines and Golden Images. Signed-off-by: Alexey Artamonov --- .../virtualization/windows-golden-image.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 content/en/docs/next/virtualization/windows-golden-image.md diff --git a/content/en/docs/next/virtualization/windows-golden-image.md b/content/en/docs/next/virtualization/windows-golden-image.md new file mode 100644 index 00000000..b1565c47 --- /dev/null +++ b/content/en/docs/next/virtualization/windows-golden-image.md @@ -0,0 +1,95 @@ +--- +title: "Building a Windows Golden Image with Packer" +linkTitle: "Windows Golden Images" +description: "How to build a customized Windows Server golden image (pre-installed software, autologon, RDP) with Packer and register it in Cozystack as a reusable VM image." +weight: 55 +--- + +[Running Windows VMs]({{% ref "windows.md" %}}) shows how to boot and install a Windows VM by hand (including the Virtio drivers). This guide is the automated counterpart: it bakes a Windows Server install plus your software and configuration into **one reusable image** with [Packer](https://www.packer.io/), so every VM clones from a prepared disk instead of being installed from scratch. + +The [Golden Images]({{% ref "vm-image.md" %}}) guide covers *named* images cached from an HTTP(S) URL — the `vm-default-images` collection and custom entries added with `cdi_golden_image_create.sh`. That model fits cloud images that already exist as a single downloadable disk (most Linux cloud images do). A customized Windows image cannot be expressed as a plain download URL, so you build it with Packer against the KubeVirt builder, capture the configured disk, and register that disk instead. + +## When to use this + +- The image needs software or configuration baked in (an application, Office, drivers, autologon), so a bare cloud-image URL will not do. +- An installer is interactive or licensed and cannot be scripted end-to-end. +- You want one prepared Windows disk that many VMs clone from, instead of re-installing per VM. + +For a bare, unmodified base that *is* reachable by URL, prefer the simpler [`vm-default-images`]({{% ref "vm-image.md" %}}) path instead. + +## Prerequisites + +- `packer`, plus the **KubeVirt builder plugin**. It is a community plugin (not published by HashiCorp), so `packer init` cannot fetch it — install the binary manually and run `packer build` directly. +- A `KUBECONFIG` pointing at the target cluster, with access to a tenant namespace (e.g. `tenant-root`). +- The Windows installation ISO staged as a DataVolume so the builder can boot from it. A minimal DataVolume manifest referencing the ISO source is enough: + +```bash +kubectl apply -f win2022-iso-dv.yaml +``` + +## Build outline + +Your Packer project holds the build definition (`.pkr.hcl`), an unattended-install answer file (`autounattend.xml`), and the guest-provisioning scripts. In outline: + +1. Stage the Windows Server ISO as a DataVolume (step above). +2. Run the build — it boots the VM from the ISO via the unattended-install answer file, then provisions the guest (software, RDP, no-sleep, autologon): + +```bash +export KUBECONFIG=/path/to/kubeconfig +export PKR_VAR_build_password='' # must match the answer file +packer build . +``` + +3. **Interactive software step (if any).** Installers that are not silent cannot be scripted. RDP into the running VM (autologon gives you a logged-in desktop), install the software through its GUI, verify it, then sign out. +4. A final hardening provisioner reverts the build-time WinRM/RDP relaxations (turns basic/unencrypted auth off, drops the temporary firewall rule, re-enables NLA) and strips any baked autologon password, so none of the build-time weakening ships in the golden. +5. Capture the configured VM disk as the golden. + +{{% alert title="Never bake credentials or licences" color="warning" %}} + +The build-time Administrator/WinRM password is a throwaway placeholder in the answer file — change it per build and reset it at deploy. The autologon password is not baked: inject it at deploy time or use Sysinternals `Autologon.exe` (which stores it as an encrypted LSA secret). Any application credentials are entered by hand in the running VM, never committed to the image or to Git. + +{{% /alert %}} + +{{% alert title="Sysprep on evaluation media" color="warning" %}} + +On the Windows Server evaluation ISO, `sysprep /generalize` can crash (`spopk.dll`). If it does, capture the golden **without** generalize — but then clones share the same SID and computer name, which is acceptable only for a single VM; assign a unique name per clone if you run several. A purchased licence and non-evaluation media are required for production use. + +{{% /alert %}} + +## Register the image in Cozystack + +Once you have a captured Windows disk, make it reusable one of two ways: + +- **As a cloneable `vm-disk` (recommended for customized disks).** Keep the captured disk as a reference `VMDisk`, then create each VM from a **copy-clone** of it — see [Cloneable Virtual Machines]({{% ref "cloneable-vms.md" %}}). Use `cloneType: copy` (not snapshot) so each VM gets an independent disk. This is the right choice for a customized Windows image, whose disk is not a single public URL. +- **As a `vm-default-images` collection entry.** This path caches an image from a public HTTP(S) URL — see [Golden Images]({{% ref "vm-image.md" %}}). It suits a *bare* base image reachable by URL, not a customized captured disk. + +## Create and access the VM + +Create a `VMInstance` from a copy-clone of the reference disk: + +```bash +kubectl -n tenant-root create -f- < Date: Fri, 21 Aug 2026 21:30:05 +0300 Subject: [PATCH 2/5] docs(virtualization): show the Packer template, plugin, and answer file Expand the Windows golden-image guide with the actual Packer usage: installing the community KubeVirt builder plugin, a complete windows.pkr.hcl (source + build with WinRM communicator and provisioners), the variables file, and the role of autounattend.xml in enabling WinRM for the build. Previously the guide only showed `packer build` without the configuration. Signed-off-by: Alexey Artamonov --- .../virtualization/windows-golden-image.md | 118 ++++++++++++++++-- 1 file changed, 107 insertions(+), 11 deletions(-) diff --git a/content/en/docs/next/virtualization/windows-golden-image.md b/content/en/docs/next/virtualization/windows-golden-image.md index b1565c47..05684a81 100644 --- a/content/en/docs/next/virtualization/windows-golden-image.md +++ b/content/en/docs/next/virtualization/windows-golden-image.md @@ -19,34 +19,130 @@ For a bare, unmodified base that *is* reachable by URL, prefer the simpler [`vm- ## Prerequisites -- `packer`, plus the **KubeVirt builder plugin**. It is a community plugin (not published by HashiCorp), so `packer init` cannot fetch it — install the binary manually and run `packer build` directly. +- `packer`. - A `KUBECONFIG` pointing at the target cluster, with access to a tenant namespace (e.g. `tenant-root`). -- The Windows installation ISO staged as a DataVolume so the builder can boot from it. A minimal DataVolume manifest referencing the ISO source is enough: +- The Windows installation ISO staged as a DataVolume so the builder can boot from it. + +Stage the ISO as a DataVolume — the builder boots the VM from it: ```bash kubectl apply -f win2022-iso-dv.yaml ``` -## Build outline +## Install the KubeVirt builder plugin + +Packer needs the **KubeVirt builder** to create the VM on the cluster. It is a community plugin, **not published by HashiCorp**, so `packer init` cannot resolve it. Install the plugin binary manually into Packer's plugin directory (`~/.config/packer/plugins/...` on Linux/macOS, `%APPDATA%\packer.d\plugins\...` on Windows), keep the `source` string in the template matching where you installed it, and run `packer build` directly (skip `packer init`). + +## The Packer template + +A minimal `windows.pkr.hcl` has three parts: the plugin requirement, a `source` describing the VM and how Packer connects to it (WinRM), and a `build` listing the provisioners. + +```hcl +packer { + required_plugins { + kubevirt = { + # Community plugin, installed manually (see above). `packer init` cannot + # resolve this — keep the source matching your local install path. + source = "github.com/hashicorp/kubevirt" + version = ">= 0.9.0" + } + } +} + +source "kubevirt-iso" "windows" { + kube_config = var.kube_config + name = var.image_name + namespace = var.namespace + + iso_volume_name = "windows-2022-x86-64-iso" # the DataVolume you applied above + + disk_size = "32Gi" + instance_type = "u1.large" + instance_type_kind = "virtualmachineclusterinstancetype" + preference = "windows.2k22.virtio" + preference_kind = "virtualmachineclusterpreference" + os_type = "windows" + + networks { + name = "default" + pod {} + } + + # Files placed on the setup CD. Windows Setup auto-reads autounattend.xml; + # the scripts run at first boot and enable WinRM so Packer can connect. + media_files = [ + "./autounattend.xml", + "./scripts/enable-winrm.ps1", + "./scripts/set-network.ps1", + ] + + boot_command = [""] # press a key to boot from the install CD + boot_wait = "5s" + installation_wait_timeout = "8m" + + communicator = "winrm" + winrm_username = "Administrator" + winrm_password = var.build_password # must match autounattend.xml + winrm_wait_timeout = "45m" +} + +build { + sources = ["source.kubevirt-iso.windows"] + + # Confirm the WinRM connection is up. + provisioner "powershell" { + inline = ["(Get-CimInstance Win32_OperatingSystem).Caption"] + } + + # Your software + guest configuration — replace with what you need baked in. + provisioner "powershell" { script = "./scripts/install-software.ps1" } + provisioner "powershell" { script = "./scripts/configure-guest.ps1" } # RDP, disable sleep, etc. + provisioner "powershell" { script = "./scripts/configure-autologon.ps1" } # optional: logged-in desktop on boot + + # FINAL provisioner — keep it last. Reverts the build-time WinRM/RDP + # relaxations and strips any baked autologon password, so nothing weakened + # from the build ships in the golden. + provisioner "powershell" { script = "./scripts/harden.ps1" } +} +``` + +Declare the variables in `variables.pkr.hcl`: + +```hcl +variable "kube_config" { type = string } # pass via PKR_VAR_kube_config or -var +variable "namespace" { type = string, default = "tenant-root" } +variable "image_name" { type = string, default = "windows" } + +variable "build_password" { + type = string + sensitive = true + default = "REPLACE_ME_BUILD_PW" # throwaway; must match autounattend.xml +} +``` + +## The answer file (`autounattend.xml`) + +Windows Setup reads `autounattend.xml` from the media CD and installs unattended. Two things matter for the build: -Your Packer project holds the build definition (`.pkr.hcl`), an unattended-install answer file (`autounattend.xml`), and the guest-provisioning scripts. In outline: +- It sets the **Administrator password** to the same value as `var.build_password` — that is how Packer's WinRM communicator authenticates. +- It runs `enable-winrm.ps1` at first logon, which opens WinRM (basic + unencrypted, port 5985) so Packer can connect. These relaxations are **temporary** — `harden.ps1` reverts them before the golden is captured. -1. Stage the Windows Server ISO as a DataVolume (step above). -2. Run the build — it boots the VM from the ISO via the unattended-install answer file, then provisions the guest (software, RDP, no-sleep, autologon): +The password in `autounattend.xml` is a build-time placeholder, not a real secret — see the note below. + +## Run the build ```bash export KUBECONFIG=/path/to/kubeconfig -export PKR_VAR_build_password='' # must match the answer file +export PKR_VAR_build_password='' # must match autounattend.xml +kubectl apply -f win2022-iso-dv.yaml packer build . ``` -3. **Interactive software step (if any).** Installers that are not silent cannot be scripted. RDP into the running VM (autologon gives you a logged-in desktop), install the software through its GUI, verify it, then sign out. -4. A final hardening provisioner reverts the build-time WinRM/RDP relaxations (turns basic/unencrypted auth off, drops the temporary firewall rule, re-enables NLA) and strips any baked autologon password, so none of the build-time weakening ships in the golden. -5. Capture the configured VM disk as the golden. +Packer boots the VM from the ISO, waits for WinRM, runs the provisioners in order, and shuts the VM down. If any of your installers is **not silent** (cannot be scripted), install it interactively instead: RDP into the running VM (autologon gives you a logged-in desktop), install and verify through its GUI, then sign out and let the build continue to `harden.ps1`. When the build finishes, capture the VM disk as the golden. {{% alert title="Never bake credentials or licences" color="warning" %}} -The build-time Administrator/WinRM password is a throwaway placeholder in the answer file — change it per build and reset it at deploy. The autologon password is not baked: inject it at deploy time or use Sysinternals `Autologon.exe` (which stores it as an encrypted LSA secret). Any application credentials are entered by hand in the running VM, never committed to the image or to Git. +The build-time Administrator/WinRM password is a throwaway placeholder in `autounattend.xml` and `variables.pkr.hcl` — change it per build and reset it at deploy. The autologon password is not baked: inject it at deploy time or use Sysinternals `Autologon.exe` (which stores it as an encrypted LSA secret). Any application credentials are entered by hand in the running VM, never committed to the image or to Git. {{% /alert %}} From 2d06e543c61e9b5b646ddb0d6713e0cd0d766af2 Mon Sep 17 00:00:00 2001 From: Alexey Artamonov Date: Fri, 21 Aug 2026 21:30:26 +0300 Subject: [PATCH 3/5] docs(virtualization): drop Office from the example list (keep it generic) Signed-off-by: Alexey Artamonov --- content/en/docs/next/virtualization/windows-golden-image.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/en/docs/next/virtualization/windows-golden-image.md b/content/en/docs/next/virtualization/windows-golden-image.md index 05684a81..f3987a92 100644 --- a/content/en/docs/next/virtualization/windows-golden-image.md +++ b/content/en/docs/next/virtualization/windows-golden-image.md @@ -11,7 +11,7 @@ The [Golden Images]({{% ref "vm-image.md" %}}) guide covers *named* images cache ## When to use this -- The image needs software or configuration baked in (an application, Office, drivers, autologon), so a bare cloud-image URL will not do. +- The image needs software or configuration baked in (an application, drivers, agents, autologon), so a bare cloud-image URL will not do. - An installer is interactive or licensed and cannot be scripted end-to-end. - You want one prepared Windows disk that many VMs clone from, instead of re-installing per VM. From 89d10a48a494d0e235f727d722d240b5569e5f39 Mon Sep 17 00:00:00 2001 From: Alexey Artamonov Date: Fri, 21 Aug 2026 22:44:56 +0300 Subject: [PATCH 4/5] =?UTF-8?q?docs(virtualization):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20real=20plugin,=20clone=20flow,=20VM=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Plugin: the KubeVirt builder is the published github.com/hashicorp/kubevirt plugin, installed with `packer init` — drop the incorrect "community / install manually / cannot packer init" wording and the misattribution. - Register: describe the CDI-based clone flow (capture a vm-image DataVolume in cozy-public, clone per VM via source.image.name, verify it actually has data) instead of a non-existent cloneType field. - VMInstance example: set instanceProfile: windows.2k22.virtio and instanceType explicitly, so the VM does not fall back to the ubuntu profile. - Fix invalid HCL in variables (comma-separated attributes) and remove the duplicated ISO DataVolume apply. Signed-off-by: Alexey Artamonov --- .../virtualization/windows-golden-image.md | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/content/en/docs/next/virtualization/windows-golden-image.md b/content/en/docs/next/virtualization/windows-golden-image.md index f3987a92..263692f9 100644 --- a/content/en/docs/next/virtualization/windows-golden-image.md +++ b/content/en/docs/next/virtualization/windows-golden-image.md @@ -21,17 +21,17 @@ For a bare, unmodified base that *is* reachable by URL, prefer the simpler [`vm- - `packer`. - A `KUBECONFIG` pointing at the target cluster, with access to a tenant namespace (e.g. `tenant-root`). -- The Windows installation ISO staged as a DataVolume so the builder can boot from it. - -Stage the ISO as a DataVolume — the builder boots the VM from it: +- The Windows installation ISO staged as a DataVolume, so the builder can boot from it: ```bash kubectl apply -f win2022-iso-dv.yaml ``` -## Install the KubeVirt builder plugin +## The KubeVirt builder plugin + +Packer creates the VM on the cluster with the **KubeVirt builder** — the published plugin [`github.com/hashicorp/kubevirt`](https://github.com/hashicorp/packer-plugin-kubevirt), which provides the `kubevirt-iso` builder for both Linux and Windows. Declare it in `required_plugins` (see the template below) and install it with `packer init` — no manual download. -Packer needs the **KubeVirt builder** to create the VM on the cluster. It is a community plugin, **not published by HashiCorp**, so `packer init` cannot resolve it. Install the plugin binary manually into Packer's plugin directory (`~/.config/packer/plugins/...` on Linux/macOS, `%APPDATA%\packer.d\plugins\...` on Windows), keep the `source` string in the template matching where you installed it, and run `packer build` directly (skip `packer init`). +The plugin repository has a [Windows `kubevirt-iso` example](https://github.com/hashicorp/packer-plugin-kubevirt/tree/main/examples/builder/kubevirt-iso/windows) that this guide follows. ## The Packer template @@ -41,8 +41,7 @@ A minimal `windows.pkr.hcl` has three parts: the plugin requirement, a `source` packer { required_plugins { kubevirt = { - # Community plugin, installed manually (see above). `packer init` cannot - # resolve this — keep the source matching your local install path. + # Published plugin — `packer init` installs it automatically. source = "github.com/hashicorp/kubevirt" version = ">= 0.9.0" } @@ -78,9 +77,12 @@ source "kubevirt-iso" "windows" { boot_command = [""] # press a key to boot from the install CD boot_wait = "5s" - installation_wait_timeout = "8m" + installation_wait_timeout = "20m" communicator = "winrm" + winrm_host = "127.0.0.1" + winrm_local_port = 5000 + winrm_remote_port = 5985 winrm_username = "Administrator" winrm_password = var.build_password # must match autounattend.xml winrm_wait_timeout = "45m" @@ -109,9 +111,20 @@ build { Declare the variables in `variables.pkr.hcl`: ```hcl -variable "kube_config" { type = string } # pass via PKR_VAR_kube_config or -var -variable "namespace" { type = string, default = "tenant-root" } -variable "image_name" { type = string, default = "windows" } +variable "kube_config" { + type = string + default = "${env("KUBECONFIG")}" +} + +variable "namespace" { + type = string + default = "tenant-root" +} + +variable "image_name" { + type = string + default = "windows" +} variable "build_password" { type = string @@ -134,7 +147,7 @@ The password in `autounattend.xml` is a build-time placeholder, not a real secre ```bash export KUBECONFIG=/path/to/kubeconfig export PKR_VAR_build_password='' # must match autounattend.xml -kubectl apply -f win2022-iso-dv.yaml +packer init . # installs the KubeVirt builder plugin packer build . ``` @@ -156,12 +169,12 @@ On the Windows Server evaluation ISO, `sysprep /generalize` can crash (`spopk.dl Once you have a captured Windows disk, make it reusable one of two ways: -- **As a cloneable `vm-disk` (recommended for customized disks).** Keep the captured disk as a reference `VMDisk`, then create each VM from a **copy-clone** of it — see [Cloneable Virtual Machines]({{% ref "cloneable-vms.md" %}}). Use `cloneType: copy` (not snapshot) so each VM gets an independent disk. This is the right choice for a customized Windows image, whose disk is not a single public URL. +- **As a named image you clone per VM (recommended for customized disks).** Capture the prepared disk into a `vm-image-` DataVolume in the `cozy-public` namespace, then create each `VMDisk` from it via `source.image.name` — the full flow is in [Cloneable Virtual Machines]({{% ref "cloneable-vms.md" %}}). This is the right choice for a customized Windows image, whose disk is not a single public URL. The clone strategy (a storage smart-clone vs a host-assisted copy) is chosen by CDI and the storage backend, not set in the manifest; on some backends a smart-clone can report `Succeeded` while leaving the target empty, so verify the cloned DataVolume both reaches `Succeeded` and actually contains data before relying on it. - **As a `vm-default-images` collection entry.** This path caches an image from a public HTTP(S) URL — see [Golden Images]({{% ref "vm-image.md" %}}). It suits a *bare* base image reachable by URL, not a customized captured disk. ## Create and access the VM -Create a `VMInstance` from a copy-clone of the reference disk: +Create a `VMInstance` from a cloned Windows disk, setting the Windows preference and an instance type explicitly (otherwise the VM defaults to the `ubuntu` profile and boots Windows with the wrong devices and scheduling): ```bash kubectl -n tenant-root create -f- < Date: Fri, 21 Aug 2026 23:07:34 +0300 Subject: [PATCH 5/5] docs(virtualization): note cdi.kubevirt.io/cloneType:copy remedy for empty smart-clones Signed-off-by: Alexey Artamonov --- content/en/docs/next/virtualization/windows-golden-image.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/en/docs/next/virtualization/windows-golden-image.md b/content/en/docs/next/virtualization/windows-golden-image.md index 263692f9..d49c9359 100644 --- a/content/en/docs/next/virtualization/windows-golden-image.md +++ b/content/en/docs/next/virtualization/windows-golden-image.md @@ -169,7 +169,7 @@ On the Windows Server evaluation ISO, `sysprep /generalize` can crash (`spopk.dl Once you have a captured Windows disk, make it reusable one of two ways: -- **As a named image you clone per VM (recommended for customized disks).** Capture the prepared disk into a `vm-image-` DataVolume in the `cozy-public` namespace, then create each `VMDisk` from it via `source.image.name` — the full flow is in [Cloneable Virtual Machines]({{% ref "cloneable-vms.md" %}}). This is the right choice for a customized Windows image, whose disk is not a single public URL. The clone strategy (a storage smart-clone vs a host-assisted copy) is chosen by CDI and the storage backend, not set in the manifest; on some backends a smart-clone can report `Succeeded` while leaving the target empty, so verify the cloned DataVolume both reaches `Succeeded` and actually contains data before relying on it. +- **As a named image you clone per VM (recommended for customized disks).** Capture the prepared disk into a `vm-image-` DataVolume in the `cozy-public` namespace, then create each `VMDisk` from it via `source.image.name` — the full flow is in [Cloneable Virtual Machines]({{% ref "cloneable-vms.md" %}}). This is the right choice for a customized Windows image, whose disk is not a single public URL. The clone strategy (a storage smart-clone vs a host-assisted copy) is chosen by CDI and the storage backend, not set in the manifest; on some backends a smart-clone can report `Succeeded` while leaving the target empty, so verify the cloned DataVolume both reaches `Succeeded` and actually contains data before relying on it. If it comes up empty, set the annotation `cdi.kubevirt.io/cloneType: copy` on the `vm-image-` DataVolume to force a host-assisted, byte-for-byte copy — it either populates the target or fails loudly instead of silently succeeding. - **As a `vm-default-images` collection entry.** This path caches an image from a public HTTP(S) URL — see [Golden Images]({{% ref "vm-image.md" %}}). It suits a *bare* base image reachable by URL, not a customized captured disk. ## Create and access the VM