Installing Windows on VHD to use with Hyper-V
Tags: powershell, vhd
Published: 2020-01-17 16:38:32

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:

1. Mounting Installation ISO file
2. Presenting a menu to choose edition to install
3. Creating virtual hard disk
4. Installing windows using DISM tool
5. Setting up boot configuration
6. Copying unattend.xml
7. Cleaning up



## Example Usage

```powershell
.\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.

```powershell
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:

```powershell
$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

```powershell
$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.

```powershell
$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

```powershell
$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.

```powershell
$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.

```powershell
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.

```powershell
$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

```powershell
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)

```powershell
@"
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`

```powershell
Invoke-Expression "dism /apply-image /imagefile:`"$WimFile`" /index:$WimIdx /applydir:$($VirtualWinLetter):\"
```

### Setting up boot configuration

And then we make disk bootable

```powershell
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.

```powershell
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

```powershell
@"
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)](https://docs.microsoft.com/en-us/windows-hardware/manufacture/desktop/update-windows-settings-and-scripts-create-your-own-answer-file-sxs)  or use online tool [Windows Answer File Generator](https://www.windowsafg.com/)



```xml
<?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>
```



## Final script

{% gist https://gist.github.com/milolav/3e296ed6a9f8a6c431a8553060f7514b %}