public/New-OSDeployHyperVM.ps1

#Requires -PSEdition Core
#Requires -Version 7.4

<#
.SYNOPSIS
Creates a Hyper-V virtual machine for OSDeploy workflows.
 
.DESCRIPTION
Creates a new Hyper-V VM with configurable generation, memory, CPU, switch,
and VHD size. If -ISO is provided, the ISO is mounted to the VM's DVD drive.
If -ISO is not provided, an empty DVD drive is created.
 
.PARAMETER ISO
Optional path to a boot ISO to mount on the VM DVD drive. If omitted, the
function searches for the latest bootmedia.iso under
$env:ProgramData\OSDeployCore\boot\ and mounts it automatically. If
none is found the VM is created with an empty DVD drive.
 
.PARAMETER NamePrefix
Prefix used to generate the VM name. The final name is timestamped.
 
.PARAMETER Generation
VM generation to create. Valid values are 1 or 2.
 
.PARAMETER MemoryStartupGB
Startup memory in GB.
 
.PARAMETER ProcessorCount
Number of virtual processors.
 
.PARAMETER VHDSizeGB
Virtual disk size in GB.
 
.PARAMETER DisplayResolution
Display resolution to set on the VM video adapter.
 
.PARAMETER SwitchName
Virtual switch name. If omitted, the function tries to use 'Default Switch',
then falls back to the first available switch, otherwise creates an unconnected VM.
 
.PARAMETER CheckpointVM
Create an initial checkpoint after VM creation.
 
.PARAMETER SecureBootTemplate
Secure Boot template to apply on Generation 2 VMs. Valid values:
MicrosoftWindows, MicrosoftUEFICertificateAuthority, OpenSourceShieldedVM.
Default is MicrosoftWindows.
 
.PARAMETER StartVM
Start the VM after creation.
 
.OUTPUTS
System.Management.Automation.PSCustomObject
 
.INPUTS
None. This function does not accept pipeline input.
 
.NOTES
    Author: David Segura
    Company: Recast Software
        Requires: Windows 11 25H2 (build 26200), PowerShell 7.4+ (MSI install), Hyper-V enabled, Run as Administrator
 
        Dependencies:
            Module Functions: Install-OSDeploySoftware, Test-HyperVEnabled, Test-HyperVEnablePending, Test-IsAdministrator, Test-IsVM, Write-OSDeployBannerPreview
            Executables: vmconnect.exe
            Windows Features: Hyper-V
            .NET Classes: [IO.Path]
 
.EXAMPLE
New-OSDeployHyperVM
 
Creates a VM with an empty DVD drive.
 
.EXAMPLE
New-OSDeployHyperVM -ISO 'D:\ISO\WinPE.iso' -StartVM $true
 
Creates a VM and mounts the specified ISO.
#>

function New-OSDeployHyperVM {
    [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
    )
    #=================================================
    Write-OSDeployBannerPreview
    #=================================================
    # OSDeploy Requirements
    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."
    }
    #=================================================
    # Function Requirements
    if (Test-IsVM) {
        throw "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] New-OSDeployHyperVM cannot run inside a virtual machine."
    }
    #=================================================
    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 "[$(Get-Date -Format s)] [$($MyInvocation.MyCommand.Name)] Auto-selected ISO: $($latestISO.FullName)"
            $ISO = $latestISO.FullName
        }
        else {
            Write-Verbose "[$(Get-Date -Format s)] [$($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
    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

    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
        }
    }

    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
    }

    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
    if ($CheckpointVM) {
        $vm | Checkpoint-VM -SnapshotName 'New-OSDeployHyperVM' -ErrorAction Stop
        $checkpointed = $true
    }

    $started = $false
    $startVmConnnect = $false
    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 { $_ }
    }
}