Windows 11 VM proof and prerequisites

Windows 11 workspace integration is in development. These tools create direct KubeVirt proof VMs; they do not enable a Windows Image in the product catalog. The cross-repository status is maintained in tracking.

For an end-to-end process starting with Microsoft’s official ISO, see Build your own Windows 11 image. That guide retains the install/bootstrap/evidence and Docker-free packaging scripts, and covers private root export, registry authentication and clone validation. Operators supply Windows media and retain prepared disks privately; the catalog distributes recipes, not Windows binaries.

Create a Windows Image CR and Workspace

The Windows controller/API/UI integration uses the versioned windows11-amd64-v1 profile. Deploy the matching controller and API builds, generated Image/Workspace CRDs, and prerequisite RBAC together. When a GitOps application owns CRDs/RBAC through a pinned chart, update that chart through its normal release/revision workflow; do not override another field manager’s schema.

After preparing and privately publishing a generalised image using the build guide, create an operator-owned Image (replace the example reference with its real digest):

apiVersion: kubeworkspaces.io/v1alpha1
kind: Image
metadata:
  name: windows11-pro-private
spec:
  displayName: Windows 11 Pro (private)
  image: registry.example.com/private/windows11@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
  workspaceTypes: [vm]
  vmProfile: windows11-amd64-v1
  persistentRootDisk: true
  persistentRootDiskSize: 80Gi
  memoryLimit: 4Gi
  memoryRequest: 4Gi
  defaultPort: 0

The digest references the prepared root, not an installer ISO. No shared password, Linux cloud-init or Selkies configuration belongs in this Image. defaultPort: 0 advertises no guest web service; access uses hypervisor VNC.

Create CDI credentials and, when needed, a registry CA ConfigMap in the Workspace’s namespace. Certify an eligible worker with kubeworkspaces.io/windows11-amd64-eligible: "true" after checking the actual CPU/build and persistent-state prerequisites. A minimal Workspace is:

apiVersion: kubeworkspaces.io/v1alpha1
kind: Workspace
metadata:
  name: windows-desktop
  namespace: workspaces
spec:
  type: vm
  vmProfile:
    id: windows11-amd64-v1
    image: registry.example.com/private/windows11@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
    cpuCores: 2
    guestMemory: 4Gi
    rootDiskSize: 80Gi
    importSecretName: registry-import
    importCertConfigMapName: registry-ca
  template:
    spec:
      nodeSelector:
        kubernetes.io/hostname: eligible-worker
        kubernetes.io/arch: amd64
      containers:
        - name: workspace
          image: registry.example.com/private/windows11@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
          resources:
            requests: {cpu: "2", memory: 4Gi}
            limits: {cpu: "2", memory: 4Gi}

Omit importCertConfigMapName for a registry using normal public trust roots; omit importSecretName only when the root can legitimately be imported without authentication. The controller generates a unique provisioning generation, firmware identity, hostname and initial account credentials. Keep vmProfile and the main image consistent. Normal restart preserves them; Reset creates a fresh root/state/credential generation. User data disks are not part of this Windows profile.

In the UI select Virtual Machine, choose the Windows Image, set an eligible worker, and supply the same-namespace import Secret/CA references. The REST create payload uses those references under vm_options:

{
  "name": "windows-desktop",
  "namespace": "workspaces",
  "type": "vm",
  "container": {
    "name": "workspace",
    "image": "registry.example.com/private/windows11@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "cpu_limit": "2",
    "memory_limit": "4Gi"
  },
  "node_selector": {"kubernetes.io/hostname": "eligible-worker"},
  "vm_options": {"import_secret_name": "registry-import", "import_cert_config_map_name": "registry-ca"}
}

Windows readiness requires native setup/account completion, guest-agent connection and bootstrap removal. The controller gracefully restarts the guest once after setup to remove its answer CD, then retains only the unique initial credentials in a Workspace-owned Secret. Namespace editors/admins retrieve them through GET /v1/workspaces/windows-desktop/credentials?namespace=workspaces or the workspace’s initial-account panel. The response is Cache-Control: no-store; the answer XML/password is not included in ordinary Image/Workspace responses or YAML views. Use the Display connection for login. Serial, SSH, Linux mounts and guest-streaming are unavailable for this profile.

Infrastructure preflight

Target KubeVirt 1.9.x, CDI 1.61.0, Linux amd64 KVM workers and operator provided Windows 11 Pro/Enterprise media. Select and record the exact supported Windows feature release, edition, ISO SHA256, patch build and signed VirtIO driver ISO version/SHA256 before installing. Record activation and virtualisation entitlements separately from the public evidence.

python3 scripts/windows-vm-proof.py preflight --node YOUR_WORKER

