How to use WDS to PXE Boot a Nano Server VHD with PowerShell

The goal of this article is to provide a more detailed step by step way of achieving what was recently presented in How to use WDS to PXE Boot a Nano Server VHD post on Nano Server team blog.

Let’s have a look at the requirements to go through these steps. I’ll actually assume that:

My test laptop is a Windows 10 Enterprise insider preview installed (build 10130).

Let’s quickly summarize what steps are required to be able to deploy a Nano.vhd from WDS after a PXE boot:

  1. Create an Internal Hyper-V switch so that computers attached to it won’t impact your environment but can communicate with each other.
  2. Provision a Domain Controller virtual machine from the ISO file
  3. Configure the DC by:
    • Setting a static IP Address and assign it to the adapter bound to the Internal Hyper-V switch
    • Installing the required Windows features and roles: Active Directory Domain Services, DHCP and DNS Server, and Windows Deployment Services (a.k.a. WDS)
    • Configuring these features to be able to PXE boot from the boot.wim file available on the original ISO file and delivering the prepared Nano.vhd file as an image to be installed
  4. Provision a new empty VM, PXE boot and install the Nano.vhd

Step 1

Prepare the Internal switch on Hyper-V and assign it a static IP address.

1
2
# Create an internal switch on Hyper-V
($VMswitch = New-VMSwitch -Name "Internal-Test" -SwitchType Internal)

1
2
3
4
5
# Set a static IP address on Hyper-V switch
Get-NetAdapter |
   Where Name -eq "vEthernet ($($VMswitch.Name))" |
   Where InterfaceDescription -match "Hyper-V\sVirtual\sEthernet Adapter" |
   New-NetIPAddress -IPAddress 10.0.0.1 -PrefixLength 24

I’ve downloaded the required ISO into the Downloads folder of the logged on user. I’ll store it as a variable to be able to use it later on.

1
2
3
# Set the path to ISO
$iso  = $ExecutionContext.SessionState.Path.
GetUnresolvedProviderPathFromPSPath('~/downloads/en_windows_server_technical_preview_2_x64_dvd_6687981.iso')

NB: The path is expanded and there’s no check whether the file exists or not.

Let’s make sure that the integrity of the file is fine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Integrity check of ISO file
if (
 (Get-FileHash -Path $ISO -Algorithm SHA256 -ErrorAction SilentlyContinue).Hash -eq
 'D8D841393F661E30D448D2E6CBCEE20A94D9A57A94695B64EE76CA6B0910F849'
){
 Write-Information -Msg "Got the correct Technical Preview 2 ISO file" -InfA 2
} else {
 Write-Warning -Message "Don't have the correct ISO of the Technical Preview 2"
 break
}

The Msg parameter name is the alias for MessageData and InfA is the alias for InformationAction.

Let’s also prepare a Nano.vhd file that will be copied to the DC1 disk and proposed as an installation image by the PXE server.

To create this Nano.vhd file, you can either follow the Getting Started with Nano Server guide, or you can use the new PowerShell Script to build your Nano Server Image. I’ll use the latter.

That said, first download the required scripts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Download scripts requirements

@(
 @{
 URI = 'http://blogs.technet.com/cfs-filesystemfile.ashx/__key/telligent-evolution-components-attachments/01-10474-00-00-03-65-09-88/NanoServer.ps1';
 SHA1 = '27C00A02B49F3565783051B95D82498F17F74D57' ;
 },
 @{
 URI = 'https://gallery.technet.microsoft.com/scriptcenter/Convert-WindowsImageps1-0fe23a8f/file/59237/7/Convert-WindowsImage.ps1';
 SHA1 = '4B91A8ED09BD1E9DB5C63C8F63BB2BA83567917C' ;
 }
) | ForEach-Object -Process {
     $f = ([system.uri]$($_.URI)).Segments[-1] ;
     $o = (Join-Path ~/Downloads -ChildPath $f) ;
     if(-not((Get-FileHash -Path $o -Algorithm SHA1 -ErrorAction SilentlyContinue).Hash -eq $_.SHA1)) {
         try {
            $null = Invoke-WebRequest -Uri $($_.URI) -OutFile $o -ErrorAction Stop
            Unblock-File $o -ErrorAction Stop
            Write-Information -Msg "Successfully downloaded the correct version of $f file" -InfA 2
         } catch {
            Write-Warning -Message "There was a problem downloading the $f file"
         }
     } else {
         Write-Information -Msg "Successfully found the correct version of $f file" -InfA 2
     }
}


