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-media\ 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: PowerShell 7.6+, Hyper-V enabled, Run as Administrator
 
.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
    )

    if (-not $IsWindows) {
        throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] New-OSDeployVM is supported only on Windows."
    }

    $currentPrincipal = [Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()
    if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] New-OSDeployVM requires Administrator rights. Re-run PowerShell as Administrator and try again."
    }

    if ([string]::IsNullOrWhiteSpace($ISO)) {
        $bootImageRoot = Join-Path -Path $env:ProgramData -ChildPath 'OSDeployCore\boot-media'
        $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."
        }
    }

    $hyperVState = Get-WindowsOptionalFeature -Online -FeatureName 'Microsoft-Hyper-V-All' -ErrorAction SilentlyContinue
    if (-not $hyperVState -or $hyperVState.State -notin @('Enabled', 'EnablePending')) {
        throw "[$(Get-Date -format s)] [$($MyInvocation.MyCommand.Name)] Hyper-V is not enabled. Run Install-OSDeployHyperV first. Current state: $($hyperVState.State)"
    }

    $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 { $_ }
    }
}