This only reads cluster resources and reports known infrastructure blockers. It does not certify CPU eligibility, free physical disk space or Windows licensing. Inventory the actual CPU with lscpu on the worker; KubeVirt’s host-model-cpu labels can describe an older compatibility model. Check namespace quotas, existing pod requests and physical storage capacity. The default proof needs 4 vCPU, 8Gi guest memory plus launcher overhead, 80Gi root, installer and driver media, import scratch space and a separate backend-state PVC.

Persistent EFI and TPM storage

VMPersistentState is GA and automatically enabled in KubeVirt 1.9.0. An empty feature-gate array is not a blocker on this version. Older upstream documentation still asks operators to enable the gate explicitly. Verify the versioned implementation when using another version.

Leave spec.configuration.vmStateStorageClass unset in the KubeVirt CR to use the default storage class. To choose another class, configure that field in the deployment that owns the KubeVirt CR. Chart-side support requires a coordinated chart rollout; inspect your deployed chart’s values before assuming it exposes this override.

Do not explicitly select an RWO-only class with an empty StorageProfile. KubeVirt 1.9 selects RWX in that case; with no explicit class it defaults to RWO. If explicitly selecting local-path, first set its CDI StorageProfile:

apiVersion: cdi.kubevirt.io/v1beta1
kind: StorageProfile
metadata:
  name: local-path
spec:
  claimPropertySets:
    - accessModes: [ReadWriteOnce]
      volumeMode: Filesystem

Wait for status.claimPropertySets to reflect this before creating the VM. Backend state and root/media PVCs must schedule on the same worker. Node-local storage provides stop/start persistence on that worker, not node-failure recovery or relocation. KubeVirt creates backend-state PVCs owned by the VM; keep the VM object when restarting. Creating a bare VMI ties its state PVC to that VMI.

Isolated proof

Create an isolated windows11-proof namespace. The renderer emits a stopped VM with a new firmware UUID; save the manifest and reuse it for that VM. Rendering again without --firmware-uuid creates a new identity. Nothing is automatically applied or started by the script.

Infrastructure-only firmware probe

kubectl create namespace windows11-proof
python3 scripts/windows-vm-proof.py render --mode firmware --node YOUR_WORKER > firmware.json
kubectl apply --dry-run=server -f firmware.json
kubectl apply -f firmware.json
virtctl start -n windows11-proof windows11-proof

The firmware-only probe uses 1 vCPU, 1Gi RAM and a 1Gi blank root; install/clone fixtures use the 4 vCPU/8Gi/80Gi desktop defaults. --cpus 2 --memory-gi 4 selects the Windows minimum validation floor when capacity is constrained. Reserve host/control-plane headroom beyond guest and launcher memory. The live proof’s resource selection must account for the other workloads on the worker; successful scheduling alone does not establish sufficient physical headroom.

An empty root can reach firmware only. It can establish persistent state PVC allocation, KVM, signed OVMF, SMM and an emulated TPM in the launcher. It cannot prove Windows installation, BitLocker persistence, graphics or guest readiness.

Installer proof

Upload operator-supplied ISO files with the version-matched virtctl image-upload tool, using the default kubevirt content type, Filesystem/RWO PVCs, an appropriate size (e.g. 10Gi installer and 2Gi drivers) and the chosen StorageClass. With node-local storage, ensure upload pods/media are placed on the selected worker. Do not use --insecure as a default; configure trust for the CDI upload proxy.

python3 scripts/windows-vm-proof.py render --mode install --node YOUR_WORKER \
  --name windows11-build --installer-pvc windows11-iso --drivers-pvc virtio-iso > install.json
kubectl apply --dry-run=server -f install.json
kubectl apply -f install.json
virtctl start -n windows11-proof windows11-build
virtctl vnc -n windows11-proof windows11-build

The installer boots first, then the VirtIO root. Load signed viostor drivers for the root and NetKVM for networking; install the matching guest-agent serial driver and QEMU guest agent. The initial graphics candidate is VGA with a USB absolute tablet, not Linux VirtIO graphics. Pin and test drivers with Secure Boot enabled. Do not bypass the Windows installer requirements.

After installation stop the VM, wait for the VMI to disappear, remove installer and driver disks/volumes from the saved manifest, and set root boot order to 1. Apply it while stopped, then restart. Record TPM 2.0, Secure Boot, connected agent, network and display evidence using the catalog’s guest evidence script.

Seal and test two clones

