From 79f2966c6c6115eaacd6b255fc6d209e0a81bb98 Mon Sep 17 00:00:00 2001 From: Andrew Johnson <15828090+AndrewJNet@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:04:52 -0500 Subject: [PATCH] Update script for Autopilot VM creation with Windows 10/11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @arwidmark Playing around with Claude a bit - I wanted to add some additional error checking, cleaner output and comments for a bit of context to new users running this script. 2 changes that affect how this script has worked in the past that I want to call out: -vTPM is enabled -The copied VHDX is named after the VM, not the template VHDX Feel free to decline these changes - no hurt feelings :) -------------------------------- ## Improve robustness and usability of Autopilot test VM script This PR hardens the VM creation script and makes it friendlier for new Intune admins. No changes to the overall flow — it's still a flat, top-to-bottom script. ### Safety & correctness - Capture the existing VM's actual VHDX paths and config folder from the VM object before deletion, instead of assuming they're under `$VMLocation`. Prevents orphaned disk files when the VM was created with a different location. (Note: `Remove-VM` never deletes disks — the previous folder cleanup only worked when the paths happened to match.) - Prompt for confirmation before deleting an existing VM and its files. - Replace `exit 1` inside the try block with `throw`, so the `finally` block reliably dismounts the VHDX on failure. - Guard the unattend.xml edit: fail with a clear message if no ComputerName element is found in the template. ### Fail fast (before the slow VHDX copy) - `#Requires -RunAsAdministrator` and `#Requires -Modules Hyper-V`. - Verify the Hyper-V switch exists (and list available switches if not). - Check free disk space against the reference VHDX size. ### Autopilot/Windows 11 support - Enable a vTPM before first boot (required by Windows 11; keeps the hardware hash consistent with device state). Auto-creates the UntrustedGuardian if missing. Commented that VMs can't pass TPM attestation, so self-deploying mode won't work — user-driven only. - Disable checkpoints by default (comment explains when to re-enable). ### Usability - All configurable values grouped in one clearly marked "edit this" block, each with a one-line comment; removed leftover commented-out values. - Quieter, color-coded console output: cyan progress messages per phase, green completion message; removed `-Verbose` and suppressed the VM object dump from `New-VM`. - Final message prints the actual host name (`$env:COMPUTERNAME`) in the `vmconnect` hint, so it's correct when run remotely via `Invoke-Command`. ### Testing - [ ] Fresh run on a host with no existing VM - [ ] Re-run with an existing VM (confirm prompt + cleanup of old paths) - [ ] Run with a bad switch name / missing files (verify early abort) - [ ] Remote run via `Invoke-Command -ComputerName -FilePath` --- ...ilotVM-PreRegisterScenario-FullVHDCopy.ps1 | 181 +++++++++++++----- 1 file changed, 138 insertions(+), 43 deletions(-) diff --git a/Scripts/AutoPilot/New-AutoPilotVM-PreRegisterScenario-FullVHDCopy.ps1 b/Scripts/AutoPilot/New-AutoPilotVM-PreRegisterScenario-FullVHDCopy.ps1 index 271a9d4..88bac55 100644 --- a/Scripts/AutoPilot/New-AutoPilotVM-PreRegisterScenario-FullVHDCopy.ps1 +++ b/Scripts/AutoPilot/New-AutoPilotVM-PreRegisterScenario-FullVHDCopy.ps1 @@ -1,65 +1,160 @@ # Script to create a VM for Autopilot testing -# Requirements: VHDX file of sysprepped Windows 11 setup (can be default from Microsoft) +# Requirements: VHDX file of sysprepped Windows 10/11 setup (can be default from Microsoft) # -# TIP: To convert an existing WIM image to VHDX file, use Convert-WindowsImage.ps1 from https://github.com/nerdile/convert-windowsimage -# For example syntax, see https://github.com/DeploymentResearch/DRFiles/blob/master/Scripts/AutoPilot/Convert-WindowsImage-Syntax.ps1 +# TIP: To convert an existing WIM image to VHDX file, use Convert-WindowsImage.ps1 from https://github.com/nerdile/convert-windowsimage +# For example syntax, see https://github.com/DeploymentResearch/DRFiles/blob/master/Scripts/AutoPilot/Convert-WindowsImage-Syntax.ps1 # # Author: Johan Arwidmark # Twitter: @jarwidmark # LinkedIn: https://www.linkedin.com/in/jarwidmark -# Set some variables +# ============================================================ +# EDIT THESE VALUES FOR YOUR ENVIRONMENT +# ============================================================ + +# Name of the VM. This will also be set as the Windows computer name. +# WARNING: If a VM with this name already exists, it will be deleted (you will be prompted first). $VMName = "APTEST212" + +# Folder where the VM and its virtual hard disk will be created $VMLocation = "E:\VMs" + +# Name of the Hyper-V virtual switch to connect the VM to $VMNetwork = "NoInternet" + +# Memory and CPU for the VM $VMMemory = 4096MB +$VMProcessorCount = 2 + +# Path to your sysprepped reference VHDX file $RefVHD = "C:\VHD\W11-X64-25H2-Enterprise-2025-09.vhdx" + +# Paths to the supporting files copied into the VM $Unattend = "F:\GitHub\DRFiles\Scripts\AutoPilot\Unattend.xml" $APScript = "C:\Setup\Scripts\Get-WindowsAutoPilotInfo.ps1" $RemoveUnattendScript = "C:\Setup\Scripts\Remove-APUnattend.ps1" -# Verify that specified files exist -If (!(Test-Path $APScript)){ Write-Warning "Autopilot script not found, aborting...";Break} -If (!(Test-Path $Unattend)){ Write-Warning "Unattend.xml file not found, aborting...";Break} -If (!(Test-Path $RefVHD)){ Write-Warning "Parent VHDX file not found, aborting...";Break} +# ============================================================ +# No changes needed below this line +# ============================================================ + +# --- Verify that specified files and the virtual switch exist --- +# Checking everything up front means we fail fast, before the slow VHDX copy. +If (!(Test-Path $APScript)) { Write-Error "Autopilot script not found at $APScript, aborting..."; exit 1 } +If (!(Test-Path $Unattend)) { Write-Error "Unattend.xml file not found at $Unattend, aborting..."; exit 1 } +If (!(Test-Path $RefVHD)) { Write-Error "Parent VHDX file not found at $RefVHD, aborting..."; exit 1 } +If (!(Test-Path $RemoveUnattendScript)) { Write-Error "Remove-APUnattend script not found at $RemoveUnattendScript, aborting..."; exit 1 } +If (!(Get-VMSwitch -Name $VMNetwork -ErrorAction Ignore)) { + Write-Error "Hyper-V virtual switch '$VMNetwork' not found, aborting..." + Write-Host "Available switches:" -ForegroundColor Yellow + Get-VMSwitch | Select-Object Name, SwitchType | Format-Table -AutoSize + exit 1 +} + +# --- Verify there is enough free disk space for the VHDX copy --- +$RefVHDSizeGB = [math]::Round((Get-Item $RefVHD).Length / 1GB, 1) +$TargetDrive = (Get-Item (Split-Path $VMLocation -Qualifier)).PSDrive +$FreeSpaceGB = [math]::Round($TargetDrive.Free / 1GB, 1) +If ($FreeSpaceGB -lt ($RefVHDSizeGB + 5)) { + Write-Error "Not enough free space on $($TargetDrive.Name): drive. Need ~$($RefVHDSizeGB + 5) GB, found $FreeSpaceGB GB. Aborting..." + exit 1 +} -# Cleanup existing VM (if it exist) +# --- Cleanup existing VM (if it exists) --- $VM = Get-VM $VMName -ErrorAction Ignore -If ($VM) {$VM | Remove-VM -Force} -If (Test-Path "$VMLocation\$VMName") { Remove-Item -Recurse "$VMLocation\$VMName" -Force } +If ($VM) { + Write-Warning "A VM named '$VMName' already exists. It will be STOPPED and DELETED, including its files." + Read-Host "Press Enter to continue, or Ctrl+C to abort" + + # Ask the VM where its files actually live, BEFORE removing it. + # (The VM may have been created with a different location than $VMLocation.) + $OldVHDPaths = $VM.HardDrives.Path + $OldVMFolder = $VM.Path + + Stop-VM -VMName $VMName -Force -ErrorAction SilentlyContinue + $VM | Remove-VM -Force + # Remove-VM only deletes the VM configuration, not the disks, so clean those up too + foreach ($OldVHD in $OldVHDPaths) { + If (Test-Path $OldVHD) { Remove-Item -Path $OldVHD -Force } + } + If (Test-Path "$OldVMFolder\$VMName") { Remove-Item -Recurse "$OldVMFolder\$VMName" -Force } +} +If (Test-Path "$VMLocation\$VMName") { Remove-Item -Recurse "$VMLocation\$VMName" -Force } -# Create a new VHDX file -$VHDFileName = Split-Path $RefVHD -Leaf +# --- Create a new VHDX file named after the VM --- +$TargetVHDName = "$VMName.vhdx" $TargetVHDPath = "$VMLocation\$VMName\Virtual Hard Disks" -New-Item -Path $TargetVHDPath -ItemType Directory -Copy-Item -Path $RefVHD -Destination $TargetVHDPath -Mount-DiskImage -ImagePath "$TargetVHDPath\$VHDFileName" -$VHDXDisk = Get-DiskImage -ImagePath "$TargetVHDPath\$VHDFileName" | Get-Disk -$VHDXDiskNumber = [string]$VHDXDisk.Number -$VHDXDrive = Get-Partition -DiskNumber $VHDXDiskNumber -PartitionNumber 3 -$VHDXVolume = [string]$VHDXDrive.DriveLetter+":" - -# Copy unattend.xml and other files to disk -Copy-Item -Path $Unattend -Destination "$VHDXVolume\Windows\system32\Sysprep\Unattend.xml" -Copy-Item -Path $APScript -Destination "$VHDXVolume\Windows" -Copy-Item -Path $RemoveUnattendScript -Destination "$VHDXVolume\Windows" - -# Remove Convert-WindowsImageInfo.txt file -If (Test-Path "$VHDXVolume\Convert-WindowsImageInfo.txt"){Remove-Item -Path "$VHDXVolume\Convert-WindowsImageInfo.txt" -Force} - -# Update ComputerName in unattend.xml -$UnattendFileToModify = "$VHDXVolume\Windows\system32\Sysprep\Unattend.xml" -[xml]$xml = get-content $UnattendFileToModify -$xml.unattend.settings.component[1].computername = "$VMName" -$xml.save("$UnattendFileToModify") - -# Dismount the disk -Dismount-DiskImage -ImagePath "$TargetVHDPath\$VHDFileName" - -# Create the VM -New-VM -Name $VMName -Generation 2 -MemoryStartupBytes $VMMemory -SwitchName $VMNetwork -Path $VMLocation -VHDPath "$TargetVHDPath\$VHDFileName" -Verbose -Set-VMProcessor -VMName $VMName -Count 2 - -# Start the virtual machine +New-Item -Path $TargetVHDPath -ItemType Directory | Out-Null + +Write-Host "Copying VHDX ($RefVHDSizeGB GB), this may take a while..." -ForegroundColor Cyan +Copy-Item -Path $RefVHD -Destination "$TargetVHDPath\$TargetVHDName" + +# --- Mount the new VHDX and inject the Autopilot files --- +# The try/finally block makes sure the VHDX is always dismounted, +# even if something goes wrong halfway through. +try { + Mount-DiskImage -ImagePath "$TargetVHDPath\$TargetVHDName" | Out-Null + $VHDXDisk = Get-DiskImage -ImagePath "$TargetVHDPath\$TargetVHDName" | Get-Disk + $VHDXDrive = Get-Partition -DiskNumber $VHDXDisk.Number | + Where-Object { $_.Type -eq 'Basic' } | + Sort-Object Size -Descending | + Select-Object -First 1 + + If ([string]::IsNullOrEmpty([string]$VHDXDrive.DriveLetter)) { + throw "No drive letter assigned to the VHDX Windows partition, aborting..." + } + $VHDXVolume = [string]$VHDXDrive.DriveLetter + ":" + + # Copy unattend.xml and the Autopilot scripts into the Windows image + Copy-Item -Path $Unattend -Destination "$VHDXVolume\Windows\System32\Sysprep\Unattend.xml" + Copy-Item -Path $APScript -Destination "$VHDXVolume\Windows" + Copy-Item -Path $RemoveUnattendScript -Destination "$VHDXVolume\Windows" + + # Remove Convert-WindowsImageInfo.txt file (leftover from image creation, if present) + If (Test-Path "$VHDXVolume\Convert-WindowsImageInfo.txt") { + Remove-Item -Path "$VHDXVolume\Convert-WindowsImageInfo.txt" -Force + } + + # Update ComputerName in unattend.xml so Windows gets the same name as the VM + # Note: $xml.Save() requires a full (absolute) path - do not change this to a relative path. + $UnattendFileToModify = "$VHDXVolume\Windows\System32\Sysprep\Unattend.xml" + [xml]$xml = Get-Content $UnattendFileToModify + $component = $xml.unattend.settings.component | Where-Object { $_.ComputerName } + If (!$component) { + throw "No ComputerName element found in Unattend.xml - check your unattend template." + } + $component.ComputerName = $VMName + $xml.Save($UnattendFileToModify) +} +finally { + Dismount-DiskImage -ImagePath "$TargetVHDPath\$TargetVHDName" -ErrorAction SilentlyContinue | Out-Null +} + +# --- Create the VM --- +Write-Host "Creating VM '$VMName'..." -ForegroundColor Cyan +New-VM -Name $VMName -Generation 2 -MemoryStartupBytes $VMMemory -SwitchName $VMNetwork -Path $VMLocation -VHDPath "$TargetVHDPath\$TargetVHDName" | Out-Null +Set-VMProcessor -VMName $VMName -Count $VMProcessorCount + +# --- Enable a virtual TPM --- +# Windows 11 requires TPM 2.0, and having it enabled before first boot means the +# Autopilot hardware hash matches the real state of the device. +# Note: A vTPM is enough for user-driven Autopilot testing, but Hyper-V VMs +# cannot pass TPM attestation, so self-deploying mode will not work in a VM. +Write-Host "Enabling virtual TPM..." -ForegroundColor Cyan +$Owner = Get-HgsGuardian UntrustedGuardian -ErrorAction Ignore +If (!$Owner) { $Owner = New-HgsGuardian -Name UntrustedGuardian -GenerateCertificates } +$KeyProtector = New-HgsKeyProtector -Owner $Owner -AllowUntrustedRoot +Set-VMKeyProtector -VMName $VMName -KeyProtector $KeyProtector.RawData +Enable-VMTPM -VMName $VMName + +# --- Disable checkpoints to keep the lab tidy --- +# (Change to 'Standard' if you prefer to snapshot the VM before first boot +# so you can re-run Autopilot scenarios without recopying the VHDX.) +Set-VM -VMName $VMName -CheckpointType Disabled + +# --- Start the virtual machine --- Start-VM -VMName $VMName +Write-Host "" +Write-Host "Done. VM '$VMName' has been created and is starting on host '$env:COMPUTERNAME'..." -ForegroundColor Green +Write-Host "Connect to it with: vmconnect.exe $([System.Net.Dns]::GetHostEntry('').HostName) `"$VMName`"" -ForegroundColor Green