public/New-OSDeployHyperVM.ps1

#Requires -PSEdition Core
#Requires -Version 7.4

function New-OSDeployHyperVM {
    <#
    .SYNOPSIS
        Creates a Hyper-V virtual machine for OSDeploy workflows
 
    .DESCRIPTION
        Creates a timestamp-named Hyper-V VM with a new VHDX, fixed startup memory, configurable
        processors and display resolution, and a DVD drive. When ISO is omitted, the function
        selects the most recently modified bootmedia.iso under
        %ProgramData%\OSDeployCore\boot; when none exists, it creates an empty DVD drive.
 
        When SwitchName is omitted, the command selects Default Switch, then the first available
        switch, or creates an unconnected VM. Generation 2 VMs boot from the DVD drive with the
        selected Secure Boot template and enable available TPM-related settings when the host TPM
        is present and ready. The command can create an initial checkpoint and can invoke
        VMConnect before starting the VM.
 
    .PARAMETER ISO
        Specifies an existing file with an .iso extension. An empty or omitted value selects the
        newest OSDeploy bootmedia.iso, or leaves the new DVD drive empty when none is found.
 
    .PARAMETER NamePrefix
        Specifies text appended after a yyMMdd-HHmmss timestamp in the VM name. The default is
        OSDeploy.
 
    .PARAMETER Generation
        Specifies Hyper-V generation 1 or 2. The default is 2.
 
    .PARAMETER MemoryStartupGB
        Specifies fixed startup memory in GB from 2 through 64. The default is 8.
 
    .PARAMETER ProcessorCount
        Specifies 1 through 64 virtual processors. The default is 2.
 
    .PARAMETER VHDSizeGB
        Specifies the new VHDX size in GB from 8 through 512. The default is 64.
 
    .PARAMETER DisplayResolution
        Specifies one of the validated Hyper-V display resolutions. The default is 1600x900.
 
    .PARAMETER SwitchName
        Specifies an existing Hyper-V virtual-switch name. When omitted, the command uses
        Default Switch, then the first returned switch, or no switch when none is available.
 
    .PARAMETER SecureBootTemplate
        Specifies MicrosoftWindows, MicrosoftUEFICertificateAuthority, or OpenSourceShieldedVM
        for a Generation 2 VM. The default is MicrosoftWindows. Generation 1 ignores this value.
 
    .PARAMETER CheckpointVM
        Specifies whether to create a checkpoint named New-OSDeployHyperVM. The default is $true.
 
    .PARAMETER StartVM
        Specifies whether to invoke VMConnect when available and start the VM. The default is
        $true.
 
    .EXAMPLE
        PS> New-OSDeployHyperVM
 
        Creates, checkpoints, connects to, and starts a Generation 2 VM with the latest OSDeploy
        ISO when one is available.
 
    .EXAMPLE
        PS> New-OSDeployHyperVM -ISO 'D:\ISO\WinPE.iso' -StartVM $false
 
        Creates and checkpoints a VM with the specified ISO, leaving the VM stopped.
 
    .EXAMPLE
        PS> New-OSDeployHyperVM -WhatIf
 
        Returns a planned VM result with creation, startup, checkpoint, and VMConnect statuses
        set to $false.
 
    .INPUTS
        None. This function does not accept pipeline input.
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject. Returns VM identity, media, disk, switch,
        sizing, and action-status properties. A successful creation result also includes
        DisplayResolution. When ShouldProcess declines, the planned result omits that property
        and sets Created, Started, Checkpointed, and StartVMConnect to $false.
 
    .NOTES
        Author: David Segura
        Company: Recast Software
        Version: 1.0.0
        Date: 2026-08-28
 
        Requires Windows 11 25H2 or later, PowerShell 7.4 or later installed from MSI,
        curl.exe, Hyper-V and its PowerShell commands, a physical host, and Administrator rights.
        Hyper-V enabled-pending-reboot state stops the command.
 
    .LINK
        https://learn.microsoft.com/powershell/module/hyper-v/new-vm
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType([pscustomobject])]
    param (
        [Parameter()]
        [ValidateScript({
            if ([string]::IsNullOrWhiteSpace($_)) {
                return $true
            }

            if (-not (Test-Path -Path $_ -PathType Leaf)) {
                throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] ISO path was not found: $_"
            }

            if ([IO.Path]::GetExtension($_) -notmatch '^\.iso$') {
                throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] ISO must point to an .iso file: $_"
            }

            return $true
        })]
        [string]$ISO,

        [Parameter()]
        [string]$NamePrefix = 'OSDeploy',

        [Parameter()]
        [ValidateSet('1','2')]
        [UInt16]$Generation = 2,

        [Parameter()]
        [ValidateRange(2, 64)]
        [UInt16]$MemoryStartupGB = 8,

        [Parameter()]
        [ValidateRange(1, 64)]
        [UInt16]$ProcessorCount = 2,

        [Parameter()]
        [ValidateRange(8, 512)]
        [UInt16]$VHDSizeGB = 64,

        [Parameter()]
        [ValidateSet('640x480','800x600','1024x768','1152x864','1280x720',
        '1280x768','1280x800','1280x960','1280x1024','1360x768','1366x768',
        '1400x1050','1440x900','1600x900','1680x1050','1920x1080','1920x1200',
        '2560x1440','2560x1600','3840x2160','3840x2400','4096x2160')]
        [string]$DisplayResolution = '1600x900',

        [Parameter()]
        [string]$SwitchName,

        [Parameter()]
        [ValidateSet('MicrosoftWindows', 'MicrosoftUEFICertificateAuthority', 'OpenSourceShieldedVM')]
        [string]$SecureBootTemplate = 'MicrosoftWindows',

        [Parameter()]
        [bool]$CheckpointVM = $true,

        [Parameter()]
        [bool]$StartVM = $true
    )
    #=================================================
    # Stop before creating a VM when a required host capability is missing.
    if (-not (Test-IsWindows11)) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Windows 11 is required."
    }
    if (-not (Test-IsWindows1125H2)) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Windows 11 25H2 (build 26200) is required."
    }
    if (-not (Test-PwshVersionMin)) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] PowerShell 7.4 or higher is required."
    }
    if (-not (Test-PwshPSHome)) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] The MSI installation of PowerShell 7 is required."
    }
    if (-not (Test-CommandCurl)) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] curl.exe is required but was not found in the current PATH. curl.exe ships with Windows 10 1803+."
    }
    if (-not (Test-IsAdministrator)) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Administrator rights are required. Re-run PowerShell as Administrator and try again."
    }
    #=================================================
    # Block nested virtualization because this workflow requires a physical host.
    # Function Requirements
    if (Test-IsVM) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] New-OSDeployHyperVM cannot run inside a virtual machine."
    }
    #=================================================
    # Select the newest OSDeploy ISO when no ISO was supplied; otherwise create an empty DVD drive.
    if ([string]::IsNullOrWhiteSpace($ISO)) {
        $bootImageRoot = Join-Path -Path $env:ProgramData -ChildPath 'OSDeployCore\boot'
        $latestISO = Get-ChildItem -Path $bootImageRoot -Filter 'bootmedia.iso' -Recurse -ErrorAction SilentlyContinue |
            Sort-Object -Property LastWriteTime -Descending |
            Select-Object -First 1
        if ($latestISO) {
            Write-Verbose "[$($MyInvocation.MyCommand.Name)] Auto-selected ISO: $($latestISO.FullName)"
            $ISO = $latestISO.FullName
        }
        else {
            Write-Verbose "[$($MyInvocation.MyCommand.Name)] No bootmedia.iso found in $bootImageRoot; VM will be created with an empty DVD drive."
        }
    }

    $requiredCommands = @(
        'New-VM',
        'Get-VMHost',
        'Add-VMDvdDrive',
        'Set-VMFirmware',
        'Set-VMVideo',
        'Get-VMIntegrationService',
        'Enable-VMIntegrationService',
        'Set-VMProcessor',
        'Set-VMMemory',
        'Set-VM',
        'Get-VM',
        'Start-VM',
        'Checkpoint-VM',
        'Get-VMSwitch'
    )

    foreach ($commandName in $requiredCommands) {
        if (-not (Get-Command -Name $commandName -ErrorAction SilentlyContinue)) {
            throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Required Hyper-V command '$commandName' was not found. Ensure Hyper-V PowerShell tools are installed."
        }
    }

    if (-not (Test-HyperVEnabled)) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Hyper-V is not enabled. Run Install-OSDeploySoftware -Name 'hyperv'"
    }

    if (Test-HyperVEnablePending) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Hyper-V is enabled but requires a reboot. Please restart your computer."
    }

    $availableSwitches = Get-VMSwitch -ErrorAction SilentlyContinue
    # Choose a default virtual switch only when the caller did not supply one.
    if (-not $SwitchName -and $availableSwitches) {
        $defaultSwitch = $availableSwitches | Where-Object { $_.Name -eq 'Default Switch' } | Select-Object -First 1
        if ($defaultSwitch) {
            $SwitchName = $defaultSwitch.Name
        }
        else {
            $SwitchName = ($availableSwitches | Select-Object -First 1).Name
        }
    }

    $vmName = "$((Get-Date).ToString('yyMMdd-HHmmss')) $NamePrefix"
    $vmHost = Get-VMHost -ErrorAction Stop
    $vhdPath = Join-Path -Path $vmHost.VirtualHardDiskPath -ChildPath "$vmName.vhdx"
    $memoryStartupBytes = $MemoryStartupGB * 1GB
    $vhdSizeBytes = $VHDSizeGB * 1GB

    # Honor WhatIf and Confirm by returning the planned VM configuration without creating it.
    if (-not $PSCmdlet.ShouldProcess($vmName, 'Create and configure Hyper-V virtual machine')) {
        return [pscustomobject]@{
            VMName         = $vmName
            ISOPath        = $ISO
            VHDPath        = $vhdPath
            SwitchName     = $SwitchName
            Generation     = $Generation
            MemoryStartupGB= $MemoryStartupGB
            ProcessorCount = $ProcessorCount
            VHDSizeGB      = $VHDSizeGB
            Created         = $false
            Started         = $false
            Checkpointed    = $false
            StartVMConnect = $false
        }
    }

    # Attach the selected switch when available; otherwise create an unconnected VM.
    if ($SwitchName) {
        $vm = New-VM -Name $vmName -Generation $Generation -MemoryStartupBytes $memoryStartupBytes -NewVHDPath $vhdPath -NewVHDSizeBytes $vhdSizeBytes -SwitchName $SwitchName -ErrorAction Stop
    }
    else {
        $vm = New-VM -Name $vmName -Generation $Generation -MemoryStartupBytes $memoryStartupBytes -NewVHDPath $vhdPath -NewVHDSizeBytes $vhdSizeBytes -ErrorAction Stop
    }

    # Attach the supplied or discovered ISO; otherwise add an empty DVD drive.
    if ($ISO) {
        $dvdDrive = $vm | Add-VMDvdDrive -Path $ISO -Passthru -ErrorAction Stop
    }
    else {
        $dvdDrive = $vm | Add-VMDvdDrive -Passthru -ErrorAction Stop
    }

    if ($Generation -eq 2 -and $dvdDrive) {
        $vm | Set-VMFirmware -FirstBootDevice $dvdDrive -EnableSecureBoot On -SecureBootTemplate $SecureBootTemplate -ErrorAction Stop

        if (Get-Command -Name 'Get-TPM' -ErrorAction SilentlyContinue) {
            $tpm = Get-TPM
            if ($tpm.TpmPresent -eq $true -and $tpm.TpmReady -eq $true) {
                if (Get-Command -Name 'Set-VMSecurity' -ErrorAction SilentlyContinue) {
                    $vm | Set-VMSecurity -VirtualizationBasedSecurityOptOut:$false -ErrorAction Stop
                }

                if (Get-Command -Name 'Set-VMKeyProtector' -ErrorAction SilentlyContinue) {
                    $vm | Set-VMKeyProtector -NewLocalKeyProtector -ErrorAction Stop
                }

                if (Get-Command -Name 'Enable-VMTPM' -ErrorAction SilentlyContinue) {
                    $vm | Enable-VMTPM -ErrorAction Stop
                }
            }
        }
    }

    $vm | Set-VMMemory -DynamicMemoryEnabled $false -ErrorAction Stop
    $vm | Set-VMProcessor -Count $ProcessorCount -ErrorAction Stop

    $horizontalResolution = [int]($DisplayResolution.Split('x')[0])
    $verticalResolution = [int]($DisplayResolution.Split('x')[1])
    $vm | Set-VMVideo -HorizontalResolution $horizontalResolution -VerticalResolution $verticalResolution -ResolutionType Single -ErrorAction Stop

    $integrationService = Get-VMIntegrationService -VMName $vm.Name -ErrorAction SilentlyContinue |
        Where-Object { $_ -match 'Microsoft:[0-9A-Fa-f]{8}[-][0-9A-Fa-f]{4}[-][0-9A-Fa-f]{4}[-][0-9A-Fa-f]{4}[-][0-9A-Fa-f]{12}\\6C09BB55-D683-4DA0-8931-C9BF705F6480' }

    if ($integrationService) {
        $vm | Get-VMIntegrationService -Name $integrationService.Name | Enable-VMIntegrationService -ErrorAction SilentlyContinue
    }

    $vm | Set-VM -AutomaticCheckpointsEnabled $false -AutomaticStartAction Nothing -AutomaticStartDelay 3 -AutomaticStopAction Shutdown -ErrorAction Stop

    $checkpointed = $false
    # Create the initial checkpoint only when requested.
    if ($CheckpointVM) {
        $vm | Checkpoint-VM -SnapshotName 'New-OSDeployHyperVM' -ErrorAction Stop
        $checkpointed = $true
    }

    $started = $false
    $startVmConnnect = $false
    # Start and optionally open VMConnect only when requested.
    if ($StartVM) {
        if (Get-Command -Name 'vmconnect.exe' -ErrorAction SilentlyContinue) {
            vmconnect.exe $env:ComputerName $vmName
            Start-Sleep -Seconds 10
            $startVmConnnect = $true
        }

        $vm | Start-VM -ErrorAction Stop
        $started = $true
    }

    $finalVm = Get-VM -Name $vmName -ErrorAction Stop

    [pscustomobject]@{
        VMName          = $finalVm.Name
        ISOPath         = if ($ISO) { $ISO } else { $null }
        VHDPath         = $vhdPath
        SwitchName      = $SwitchName
        Generation      = $Generation
        MemoryStartupGB = $MemoryStartupGB
        ProcessorCount  = $ProcessorCount
        VHDSizeGB       = $VHDSizeGB
        DisplayResolution = $DisplayResolution
        Created         = $true
        Started         = $started
        Checkpointed    = $checkpointed
        StartVMConnect = $startVmConnnect
    }
}

Register-ArgumentCompleter -CommandName New-OSDeployHyperVM -ParameterName 'SwitchName' -ScriptBlock {
    Get-VMSwitch | Select-Object -ExpandProperty Name | ForEach-Object {
        if ($_.Contains(' ')) { "'$_'" } else { $_ }
    }
}