Follow the private image recipe in image-catalog/windows11. The catalog includes windows11/render-bootstrap.py for private install/clone answer-file generation (see its README). Install mode wipes disk 0 and must only be used with a disposable blank root. Clone mode does not partition disks. Use the recipe’s offline prepare-sealed-root.py step after Sysprep/export: the tested Windows build needs a credential-free native setup registry pointer to D:\autounattend.xml to consume its SATA answer CD. Keep clone media at root plus that one CD, and remove the pointer after confirmed setup. For each clone create a different same-namespace bootstrap Secret containing autounattend.xml rendered with Windows System Image Manager for the pinned edition/build. Use distinct local account credentials/hostnames, XML-safe values, no auto-logon and no shared password. Secrets are supplied locally, never committed. This initial fixture references a Secret; the product’s automated credential generation/retrieval is a later controller/API milestone.

For CDI 1.61 pod registry imports, create an Opaque Secret with accessKeyId (registry username) and secretKey (password/token), and pass it as --import-secret. The catalog’s windows11/render-import-secret.py converts private inline Docker auth to these keys. A Secret with only .dockerconfigjson causes CreateContainerConfigError; Pod imagePullSecrets alone do not configure the import. Test authentication to the private registry using the actual CDI importer. Do not include registry credentials in the manifest.

For a registry signed by your own CA, create a same-namespace ConfigMap with the CA PEM (e.g. kubectl create configmap registry-ca --from-file=ca.crt -n windows11-proof) and pass --import-cert-configmap registry-ca. This sets CDI’s registry.certConfigMap; local workstation trust alone does not configure the importer. Keep TLS/hostname verification enabled.

python3 scripts/windows-vm-proof.py render --mode clone --node YOUR_WORKER \
  --name windows11-clone-a --image 'REGISTRY/PRIVATE/IMAGE@sha256:DIGEST' \
  --import-secret registry-import --sysprep-secret clone-a-bootstrap > clone-a.json

Repeat as clone-b with a different Secret and VM identity. Apply the saved manifests and start each clone. KubeVirt attaches Sysprep as a SATA CD-ROM. After setup is confirmed, stop, wait for VMI deletion, remove the sysprep disk/volume from the saved manifest and apply it. Delete the bootstrap Secret only after detaching it. Clean Windows Panther/Sysprep answer-file caches and verify that subsequent starts do not replay setup.

Acceptance and cleanup

Record root PVC UID, state PVC UID, VM UID, firmware UUID and VMI UID. Use virtctl stop, wait for VMI deletion, then virtctl start; require a different VMI UID and unchanged root/state PVCs, firmware identity and guest files. Prove guest TPM-bound state survives, using BitLocker only on a disposable guest with separately stored recovery material. Test Windows Update and guest reboot. Check graceful shutdown via agent/ACPI; do not substitute force deletion for it.

Record browser/native VNC login, secure-attention sequence, keyboard/layout, absolute pointer, reconnect, observer input restrictions, dxdiag and agent status. Clipboard, audio, resize, GPU, RDP, SSH, nested Hyper-V and Windows data disks are not implied by the firmware probe or VNC availability.

To dispose of a proof VM, stop it and wait for VMI deletion, delete the VM, then verify its owned root DataVolume/PVC and backend-state PVC are gone. Uploaded ISO PVCs and manually created bootstrap/import Secrets are independent resources; delete them separately. Remove only the dedicated proof namespace after all required disk exports/evidence are saved.

Guest access without VNC (QEMU guest agent)

The QEMU guest agent (QGA) channel gives shell access to a running Windows VM without opening VNC or the desktop client, which is what makes headless diagnosis possible: read the agent’s own version, service/worker state, event log, files and logs while a UAC prompt or a dead interactive session blocks the normal path. It rides the virtio-serial agent channel, not the network, so it also works when the desktop session is unusable.

Run the agent command through the VMI’s virt-launcher pod. The domain is named <namespace>_<name> and the command JSON is a single argument:

VM=windows11-pro
NS=windows11-proof
POD=$(kubectl get pod -n "$NS" -l kubevirt.io/domain="$VM" \
  -o jsonpath='{.items[0].metadata.name}')
qga() {
  kubectl exec -n "$NS" "$POD" -c compute -- \
    virsh -c qemu:///session qemu-agent-command "${NS}_${VM}" "$1"
}

guest-exec runs a guest process; capture-output collects stdout/stderr and guest-exec-status polls it. Encode a PowerShell script as base64 UTF-16LE and run it with -EncodedCommand:

SCRIPT='Get-Service KubeWorkspacesAgent | Format-List *; \
  Get-Content C:\ProgramData\workspace-agent\status.json'
B64=$(printf '%s' "$SCRIPT" | iconv -f UTF-8 -t UTF-16LE | base64 -w0)
qga "{\"execute\":\"guest-exec\",\"arguments\":{\"path\":\"C:\\\\Windows\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe\",\"arg\":[\"-NoProfile\",\"-EncodedCommand\",\"$B64\"],\"capture-output\":true}}"
# -> {"return":{"pid":1234}}; then poll:
qga '{"execute":"guest-exec-status","arguments":{"pid":1234}}'