# Dot-sourcing functions inside a script
. ~/Downloads/Convert-WindowsImage.ps1
# Fix hard-coded path in the script
(Get-Content -Path ~/Downloads/NanoServer.ps1 -ReadCount 1 -ErrorAction Stop) -replace
[regex]::Escape('. .\Convert-WindowsImage.ps1'),"" |
Set-Content -Path ~/Downloads/NanoServer.ps1 -Encoding UTF8 -ErrorAction Stop

# Load the modified version
. ~/Downloads/NanoServer.ps1

Step 2

To provision the Domain Controller, I’ll use 3 techniques: the post-installation script setupcomplete.cmd that runs at the end of the specialize phase, the unattend.xml file, and PowerShell Desired Configuration to achieve the equivalent of a DCPromo. The main idea here is to move all artifacts (DSC modules, boot.wim, the prepared nano.vhd file…) required for configuring both the Domain Controller and WDS (Windows Deployment Services) into the VHD of the domain controller.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Mount ISO
Mount-DiskImage -ImagePath $iso -StorageType ISO -Access ReadOnly -PassThru
$dl = (Get-DiskImage -ImagePath $iso | Get-Volume).DriveLetter

# Define VM Name
$VM = "DC1-test"

# Set parent VHD
$ServerVHD = (Join-Path -Path ((Get-VMHost).VirtualHardDiskPath) -ChildPath "$VM.vhd")

# Create parent VHD
# Convert the WIM file to a VHD using the loaded Convert-WindowsImage function

