Public/New-Win32ToolkitTestVM.ps1

function New-Win32ToolkitTestVM {
    <#
    .SYNOPSIS
        Provisions the Hyper-V test VM: build (or attach) a golden VHDX, create a Gen2 VM, first-boot,
        wait for PowerShell Direct, and take a warm 'clean-base' standard checkpoint.
    .DESCRIPTION
        One-time setup for the Hyper-V test backend. HOST-ONLY — requires an elevated session and the
        Hyper-V PowerShell module. Two sources: build from a Windows 11 ISO (-IsoPath) or attach an
        existing bootable Gen2 VHDX (-VhdxPath, BYO). The VM is created with Secure Boot + a vTPM (Win11
        requirements), started, and driven to readiness (Wait-Win32ToolkitVMReady). Then — unless
        -Unattended — it PAUSES and hands you the VM console so you can sign in, run Windows Update and
        let all reboots finish; once you confirm, it freezes a STANDARD (memory-state) checkpoint so later
        runs revert to that warm, fully-patched, logged-in desktop with no boot. The VM name, checkpoint
        name, and guest credential are saved to config for the resolver/provider.
    .PARAMETER IsoPath
        Build the golden VHDX from this Windows 11 x64 ISO. Mutually exclusive with -VhdxPath.
    .PARAMETER VhdxPath
        Attach this existing bootable Gen2 VHDX (BYO). Mutually exclusive with -IsoPath.
    .PARAMETER Name
        VM name (default 'win32tk-golden').
    .PARAMETER Credential
        Guest local-admin credential (baked into the unattend when building; used for PowerShell Direct).
        Prompted if omitted.
    .PARAMETER MemoryStartupBytes
        Static startup memory for the VM (default 4 GB; accepts size literals like 8GB). Saved to config
        so a re-provision keeps your choice.
    .PARAMETER ProcessorCount
        Virtual processor count (default 2). Saved to config so a re-provision keeps your choice.
    .PARAMETER SwitchName
        Hyper-V virtual switch to connect (default 'Default Switch', NAT).
    .PARAMETER CheckpointName
        Warm checkpoint name (default 'clean-base').
    .PARAMETER ImageIndex
        Explicit edition index when building from ISO. Overrides -Edition.
    .PARAMETER Edition
        Edition name substring to pick when building from ISO (e.g. 'Pro', 'Enterprise', 'Home'). When
        omitted, the default preference is used: Windows 11 Pro first, Enterprise as a fallback. Pro is
        the right choice for a consumer multi-edition ISO (which has no Enterprise).
    .PARAMETER EnableTPM
        Attach a virtual TPM (default $true — Windows 11 requires it).
    .PARAMETER Recheckpoint
        Re-take the checkpoint on an existing VM.
    .PARAMETER Force
        Overwrite an existing VHDX / rebuild an existing VM.
    .PARAMETER Unattended
        Skip the manual-prep pause and checkpoint the fresh first-boot desktop automatically (CI /
        automation). By default provisioning STOPS before the checkpoint, opens the VM console, and lets
        you sign in, run Windows Update, and finish all reboots — then asks you to confirm, so 'clean-base'
        captures a fully-patched, idle desktop instead of a bare first boot.
    .EXAMPLE
        New-Win32ToolkitTestVM -IsoPath 'C:\iso\Win11_x64.iso'
    .EXAMPLE
        New-Win32ToolkitTestVM -VhdxPath 'D:\vm\win11-base.vhdx' -Credential (Get-Credential)
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([object])]
    param(
        [string]$IsoPath,
        [string]$VhdxPath,
        [string]$Name = 'win32tk-golden',
        [pscredential]$Credential,
        [uint64]$MemoryStartupBytes = 4GB,
        [ValidateRange(1, 64)] [int]$ProcessorCount = 2,
        [string]$SwitchName = 'Default Switch',
        [string]$CheckpointName = 'clean-base',
        [int]$ImageIndex,
        [string]$Edition,
        [bool]$EnableTPM = $true,
        [switch]$Recheckpoint,
        [switch]$Force,
        [switch]$Unattended
    )

    # --- Preconditions -----------------------------------------------------------------------------
    if (-not (Test-Win32ToolkitElevated)) {
        throw 'New-Win32ToolkitTestVM requires an elevated (Administrator) session — Hyper-V + PowerShell Direct need it.'
    }
    if (-not (Get-Module -ListAvailable -Name Hyper-V)) {
        throw 'The Hyper-V PowerShell module is not installed/enabled on this host.'
    }
    if (-not $IsoPath -and -not $VhdxPath) {
        throw 'Supply -IsoPath (build the golden VHDX from an ISO) or -VhdxPath (attach an existing VHDX).'
    }
    if ($IsoPath -and $VhdxPath) {
        throw '-IsoPath and -VhdxPath are mutually exclusive.'
    }
    if (-not $Credential) {
        # Prompt with a type-it-twice confirmation — a mistyped password here gets baked into the golden
        # image (unattend + Winlogon) and then nothing can log in, forcing a full rebuild.
        $Credential = Get-Win32ToolkitGuestCredentialInteractive -Message "Guest local-admin credential for VM '$Name' — enter the password twice; it must NOT be blank."
    }
    if ([string]::IsNullOrEmpty($Credential.GetNetworkCredential().Password)) {
        throw "The guest admin password must not be empty. A blank password is blocked for PowerShell Direct (the 'Limit local account use of blank passwords to console logon only' policy) and prevents AutoLogon — the build boots to a login screen instead of an auto-logged-in desktop. Re-run with a strong password."
    }

    # Reuse the last chosen CPU/RAM (persisted by a prior provision or by Set-Win32ToolkitTestVMResource) as the
    # defaults, unless the caller passed them explicitly. So a re-provision keeps your specs without re-typing.
    if (-not $PSBoundParameters.ContainsKey('ProcessorCount')) {
        $storedCpu = Get-Win32ToolkitConfigValue -Name 'HyperVProcessorCount' -Default ''
        if ($storedCpu) { $ProcessorCount = [int]$storedCpu }
    }
    if (-not $PSBoundParameters.ContainsKey('MemoryStartupBytes')) {
        $storedMem = Get-Win32ToolkitConfigValue -Name 'HyperVMemoryStartupBytes' -Default ''
        if ($storedMem) { $MemoryStartupBytes = [uint64]$storedMem }
    }

    # --- Reuse an existing, healthy VM -------------------------------------------------------------
    $existing = Get-VM -Name $Name -ErrorAction SilentlyContinue
    if ($existing -and -not $Force) {
        $hasCp = Get-VMCheckpoint -VMName $Name -Name $CheckpointName -ErrorAction SilentlyContinue
        if ($hasCp -and -not $Recheckpoint) {
            # Reuse is the intended fast path on a re-run — a normal success outcome, not a problem, so keep it
            # on Write-Host (a Write-Warning here reads as a spurious alarm on the happy path).
            Write-Host "VM '$Name' already exists with checkpoint '$CheckpointName' — reusing." -ForegroundColor Yellow
            # Adopting a VM changes exactly what the readiness probe checks (name/checkpoint/credential):
            # a stale cached verdict for the PREVIOUS configuration must not survive this.
            Clear-Win32ToolkitHyperVStateCache
            Set-Win32ToolkitConfigValue -Name 'HyperVVMName'     -Value $Name
            Set-Win32ToolkitConfigValue -Name 'HyperVCheckpoint' -Value $CheckpointName
            Set-Win32ToolkitGuestCredential -Credential $Credential
            return $existing
        }
    }
    if ($existing -and $Force) {
        if ($PSCmdlet.ShouldProcess($Name, 'Remove existing VM before rebuild')) {
            # Clear BEFORE the destruction: if the rebuild aborts mid-way, a <=60 s-old 'ready' verdict
            # must not steer backend resolution at a VM that no longer exists.
            Clear-Win32ToolkitHyperVStateCache
            Stop-VM -Name $Name -TurnOff -Force -ErrorAction SilentlyContinue
            Get-VMCheckpoint -VMName $Name -ErrorAction SilentlyContinue | Remove-VMCheckpoint -ErrorAction SilentlyContinue
            Remove-VM -Name $Name -Force -ErrorAction SilentlyContinue
        }
    }

    # --- Resolve the VHDX (build from ISO or BYO) --------------------------------------------------
    $paths = Get-Win32ToolkitHyperVPaths -BasePath (Get-Win32ToolkitBasePath)
    foreach ($dir in @($paths.Golden, $paths.VMs)) {
        if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
    }

    if ($IsoPath) {
        $vhdx = Join-Path $paths.Golden "$Name.vhdx"
        if ($PSCmdlet.ShouldProcess($vhdx, "Build golden VHDX from '$IsoPath'")) {
            $buildArgs = @{ IsoPath = $IsoPath; VhdxPath = $vhdx; AdminCredential = $Credential; Force = [bool]$Force }
            if ($PSBoundParameters.ContainsKey('ImageIndex') -and $ImageIndex) { $buildArgs['ImageIndex'] = $ImageIndex }
            if ($Edition) { $buildArgs['Edition'] = $Edition }
            New-Win32ToolkitGoldenVhdx @buildArgs | Out-Null
        }
    }
    else {
        if (-not (Test-Path -LiteralPath $VhdxPath)) { throw "BYO VHDX not found: $VhdxPath" }
        if ([IO.Path]::GetExtension($VhdxPath) -ne '.vhdx') { throw "BYO VHDX must be a .vhdx file: $VhdxPath" }
        $vhdx = $VhdxPath
    }

    # --- Create + configure the Gen2 VM ------------------------------------------------------------
    if ($PSCmdlet.ShouldProcess($Name, 'Create Gen2 VM + take clean-base checkpoint')) {
        # Invalidate the process-local clean marker / readiness cache — a brand-new VM + checkpoint
        # supersede anything cached about the previous one.
        Clear-Win32ToolkitHyperVStateCache
        New-VM -Name $Name -Generation 2 -MemoryStartupBytes $MemoryStartupBytes -VHDPath $vhdx -Path $paths.VMs -SwitchName $SwitchName -ErrorAction Stop | Out-Null
        Set-VMFirmware  -VMName $Name -EnableSecureBoot On -SecureBootTemplate 'MicrosoftWindows'
        Set-VMProcessor -VMName $Name -Count $ProcessorCount
        Set-VMMemory    -VMName $Name -DynamicMemoryEnabled $false -StartupBytes $MemoryStartupBytes
        if ($EnableTPM) {
            Set-VMKeyProtector -VMName $Name -NewLocalKeyProtector
            Enable-VMTPM       -VMName $Name
        }

        # Unplug the vNIC for the OOBE boot so Windows 11 OOBE does NOT stall on its network-dependent
        # "checking for updates" device-prep phase — with no network it fails fast and goes straight to
        # the desktop. Safe: PowerShell Direct is over the VMBus, not TCP/IP. Reconnected before the
        # checkpoint so reverts have internet for the test workload (winget).
        Disconnect-VMNetworkAdapter -VMName $Name -ErrorAction SilentlyContinue

        Write-Verbose "Starting VM '$Name' for first boot (unattended OOBE, NIC unplugged)..."
        Start-VM -Name $Name -ErrorAction Stop
        Wait-Win32ToolkitVMReady -VMName $Name -Credential $Credential | Out-Null

        # Let the first-logon reach a SETTLED desktop before checkpointing (explorer.exe = shell is up),
        # so clean-base is a real desktop, not a half-finished OOBE screen.
        Write-Verbose 'Waiting for the guest desktop to settle (explorer)...'
        $shellDeadline = (Get-Date).AddMinutes(10)
        $shellUp = $false
        do {
            Start-Sleep -Seconds 10
            $shellUp = [bool](Invoke-Command -VMName $Name -Credential $Credential -ScriptBlock {
                [bool](Get-Process -Name explorer -ErrorAction SilentlyContinue)
            } -ErrorAction SilentlyContinue)
        } until ($shellUp -or (Get-Date) -gt $shellDeadline)
        if (-not $shellUp) { Write-Warning 'Desktop shell (explorer) not detected before timeout — checkpointing anyway.' }

        # Reconnect the NIC BEFORE configuring AutoLogon (Sysinternals Autologon needs guest internet) and
        # before the warm checkpoint (so the workload has internet on revert).
        Connect-VMNetworkAdapter -VMName $Name -SwitchName $SwitchName -ErrorAction SilentlyContinue
        Start-Sleep -Seconds 8   # let the NIC acquire an address

        # Safety net: configure guest AutoLogon so any reboot during prep (Windows Update) lands back on
        # the desktop, and so the -EnsureDesktop recovery path works later.
        try { Set-Win32ToolkitGuestAutoLogon -VMName $Name -Credential $Credential }
        catch { Write-Warning "Could not configure guest AutoLogon: $($_.Exception.Message)" }

        # --- Manual prep window (default) --------------------------------------------------------------
        # A STANDARD checkpoint freezes the LIVE state (memory + disk): every test run reverts to the
        # EXACT moment captured here. So don't snapshot a bare first-boot desktop — hand the VM to the
        # operator to sign in, run Windows Update, and let all reboots finish, then confirm. -Unattended
        # skips this and checkpoints immediately (CI / automation).
        if (-not $Unattended) {
            # Try to pop the VM console. vmconnect.exe ships with the Hyper-V GUI tools, which can be ABSENT
            # on a Server / Cloud PC that only has the Hyper-V PowerShell module — resolve it robustly and,
            # if it's missing, tell the operator how to open the VM another way (a blind pause is useless).
            $vmconnect = (Get-Command 'vmconnect.exe' -ErrorAction SilentlyContinue).Source
            if (-not $vmconnect) {
                $cand = Join-Path $env:SystemRoot 'System32\vmconnect.exe'
                if (Test-Path -LiteralPath $cand) { $vmconnect = $cand }
            }
            $consoleOpened = $false
            if ($vmconnect) {
                try { Start-Process -FilePath $vmconnect -ArgumentList 'localhost', $Name -ErrorAction Stop; $consoleOpened = $true }
                catch { }
            }

            # Windows Update needs real internet. Verify it (and repair the common nested Default-Switch DNS
            # failure) BEFORE the operator relies on it, so "Windows Update just sits there" is caught here.
            Write-Verbose 'Checking the guest has working internet (Windows Update needs it)...'
            $guestNet = Confirm-Win32ToolkitGuestInternet -VMName $Name -Credential $Credential

            $sam = $Credential.UserName.Split('\')[-1]
            Write-Host ''
            Write-Host '──────────────────────────────────────────────────────────────────────────────' -ForegroundColor Yellow
            Write-Host ' PREPARE THE VM, THEN CONFIRM — the checkpoint freezes whatever state is live' -ForegroundColor Yellow
            Write-Host '──────────────────────────────────────────────────────────────────────────────' -ForegroundColor Yellow
            Write-Host " The VM '$Name' IS created and running right now — this prompt is only waiting for you." -ForegroundColor Green
            if ($guestNet) {
                Write-Host ' Guest internet: OK (DNS + outbound reachable) — Windows Update should work.' -ForegroundColor Green
            }
            else {
                Write-Host ' Guest internet: NOT confirmed — Windows Update will not download.' -ForegroundColor Red
                Write-Host " Tried DHCP renew + public DNS already. Check: Get-VMSwitch 'Default Switch', and in the" -ForegroundColor Yellow
                Write-Host ' VM run Test-NetConnection www.msftconnecttest.com -Port 80 . On a nested host the' -ForegroundColor Yellow
                Write-Host " Default Switch NAT must be up; you can also try Set-VMNetworkAdapter -VMName $Name -MacAddressSpoofing On." -ForegroundColor Yellow
            }
            if ($consoleOpened) {
                Write-Host " A console window opened for '$Name'. In that window:" -ForegroundColor Cyan
            }
            else {
                Write-Host ' Could NOT auto-open the VM console (vmconnect.exe not available on this host).' -ForegroundColor Yellow
                Write-Host ' Open the VM yourself, any one of:' -ForegroundColor Yellow
                Write-Host " - Hyper-V Manager -> Connect to '$Name'" -ForegroundColor Yellow
                Write-Host " - run: vmconnect.exe localhost $Name" -ForegroundColor Yellow
                Write-Host ' - install the console: Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-Management-Clients -All' -ForegroundColor Yellow
                Write-Host ' Then, in the VM:' -ForegroundColor Cyan
            }
            Write-Host " 1. Sign in if you're not already (user: $sam)." -ForegroundColor Cyan
            Write-Host ' 2. Run Windows Update until nothing is left; install everything.' -ForegroundColor Cyan
            Write-Host ' 3. Let ALL reboots finish and return to the desktop (AutoLogon signs back in).' -ForegroundColor Cyan
            Write-Host ' 4. Close first-run app windows so the desktop is idle and clean.' -ForegroundColor Cyan
            Write-Host ''
            Write-Host ' Every test run reverts to exactly this moment — make it a fully patched, idle desktop.' -ForegroundColor Gray
            Write-Host ''
            Read-Host ' When the desktop is fully ready, press Enter to capture the clean-base checkpoint' | Out-Null

            # After update reboots the guest may still be settling — re-confirm the shell before freezing.
            Write-Verbose 'Confirming the desktop is up before checkpointing...'
            $reDeadline = (Get-Date).AddMinutes(5)
            do {
                $shellUp = [bool](Invoke-Command -VMName $Name -Credential $Credential -ScriptBlock {
                    [bool](Get-Process -Name explorer -ErrorAction SilentlyContinue)
                } -ErrorAction SilentlyContinue)
                if (-not $shellUp) { Start-Sleep -Seconds 5 }
            } until ($shellUp -or (Get-Date) -gt $reDeadline)
            if (-not $shellUp) { Write-Warning 'Desktop shell not detected (guest may be mid-reboot) — checkpointing anyway.' }
        }

        Set-VM -Name $Name -CheckpointType Standard
        Checkpoint-VM -VMName $Name -SnapshotName $CheckpointName
        Write-Host "✓ Warm '$CheckpointName' checkpoint taken." -ForegroundColor Green

        Set-Win32ToolkitConfigValue -Name 'HyperVVMName'            -Value $Name
        Set-Win32ToolkitConfigValue -Name 'HyperVCheckpoint'        -Value $CheckpointName
        Set-Win32ToolkitConfigValue -Name 'HyperVBaseVhdx'          -Value $vhdx
        Set-Win32ToolkitConfigValue -Name 'HyperVProcessorCount'    -Value $ProcessorCount
        Set-Win32ToolkitConfigValue -Name 'HyperVMemoryStartupBytes' -Value $MemoryStartupBytes
        Set-Win32ToolkitGuestCredential -Credential $Credential
    }

    return (Get-VM -Name $Name -ErrorAction SilentlyContinue)
}