<# This script creates a Windows VM directly from an install ISO. It has options to try to shrink the final VHD as small as possible. Tested on Windows 10. Example: $1MiB = [Math]::Pow(2,20) $1GiB = [Math]::Pow(2,30) $base_dir = [System.IO.Path]::Combine(${Env:Public}, 'Documents', 'Hyper-V', 'Virtual hard disks') $properties = @{ 'VMName' = "windows-" -f ((Get-Date).ToString('yyMMdd')); 'VhdLengthBytes' = 64 * $1GiB; 'VhdPath' = $base_dir; 'IsoPath' = 'D:\ISOs\Windows\Windows 10 Enterprise\17763.1.180914-1434.rs5_release_CLIENT_LTSC_VL_x64FRE_en-us.iso'; 'WindowsFixScriptPath' = [System.IO.Path]::Combine(${Env:UserProfile}, 'Documents', 'Win10.ps1'); 'ImageNumber' = 1; 'UnattendFilePath' = [System.IO.Path]::Combine($base_dir, 'unattend.xml'); 'PatchVHD' = $True; 'UpdatesPath' = [System.IO.Path]::Combine($base_dir, 'Win10 1809 17763.rs5_release LTSC Updates', 'Updates'); 'AutomaticCheckpointsEnabled' = $False; 'CreateVM' = $True; 'StartVM' = $True; 'Verbose' = $True; 'Generation' = 2; # DEBUG } Measure-Command { & 'C:\path\to\new-winvm.ps1' @properties } #> [CmdletBinding()] Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [Alias('Name')] [String] $VMName, [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $IsoPath, [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [UInt64] $VhdLengthBytes = $null, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [UInt16] $ProcessorCount = 2, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [String] $VhdPath = [System.IO.Path]::Combine(${Env:Public}, 'Documents', 'Hyper-V', 'Virtual hard disks'), [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [ValidateSet('Fixed', 'Dynamic')] [String] $VhdType = 'Dynamic', [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [ValidateSet('MBR', 'GPT')] [String] $PartitionStyle = 'MBR', [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [UInt64] $MemoryMinimumBytes = 2048 * [Math]::Pow(2,20), [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [UInt64] $MemoryMaximumBytes = 4096 * [Math]::Pow(2,20), [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [UInt64] $MemoryStartupBytes = $MemoryMinimumBytes, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [UInt16] $ImageNumber = 1, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [ValidateRange(1,2)] [UInt16] $Generation = 1, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [String] $UnattendFilePath = $null, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [String] $UpdatesPath = $null, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [String] $WindowsFixScriptPath = $null, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [Switch] $UseVhdxFormat = $False, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [Switch] $AutomaticCheckpointsEnabled = $False, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [Switch] $ListImages = $False, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [Switch] $PatchVHD = $True, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [Switch] $CompressVHD = $True, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [Switch] $CreateVM = $True, [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [Switch] $StartVM = $True ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 1 # Must use sdelete v1.61 here. MD5 checksum: E189B5CE11618BB7880E9B09D53A588F # sdelete v2.0+ have a serious performance bug New-Variable -Option ReadOnly -Name sdelete_path -Value ([System.IO.Path]::Combine(${Env:UserProfile}, 'bin', 'sdelete161.exe')) if (-not (Test-Path -Path $IsoPath)) { throw 'IsoPath not found' Exit } Function Create-VHD { Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdFullPath, [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $SizeBytes, [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $Type ) Write-Verbose 'Begin Create-VHD' $vhd_file_path = $VhdFullPath if ($UseVhdxFormat) { $dir_name = [System.IO.Path]::GetDirectoryName($vhd_file_path) $file_name = [System.IO.Path]::GetFileNameWithoutExtension($vhd_file_path) $vhd_file_path = [System.IO.Path]::Combine($dir_name, ($file_name + '.vhdx')) } $vhd_properties = @{ 'Path' = $vhd_file_path; 'SizeBytes' = $SizeBytes; $Type = $True; } $vhd = New-VHD @vhd_properties Write-Verbose 'End Create-VHD' Return $vhd_file_path } Function Create-VM { Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdFullPath) Write-Verbose 'Begin Create-VM' $vm_create_properties = @{ 'Name' = $VMName; 'MemoryStartupBytes' = $MemoryStartupBytes; 'Generation' = $Generation; 'VhdPath' = $VhdFullPath; } $vm = New-VM @vm_create_properties $vm_set_properties = @{ 'VM' = $vm; 'DynamicMemory' = $True; 'MemoryMinimumBytes' = $MemoryMinimumBytes; 'MemoryMaximumBytes' = $MemoryMaximumBytes; 'ProcessorCount' = $ProcessorCount; 'CheckpointType' = 'Standard'; 'AutomaticCheckpointsEnabled' = [Boolean] $AutomaticCheckpointsEnabled; } Set-VM @vm_set_properties | Out-Null Write-Verbose 'End Create-VM' Return $vm } Function Set-VMAdapter { Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [PSObject] $VM, [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VMSwitch ) Write-Verbose 'Begin Set-VMAdapter' # Remove the default unconfigured virtual network adapter and add your own: $vm_net_adapter = @{ 'VM' = $VM; 'SwitchName' = $VMSwitch; } Remove-VMNetworkAdapter -VM $VM Add-VMNetworkAdapter @vm_net_adapter Write-Verbose 'End Set-VMAdapter' Return } Function Format-VHD { Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdFullPath) Write-Verbose 'Begin Format-VHD' $vhd_obj = Mount-VHD -PassThru -Path $VhdFullPath $disk_obj = Get-Disk -Number $vhd_obj.DiskNumber $out_obj = $null Write-Verbose 'Format-VHD - Initialize-Disk' if ('RAW' -eq $disk_obj.PartitionStyle) { Initialize-Disk -Confirm:$False -InputObject $disk_obj -PartitionStyle $PartitionStyle | Out-Null } if (0 -eq $disk_obj.NumberOfPartitions) { if ('MBR' -eq $PartitionStyle.ToUpper()) { Write-Verbose 'Format-VHD - MBR' $part_obj = New-Partition -DiskNumber $vhd_obj.DiskNumber -UseMaximumSize:$True -AssignDriveLetter:$False -MbrType IFS -IsActive:$True # Format-Volume -Partition $part_obj -Confirm:$False -FileSystem NTFS -Force | Out-Null # $part_obj | Add-PartitionAccessPath -AssignDriveLetter | Out-Null } if ('GPT' -eq $PartitionStyle.ToUpper()) { Write-Verbose 'Format-VHD - GPT' # EFI sytem partition $system_partition_guid = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' # https://docs.microsoft.com/en-us/powershell/module/storage/new-partition?view=win10-ps $part_obj = New-Partition -DiskNumber $vhd_obj.DiskNumber -Size (100 * [Math]::Pow(2,20)) -GPTType $system_partition_guid -AssignDriveLetter:$False Format-Volume -Partition $part_obj -Confirm:$False -FileSystem FAT32 -Force | Out-Null $part_obj | Add-PartitionAccessPath -AssignDriveLetter | Out-Null # This prevents UI popups $vol_system_obj = Get-Volume -Partition $part_obj # OS partition $part_obj = New-Partition -DiskNumber $vhd_obj.DiskNumber -UseMaximumSize:$True -AssignDriveLetter:$False } Format-Volume -Partition $part_obj -Confirm:$False -FileSystem NTFS -Force | Out-Null $part_obj | Add-PartitionAccessPath -AssignDriveLetter | Out-Null # This prevents UI popups $vol_obj = Get-Volume -Partition $part_obj $out_obj = New-Object -TypeName PSCustomObject -Property @{ 'OSVolume' = $vol_obj.DriveLetter + [System.IO.Path]::VolumeSeparatorChar; 'EFIVolume' = $null; } if ('GPT' -eq $PartitionStyle.ToUpper()) { $out_obj.EFIVolume = $vol_system_obj.DriveLetter + [System.IO.Path]::VolumeSeparatorChar } } Write-Verbose 'EndFormat-VHD' Return $out_obj } Function Mount-ISO { Param() Write-Verbose 'Begin Mount-ISO' $disk_obj = Mount-DiskImage -ImagePath $IsoPath -StorageType ISO # Workaround for a bug in Get-Volume that fails to list removable media # $vol_obj = Get-Volume -DiskImage $disk_obj $vol_obj = Get-CimInstance -ClassName Win32_Volume | Where-Object { $_.Capacity -eq $disk_obj.Size -and $_.FreeSpace -eq 0 -and $_.FileSystem -eq 'UDF' } if ([String]::IsNullOrEmpty($vol_obj)) { Write-Error ("volume letter not found for mounted ISO {0}" -f ($IsoPath)) } Write-Verbose 'End Mount-ISO' Return $vol_obj.DriveLetter + [System.IO.Path]::DirectorySeparatorChar } Function Run-Process { Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $ProcessName, [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String[]] $ProcessArguments ) Write-Verbose ("Begin Run-Process {0} {1}" -f ($ProcessName, [String]::Join(' ', $ProcessArguments))) $proc_properties = @{ 'Wait' = $True; 'NoNewWindow' = $True; 'FilePath' = $ProcessName; 'ArgumentList' = $ProcessArguments; } $t = Measure-Command -Expression { Start-Process @proc_properties } Write-Verbose ("{0} took {1:0} min {2:0}.{3:000} sec" -f ($ProcessName, $t.Minutes, $t.Seconds, $t.Milliseconds)) Write-Verbose ("End Run-Process {0}" -f ($ProcessName)) Return } Function List-WIMFileImages { Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $IsoDriveLetter) Write-Verbose 'Begin List-WIMFileImages' $file = [System.IO.Path]::Combine($IsoDriveLetter, 'sources', 'install.wim') $dism_args = @('/Get-ImageInfo') $dism_args += ("/ImageFile:{0}" -f ($file)) Run-Process -ProcessName 'dism.exe' -ProcessArguments $dism_args Write-Verbose 'End List-WIMFileImages' Return } Function Apply-WIMFileToVHD { Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $IsoDriveLetter, [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdVolumeRoot ) Write-Verbose 'Begin Apply-WIMFileToVHD' $file = [System.IO.Path]::Combine($IsoDriveLetter, 'sources', 'install.wim') $dism_args = @('/Apply-Image') $dism_args += ("/ImageFile:{0}" -f ($file)) $dism_args += ("/Index:{0}" -f ($ImageNumber)) $dism_args += ("/ApplyDir:{0}" -f ($VhdVolumeRoot)) $dism_args += '/Compact' # optional Run-Process -ProcessName 'dism.exe' -ProcessArguments $dism_args Write-Verbose 'End Apply-WIMFileToVHD' Return } Function Copy-UnattendFile { Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdVolumeRoot) Write-Verbose 'Begin Copy-UnattendFile' $vhd_sysprep_path = [System.IO.Path]::Combine($VhdVolumeRoot, 'Windows', 'System32', 'Sysprep') $vhd_desktop_path = [System.IO.Path]::Combine($VhdVolumeRoot, 'Users', 'Default', 'Desktop') $choc_txt_path = 'D:\OneDrive\Documents\survival_kit\data\chocolatey.txt' # TODO if (-not ([String]::IsNullOrEmpty($UnattendFilePath))) { if ((Test-Path -Path $vhd_sysprep_path) -and (Test-Path -Path $UnattendFilePath)) { Copy-Item -Verbose -Path $UnattendFilePath -Destination $vhd_sysprep_path } } if (-not ([String]::IsNullOrEmpty($WindowsFixScriptPath))) { if ((Test-Path -Path $vhd_desktop_path) -and (Test-Path -Path $WindowsFixScriptPath)) { Copy-Item -Verbose -Path $WindowsFixScriptPath -Destination $vhd_desktop_path } } if ((Test-Path -Path $vhd_desktop_path) -and (Test-Path -Path $choc_txt_path)) { Copy-Item -Verbose -Path $choc_txt_path -Destination $vhd_desktop_path } Write-Verbose 'End Copy-UnattendFile' Return } Function Apply-WindowsUpdates { Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdVolumeRoot) Write-Verbose 'Begin Apply-WindowsUpdates' if (-not (Test-Path -Path $UpdatesPath)) { Write-Warning ("UpdatesPath '{0}' not found" -f ($UpdatesPath)) Write-Verbose 'End Apply-WindowsUpdates' Return } # Fetch these KB files from https://www.catalog.update.microsoft.com/Search.aspx?q=KB123456 $dism_args = @("/Image:{0}" -f ($VhdVolumeRoot)) $dism_args += '/Add-Package' Get-ChildItem -Recurse -Path $UpdatesPath | ?{ -not $_.PSIsContainer } | Sort-Object -Property FullName -Descending | foreach { $dism_args += ("/PackagePath:`"{0}`"" -f ($_.FullName)) } Run-Process -ProcessName 'dism.exe' -ProcessArguments $dism_args Write-Verbose 'End Apply-WindowsUpdates' Return } Function Clean-WindowsImage { Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdVolumeRoot) Write-Verbose 'Begin Clean-WindowsImage' $dism_args = @("/Image:{0}" -f ($VhdVolumeRoot)) $dism_args += '/Cleanup-Image' $dism_args += '/StartComponentCleanup' $dism_args += '/ResetBase' Run-Process -ProcessName 'dism.exe' -ProcessArguments $dism_args Write-Verbose 'End Clean-WindowsImage' Return } Function Set-BootLoader { Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $OSVolumeRoot, [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $BootRoot ) Write-Verbose 'Begin Set-BootLoader' $vhd_windows_path = [System.IO.Path]::Combine($OSVolumeRoot, 'Windows') $vhd_bcdboot_path = [System.IO.Path]::Combine($vhd_windows_path, 'System32', 'bcdboot.exe') $bcdboot_args = @($vhd_windows_path) $bcdboot_args += ("/s {0}" -f ($BootRoot)) $bcdboot_args += '/f ALL' Run-Process -ProcessName $vhd_bcdboot_path -ProcessArguments $bcdboot_args Write-Verbose 'End Set-BootLoader' Return } Function Prepare-VHD { Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdVolumeLetter, [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdVolumeRoot ) Write-Verbose 'Begin Prepare-VHD' $vhd_pagefiles = @('page', 'swap') | % {"{0}{1}file.sys" -f ($VhdVolumeRoot, $_)} Remove-Item -Verbose -Path $vhd_pagefiles -Force -Confirm:$False -ErrorAction SilentlyContinue Run-Process -ProcessName 'defrag.exe' -ProcessArguments @($VhdVolumeRoot, '/V', '/H', '/X') if (Test-Path -Path $sdelete_path) { Run-Process -ProcessName $sdelete_path -ProcessArguments @('-accepteula', '-z', $VhdVolumeLetter) } Write-Verbose 'End Prepare-VHD' Return } Function Shrink-VHD { Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $VhdFullPath ) Write-Verbose 'Begin Shrink-VHD' $vhd_len_old = (Get-Item -Path $VhdFullPath).Length $vhd_obj = Mount-VHD -Path $VhdFullPath -PassThru -ReadOnly $part_obj = Get-Partition -DiskNumber $vhd_obj.DiskNumber Optimize-VHD -Path $VhdFullPath Dismount-VHD -Path $VhdFullPath $vhd_len_new = (Get-Item -Path $VhdFullPath).Length $shrink_pct = 100 * (1 - ( $vhd_len_new / $vhd_len_old )) $shrink_pct_int = [Math]::Floor($shrink_pct) $shrink_pct_frac = [Math]::Floor(100 * ($shrink_pct - $shrink_pct_int)) $out = @() $out += "VHD len orig: {0:0.0} MiB" -f ($vhd_len_old / [Math]::Pow(2,20)) $out += "VHD len new: {0:0.0} MiB" -f ($vhd_len_new / [Math]::Pow(2,20)) $out += "shrink pct: {0:0}.{1:00}%" -f ($shrink_pct_int, $shrink_pct_frac) Write-Host ([String]::Join("`n", $out)) Write-Verbose 'End Shrink-VHD' Return } Function Main { Param() Write-Verbose 'Begin Main' $timestamp = (Get-Date).ToString('yyyyMMdd_HHmmss') if (2 -eq $Generation) { $UseVhdxFormat = $True $PartitionStyle = 'GPT' } $vhd_file_name = "{0}-{1}.vhd" -f ($VMName, $timestamp) $vm_vhd_path = [System.IO.Path]::Combine($VhdPath, $vhd_file_name) if ($PatchVHD) { if ([String]::IsNullOrEmpty($UpdatesPath)) { $PatchVHD = $False } } Dismount-DiskImage -ImagePath $IsoPath | Out-Null $iso_drive_letter = Mount-ISO if ([String]::IsNullOrEmpty($iso_drive_letter)) { throw 'mount iso failed' Exit } if ($ListImages) { List-WIMFileImages -IsoDriveLetter $iso_drive_letter Dismount-DiskImage -ImagePath $IsoPath | Out-Null Return } $create_properties = @{ 'VhdFullPath' = $vm_vhd_path; 'SizeBytes' = $VhdLengthBytes; 'Type' = $VhdType; } $vm_vhd_path = Create-VHD @create_properties $format_obj = Format-VHD -VhdFullPath $vm_vhd_path # $vhd_drive_letter = $format_obj.OSVolume if ([String]::IsNullOrEmpty($format_obj.OSVolume)) { throw 'vhd format failed' Exit } $vhd_drive_root = $format_obj.OSVolume + [System.IO.Path]::DirectorySeparatorChar Apply-WIMFileToVHD -IsoDriveLetter $iso_drive_letter -VhdVolumeRoot $vhd_drive_root Dismount-DiskImage -ImagePath $IsoPath | Out-Null $bootloader_properties = @{ 'OSVolumeRoot' = $vhd_drive_root; 'BootRoot' = $format_obj.OSVolume; } if (2 -eq $Generation) { $bootloader_properties.BootRoot = $format_obj.EFIVolume } Set-BootLoader @bootloader_properties if ($PatchVHD) { Apply-WindowsUpdates -VhdVolumeRoot $vhd_drive_root Clean-WindowsImage -VhdVolumeRoot $vhd_drive_root } Copy-UnattendFile -VhdVolumeRoot $vhd_drive_root if ($CompressVHD) { Prepare-VHD -VhdVolumeLetter $format_obj.OSVolume -VhdVolumeRoot $vhd_drive_root Dismount-VHD -Path $vm_vhd_path Shrink-VHD -VhdFullPath $vm_vhd_path } if ($CreateVM) { $vm = Create-VM -VhdFullPath $vm_vhd_path $vm_switch = Get-VMSwitch | Select-Object -First 1 -ExpandProperty Name Set-VMAdapter -VM $vm -VMSwitch $vm_switch if ($StartVM) { Start-VM -VM $vm -Verbose:$True } } Write-Verbose 'End Main' Return } . Main Exit