One PowerShell script that I've been using for a long time to prepare disk and install Windows for Hyper-V VMs. Since Microsoft released Windows Sandbox I'm not using it as often because sandbox starts faster and for simple application testing that's good enough. But every now and then I need a full VM and here you can find the script I'm using.
It takes a few of minutes to complete and then a few more minutes for windows setup to complete the first boot. Compared that to manual installation, faster and compared to Hyper-V "Install an OS from a bootable CD/DVD" it's more flexible.
It does the following:
- Mounting Installation ISO file
- Presenting a menu to choose edition to install
- Creating virtual hard disk
- Installing windows using DISM tool
- Setting up boot configuration
- Copying unattend.xml
- Cleaning up
Example Usage
.\Prepare-VirtualWinVhd.ps1
-VhdFile "TestWin.vhdx"
-WinIso "en_windows_10_multi-edition_version_1709_updated_dec_2017_x64_dvd_100406711.iso"
-WinEdition "Windows 10 Enterprise"
-VhdSize 60GB
-EfiLetter R
-VirtualWinLetter W
-UnattendFile ".\unattend.xml"
Script explained
Some defaults for the script. Installing Windows 10 Enterprise on a 40GB VHD. We will be using drive letter R for Efi partition and W for Windows partition. Make sure to pick letters are not assigned on your current windows installation. In addition to the installation, the unattend.xml file will also be copied to the VHD.
param (
[string]$WinIso,
[string]$VhdFile,
[string]$WinEdition = "Windows 10 Enterprise",
[long]$VhdSize = 40GB,
[char]$EfiLetter = 'R',
[char]$VirtualWinLetter = 'W',
[string]$UnattendFile = "unattend.xml"
)
In the very beginning of the script we are setting $ErrorActionPreference
to Stop
so that it stops on any error. In addition we need to make sure that script is run with elevated privileges. Both DISM and Diskpart require elevated privileges:
$ErrorActionPreference = 'Stop'
if(-not [bool]([Security.Principal.WindowsIdentity]::GetCurrent().Groups -contains 'S-1-5-32-544')) {
Write-Error "Script must be started as an Administrator"
}
Mounting Installation ISO file
Mounting iso file using Mount-DiskImage
, and getting the drive letter of mounted image
$WinIso = Resolve-Path $WinIso
Write-Progress "Mounting $WinIso ..."
$MountResult = Mount-DiskImage -ImagePath $WinIso -StorageType ISO -PassThru
$DriveLetter = ($MountResult | Get-Volume).DriveLetter
if (-not $DriveLetter) {
Write-Error "ISO file not loaded correctly" -ErrorAction Continue
Dismount-DiskImage -ImagePath $WinIso | Out-Null
return
}
Write-Progress "Mounting $WinIso ... Done"
Presenting a menu to choose edition to install
Once iso is mounted, we are using dism tool to enumerate all windows editions stored in install.wim file.
$WimFile = "$($DriveLetter):\sources\install.wim"
Write-Host "Inspecting $WimFile"
$WimOutput = dism /get-wiminfo /wimfile:`"$WimFile`" | Out-String
Output of dism /get-wiminfo
command looks like this:
C:\WINDOWS\system32>dism /get-wiminfo /wimfile:f:\sources\install.wim
Deployment Image Servicing and Management tool
Version: 10.0.18362.1
Details for image : f:\sources\install.wim
Index : 1
Name : Windows 10 Education
Description : Windows 10 Education
Size : 14.780.927.379 bytes
Index : 2
Name : Windows 10 Education N
Description : Windows 10 Education N
Size : 13.958.508.090 bytes
Index : 3
Name : Windows 10 Enterprise
Description : Windows 10 Enterprise
Size : 14.781.260.269 bytes
...
So parsing it with regex we want to extract Index and Name
$WimInfo = $WimOutput | Select-String "(?smi)Index : (?<Id>\d+).*?Name : (?<Name>[^`r`n]+)" -AllMatches
if (!$WimInfo.Matches) {
Write-Error "Images not found in install.wim`r`n$WimOutput" -ErrorAction Continue
Dismount-DiskImage -ImagePath $WinIso | Out-Null
return
}
And build the menu selecting the Edition that was passed to the script as a parameter just to make things easier. In addition we are checking that selected value is valid.
$Items = @{ }
$Menu = ""
$DefaultIndex = 1
$WimInfo.Matches | ForEach-Object {
$Items.Add([int]$_.Groups["Id"].Value, $_.Groups["Name"].Value)
$Menu += $_.Groups["Id"].Value + ") " + $_.Groups["Name"].Value + "`r`n"
if ($_.Groups["Name"].Value -eq $WinEdition) {
$DefaultIndex = [int]$_.Groups["Id"].Value
}
}
Write-Output $Menu
do {
try {
$err = $false
$WimIdx = if (([int]$val = Read-Host "Please select version [$DefaultIndex]") -eq "") { $DefaultIndex } else { $val }
if (-not $Items.ContainsKey($WimIdx)) { $err = $true }
}
catch {
$err = $true;
}
} while ($err)
Write-Output $Items[$WimIdx]
It will present a menu like this:
1) Windows 10 Education
2) Windows 10 Education N
3) Windows 10 Enterprise
4) Windows 10 Enterprise N
5) Windows 10 Pro
6) Windows 10 Pro N
7) Windows 10 Pro Education
8) Windows 10 Pro Education N
9) Windows 10 Pro for Workstations
10) Windows 10 Pro N for Workstations
Please select version [3]:
Creating virtual hard disk
Now that we know what we want to install, it is time to prepare disk for installation. First we ensure that $VhdFile
is full path, expanding it as needed, and that the file does not exists. If filename was not given as a parameter we are using Windows edition name.
If (!$VhdFile) {
$VhdFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Items[$WimIdx] + ".vhdx")
}else{
$VhdFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($VhdFile)
}
if (Test-Path $VhdFile) {
Write-Error "Vhd file '$VhdFile' allready exists." -ErrorAction Continue
Dismount-DiskImage -ImagePath $WinIso | Out-Null
return
}
Then we create a new VHD using New-VHD command, mount it and get the disk number used in diskpart.
$disk = New-VHD $VhdFile $VhdSize
Mount-DiskImage -ImagePath $VhdFile
$disknumber = (Get-DiskImage -ImagePath $VhdFile | Get-Disk).Number
Additionally just as an precaution, make sure that disk number is not 0 which is usually main system disk
if ($disknumber -eq 0) {
Write-Error "Unexpected disk number" -ErrorAction Continue
Dismount-DiskImage -ImagePath $VhdFile | Out-Null -ErrorAction Continue
Dismount-DiskImage -ImagePath $WinIso | Out-Null -ErrorAction Continue
return
}
To partition disk we are using a list of commands piped to diskpart. In addition we are assigning known letters to both EFI partition to configure boot record, and to partition for windows installation (future C drive)
@"
select disk $DiskNumber
convert gpt
select partition 1
delete partition override
create partition primary size=300
format quick fs=ntfs
create partition efi size=100
format quick fs=fat32
assign letter="$EfiLetter"
create partition msr size=128
create partition primary
format quick fs=ntfs
assign letter="$VirtualWinLetter"
exit
"@ | diskpart | Out-Null
Installing windows using DISM tool
Once the disk is ready we can apply image (install Windows) using dism tool. Since we are using all those variables it makes sense to build command line as a string and run it using Invoke-Expression
Invoke-Expression "dism /apply-image /imagefile:`"$WimFile`" /index:$WimIdx /applydir:$($VirtualWinLetter):\"
Setting up boot configuration
And then we make disk bootable
Invoke-Expression "$($VirtualWinLetter):\windows\system32\bcdboot $($VirtualWinLetter):\windows /f uefi /s $($EfiLetter):"
Invoke-Expression "bcdedit /store $($EfiLetter):\EFI\Microsoft\Boot\BCD"
Copying unattend.xml
If there was unatted.xml file set, make sure to copy it so Windows can be configured on the first boot.
if($UnattendFile) {
Write-Host "Copying unattended.xml"
New-Item -ItemType "directory" -Path "$($VirtualWinLetter):\Windows\Panther\" | Out-Null
Copy-Item $UnattendFile "$($VirtualWinLetter):\Windows\Panther\unattend.xml" | Out-Null
}
Cleaning up
In the end we just remove all assigned letters and unmount everything
@"
select disk $disknumber
select partition 2
remove letter="$EfiLetter"
select partition 4
remove letter="$VirtualWinLetter"
exit
"@ | diskpart | Out-Null
Dismount-DiskImage -ImagePath $VhdFile | Out-Null
Dismount-DiskImage -ImagePath $WinIso | Out-Null
Unattend.xml
Simple unattend.xml that just sets user (name user
, password login
) and skips all installation questions. There are many more options to set. You can find more from Microsoft Answer files (unattend.xml) or use online tool Windows Answer File Generator
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35"
language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<AutoLogon>
<Password>
<Value>login</Value>
<PlainText>true</PlainText>
</Password>
<Enabled>true</Enabled>
<Username>user</Username>
</AutoLogon>
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Home</NetworkLocation>
<SkipUserOOBE>true</SkipUserOOBE>
<SkipMachineOOBE>true</SkipMachineOOBE>
<ProtectYourPC>1</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Password>
<Value>login</Value>
<PlainText>true</PlainText>
</Password>
<Description></Description>
<DisplayName>user</DisplayName>
<Group>Administrators</Group>
<Name>user</Name>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<RegisteredOrganization></RegisteredOrganization>
<RegisteredOwner></RegisteredOwner>
<DisableAutoDaylightTimeSet>false</DisableAutoDaylightTimeSet>
<TimeZone>GMT Standard Time</TimeZone>
</component>
</settings>
</unattend>
Top comments (0)