A small Python helper loops the poll until exited and decodes out-data/ err-data. Useful probes: the running worker’s ExecutablePath, StartTime and command line (Get-CimInstance Win32_Process), the on-disk binary’s compiled version (kw-agent.exe --version) and SHA-256, service-status.json and status.json under C:\ProgramData\workspace-agent, and the worker logs under C:\Users\<user>\AppData\Local\workspace-agent\. Comparing the running process image hash with the installed file on disk is how a stale worker (old binary still resident after an MSI upgrade) is ruled out.

guest-exec requires the guest agent service to be running; a completed guest-exec-status only reports the guest process’s own exit, so read its captured output rather than assuming success from an empty result.

Tool checks

python3 -m unittest discover -s scripts -p 'test_windows_vm_proof.py'
make test-lint

Versioned references: KubeVirt 1.9 GA gates, backend PVC selection/ownership.

The chart vendors KubeVirt 1.9.1. For PCI GPU passthrough on hosts without CONFIG_IOMMUFD/VFIO cdev support (including Debian 13’s 6.12 kernel), use 1.9.1 or a later release carrying the VFIO cdev fallback fix. KubeVirt 1.9.0 can advertise a PCI resource but fail to create the launcher with lstat /dev/vfio/devices/vfio0: no such file or directory, even with the IOMMUFD feature gate disabled. Device advertisement alone is not guest attachment proof; verify a real launcher/guest and keep the host PF on its intended driver when passing an SR-IOV VF.

Experimental render-only GPU profiles

Chart 0.6.45 adds the typed Workspace/PlatformConfig GPU schema. Adopt the CRDs before declaring a profile or requesting a GPU. The controller, API and browser must also include the matching implementation; chart adoption alone does not install a Windows driver or make GPU streaming ready.

An operator can declare a versioned profile in PlatformConfig/default:

apiVersion: kubeworkspaces.io/v1alpha1
kind: PlatformConfig
metadata:
  name: default
spec:
  vmGPUProfiles:
    - id: intel-sriov-experimental-v1
      displayName: Intel graphics VF (experimental)
      resourceName: intel.com/graphics-vf
      kind: pci
      count: 1
      nodeSelector:
        example.com/windows-gpu: qualified
      guestDriverVersion: "32.0.101.6452"
      experimental: true

Apply a partial configuration with server-side apply and a dedicated field manager to preserve independently managed maintenance/auth/execution settings. Use actual qualified device resources, node labels and guest driver versions. The Windows workspace still requires an explicit certified worker hostname; that worker must match the GPU profile, be Ready/schedulable and advertise the resource. The resource must appear in the matching KubeVirt PCI or mediated allowlist. count: 1 is the initial supported allocation shape. Device capacity and namespace quotas remain enforced by Kubernetes scheduling/admission; the profile list is not a promise that a free device is available.

Discovery: GET /v1/vm-gpu-profiles. Create a Windows workspace with vm_options.gpu_profile set to the approved ID. Generic container GPU quantities remain rejected for Windows. The resolved snapshot is returned as vm_gpu_profile; it is requested configuration, not observed driver, rendering, capture or encoder readiness. Browser create/detail pages expose these choices.

For an existing workspace, Stop it and wait until the KubeVirt VM is halted and its VMI has disappeared, then call:

PUT /v1/workspaces/my-windows/gpu-profile?namespace=my-workspaces
Content-Type: application/json

{"profile_id":"intel-sriov-experimental-v1"}

Namespace editor/admin access is required. The API uses the Workspace resource version to reject concurrent lifecycle changes, and the controller independently guards actual VM/VMI state before changing hardware. No Reset or root import is performed: root, EFI/TPM, provisioning generation and initial credentials remain. Start the workspace normally after applying the choice. A Start racing ahead of reconciliation can apply the change only if the previous VM is actually halted and no old VMI exists. The emulated VGA and IDD are retained; the GPU is a secondary render-only adapter, with an exact extended-resource allocation.

Install/verify the matching signed guest driver separately using a qualified post-clone flow or private image. To remove the GPU, Stop/wait again and submit {"profile_id":""}, then Start. Software rollback does not require the old profile registry/device to be available. Do not edit an existing profile ID to represent different hardware/driver requirements: declare a new versioned ID and explicitly switch while stopped. Existing snapshots are not silently rewritten.

Before claiming accelerated Tier 1 support, separately prove an interactive GPU application on the IDD output, captured frames in native/browser viewers, device loss/resize/restart behavior and hardware encoder activation or honest software fallback. Off-screen D3D device/rendering proof alone does not establish those.