if (-not(Test-Path -Path $ServerVHD -PathType Leaf)) {
    Convert-WindowsImage -Sourcepath  "$($dl):\sources\install.wim" `
    -VHD $ServerVHD `
    -VHDformat VHD -Edition "Windows Server 2012 R2 SERVERSTANDARD" `
    -VHDPartitionStyle MBR -Verbose:$true
}
Write-Information -Msg "Created parent VHD: size = $('{0:N2} GB' -f ((Get-Item $ServerVHD).Length/1GB))" -InfA 2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Create child VHD
$cvp = (Join-Path -Path ((Get-VMHost).VirtualHardDiskPath) -ChildPath "$VM-child.vhd")
$childVHD = New-VHD -Path $cvp -ParentPath $ServerVHD -Differencing

# Create a VM Gen 1
New-VM -Name $VM -MemoryStartupBytes 2048MB -NoVHD -SwitchName Internal-Test -Generation 1

# Attach disk
Get-VM $VM | Add-VMHardDiskDrive -Path $childVHD.Path

# Increase processor count for DC
Get-VM $VM | Set-VMProcessor -Count 2

# Mount the VHD
$cm = Mount-VHD -Path $childVHD.Path -Passthru
$cml = (Get-Disk $cm.DiskNumber | Get-Partition | Where DriveLetter | Select -First 1).DriveLetter

# Prepare a Nano VHD with the new script
$bdir = Join-Path (Split-Path $iso -Parent) -ChildPath "Base"
if (-not(Test-Path -Path $bdir -PathType Container)) {
    mkdir $bdir
}

$admincred = Get-Credential -Message 'Admin password of your Nano image' -UserName 'Administrator'
$nnHT = @{
   ComputerName = 'Nano-PXE' ;
   MediaPath = "$($dl):\" ;
   BasePath = $bdir ;              # The location for the copy of the source media
   TargetPath = "$bdir\Target" ;   # The location of the final, modified image
   Language = 'en-US' ;            # The language locale of the packages
   GuestDrivers = $true ;          # Add the Guest Drivers package (enables integration of Nano Server with Hyper-V when running as a guest).
   EnableIPDisplayOnBoot = $true ; # Configures the image to show the output of 'ipconfig' on every boot
   AdministratorPassword =  $admincred.Password ;
}

New-NanoServerImage @nnHT

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# Setupcomplete.cmd file

$s = @'
@echo off
:: Define a static IP for the DC
netsh int ip set address name="Ethernet" source=static address=10.0.0.10/24 gateway=10.0.0.1
:: Configure the DNS client
netsh dns set dnsservers name="Ethernet" source=static address=10.0.0.10 validate=no
'@

mkdir "$($cml):\Windows\Setup\Scripts"
$s | Out-File -FilePath  "$($cml):\Windows\Setup\Scripts\setupcomplete.cmd" -Encoding ASCII

# Unattend.xml

$unattendDC1 = @'
<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">
<UserAccounts>
<AdministratorPassword>
<Value>UABAAHMAcwB3ADAAcgBkAEEAZABtAGkAbgBpAHMAdAByAGEAdABvAHIAUABhAHMAcwB3AG8AcgBkAA==</Value>
<PlainText&gt;false&lt;/PlainText>
</AdministratorPassword>
</UserAccounts>
<RegisteredOwner>Tuva user</RegisteredOwner>
<RegisteredOrganization&gt;NanoRocks&lt;/RegisteredOrganization>
</component>
<component name="Microsoft-Windows-International-Core" 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">
<SystemLocale&gt;en-US&lt;/SystemLocale>
<InputLocale&gt;0409:0000040c&lt;/InputLocale>
<UILanguage&gt;en-US&lt;/UILanguage>
<UserLocale&gt;en-US&lt;/UserLocale>
</component>
</settings>
<settings pass="specialize">
<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">
<ComputerName&gt;DC1-test&lt;/ComputerName>
</component>
<component name="Microsoft-Windows-DNS-Client" 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">
<DNSSuffixSearchOrder>
<DomainName wcm:action="add" wcm:keyValue="1"&gt;10.0.0.10&lt;/DomainName>
</DNSSuffixSearchOrder>
</component>
<component name="Microsoft-Windows-UnattendedJoin" 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">
<Identification>
<JoinWorkgroup>test.local&lt;/JoinWorkgroup>
</Identification>
</component>
</settings>
<cpi:offlineImage cpi:source="wim:c:/iso.6687981/sources/install.wim#Windows Server 2012 R2 SERVERSTANDARDCORE" xmlns:cpi="urn:schemas-microsoft-com:cpi" />
</unattend>
'@

$unattendDC1 | Out-File -FilePath "$($cml):\Unattend.xml" -Encoding UTF8

# Get required DSC resource

if (-not (Get-Module -ListAvailable -Name xActiveDirectory)) {
    Find-Module -Name xActiveDirectory -Repository PSGallery | Install-Module -Verbose
}

# Define environment
$ConfigData = @{
   AllNodes = @(
     @{
        NodeName = 'localhost';
        PSDscAllowPlainTextPassword = $true;
        RequiredFeatures = @(
          @{ Name = 'DHCP'}
          @{ Name = 'DNS'}
          @{ Name = 'WDS'}
          @{ Name = 'RSAT-DHCP'}
          @{ Name = 'RSAT-DNS-Server'}
          @{ Name = 'WDS-AdminPack'}
       )

   DCAdminPassword = New-Object pscredential -ArgumentList 'nanorocks\administrator',
   (ConvertTo-SecureString -String 'P@ssw0rd' -Force -AsPlainText)
   SafeAdminPassword = New-Object pscredential -ArgumentList 'Password Only',
   (ConvertTo-SecureString -String 'Azerty@123' -Force -AsPlainText)
}

  )
}

# DSC config

Configuration DCConfig {
     Param()
     Import-DscResource -ModuleName xActiveDirectory
     Node localhost {
        LocalConfigurationManager {
           RebootNodeIfNeeded = $true;
        }
	WindowsFeature ADDS {
       Name = 'AD-Domain-Services';
       Ensure = 'Present';
    }

foreach ($f in $Node.RequiredFeatures)
{
    WindowsFeature $f.Name
    {
         Name = $f.Name ;
         Ensure = 'Present';
    }
 }

 xADDomain DSDC1 {
    DomainName = 'nanorocks.local';
    DomainAdministratorCredential = $Node.DCAdminPassword
    SafemodeAdministratorPassword = $Node.SafeAdminPassword
    DependsOn = '[WindowsFeature]ADDS';
 }

}
}

# Compile config into MOF file

if (-not(Test-Path -Path ~/Documents/DSC) ){ mkdir ~/Documents/DSC }
DCConfig -outputPath ~/Documents/DSC -ConfigurationData $ConfigData

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Copy DSC resource
$cHT = @{
    Path = 'C:\Program Files\WindowsPowerShell\Modules\xActiveDirectory';
    Destination = "$($cml):\Program Files\WindowsPowerShell\Modules\xActiveDirectory"
}

Copy-Item @cHT -Recurse -Force
# Copy DSC config
Copy-Item -Path ~/documents/DSC/*.mof -Destination "$($cml):\Users\Public\Documents"
# Copy original boot image from ISO
Copy-Item -Path "$($dl):\Sources\boot.wim" -Destination "$($cml):\Users\Public\Documents"
# Copy prepared Nano.vhd
Copy-Item -Path "$bdir\Target\*.VHD" -Destination "$($cml):\Users\Public\Documents"
# Unmount ISO file
Get-DiskImage -ImagePath $iso | Dismount-DiskImage
# Unmount VHD
Dismount-VHD -Path $childVHD.Path
Start-Vm -VMName $vm

After a few minutes, the operating system of the Domain Controller is ready.

Step 3

Let’s promote it as DC with the DSC (Desired State Configuration)

1
2
3
4
5
6
# DCPromo over PowerShell Direct
Invoke-Command -VMName $VM -Credential (Get-Credential 'test.local\administrator') -ScriptBlock {
    Set-DscLocalConfigurationManager C:\Users\Public\Documents
    Start-DscConfiguration C:\Users\Public\Documents -Verbose -Wait
    exit
}

As I’m on a Windows 10 Hyper-V, I can leverage PowerShell Direct recently introduced, so that I don’t rely on the network stack.

Once the DC has rebooted, I can start configuring the features I provisioned:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Post-install
Invoke-Command -VMName $VM -Credential (Get-Credential 'nanorocks\administrator') -ScriptBlock {
    # DHCP configuration

    # Authorize

    if (-not(Get-DhcpServerInDC | Where DnsName -eq "$($env:computername).$($env:USERDNSDOMAIN)")) {
        Add-DhcpServerInDC
    } else {
        Get-DhcpServerInDC
    }

    # Scope
    Add-DhcpServerv4Scope -StartRange 10.0.0.20 -EndRange 10.0.0.100 -Name "Nano scope" -State Active -SubnetMask 255.255.255.0

    # Activate (done with Add-DhcpServerv4Scope -State param
    # WDS
    mkdir C:\RemoteInstall
    wdsutil /verbose /progress /initialize-server /RemInst:c:\RemoteInstall # /Authorize
    wdsutil /start-server
    wdsutil /verbose /progress /set-server /AnswerClients:ALL
    Import-WdsBootImage -Path C:\Users\Public\Documents\boot.wim
    dir C:\Users\Public\Documents\*.vhd | Import-WdsInstallImage
}

Step 4

Let’s now create a new VM. To be able to boot over PXE on a Generation 1 virtual machine its network adapter should be a legacy network card.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Create test VM Generation 1 and add legacy network card for PXE boot
$testVM = 'Nano-test-pxe'
New-VHD -Path (Join-Path -Path ((Get-VMHost).VirtualHardDiskPath) -ChildPath "$($testVM).vhdx") -Dynamic -SizeBytes 127GB
New-VM -VMName $testVM -Generation 1 -MemoryStartupBytes 1024MB -NoVHD -SwitchName Internal-test |
Remove-VMNetworkAdapter
Get-VM -VMName $testVM |
Add-VMNetworkAdapter -IsLegacy:$true -SwitchName 'Internal-test'
Get-VM -VMName $testVM |
Add-VMHardDiskDrive -Path  (Join-Path -Path ((Get-VMHost).VirtualHardDiskPath) -ChildPath "$($testVM).vhdx")
Start-VM -VMName $testVM

Step 5

Press F12 to PXE boot.

Share on: