Script: Image Factory for Hyper-V

Update 7/20/2015: This script is now available on GitHub.  Please go to https://github.com/BenjaminArmstrong/Hyper-V-PowerShell/tree/master/Image-Factory to get the latest version, and to contribute any changes and bug fixes.

Summer has come to Seattle - which means that it is time for me to get going on some overdue Summer projects.

The first one - that I have been working on for the last two weeks attempts to solve a simple problem:

"Why do I never have an updated Windows image handy?"

Anyone who has spent time with virtual machines understands this problem.  It is far more efficient to install and update Windows once, and then copy the virtual hard disk each time you create a virtual machine, than it is to install Windows each time.  The problem is that if you do this - you find that your original base image soon gets out of date, and you can either choose to slow down deployment times by applying updates - or to be insecure.

Unfortunately; many of us just end up running without the latest patches installed.

So I sat down to solve this problem.  My goal was to come up with a solution where I could have a set of Windows virtual hard disks that were always up-to-date; with zero ongoing maintenance from me.  The result is something that I call the Image factory.

This is essentially a PowerShell script that creates and maintains a set of Windows virtual hard disks for me that are always up to date.  To make this work - I am using some tools / information provided by others:

But what does this script actually do?  Well, it uses the following logic:

 

The great thing about this approach is that it simultaneously ensures that my images are always up-to-date - without wasting time reinstalling Windows unnecessarily, and without downloading duplicated updates.  The result is that, after the initial setup, it can update all of my images in under an hour.  This in turn means that it is something that I can schedule to run each night.

If you do not want to read over all the code - and just want to run it; some key details to know:

  • You *have* to fill out the variables at the top of the script appropriately for your environment.
  • I have tested this script for Windows Server 2012, 2012 R2, Windows 8, Windows 8.1; Generation 1 and Generation 2; 32-bit and 64-bit; core, full and professional SKUs.  I suspect that the script will need modification to work with Windows Server 2008 R2 / Windows 7 - but it should be possible.
    • Note - If you do make this script work for those OSes, shoot me a note with the changes needed; and I will update this post.
  • In the script I am passing in .WIM files for the install images - you can also use .ISO files with no modification of the script necessary.
  • The script is a bit sensitive about file locations (I know it will break if the bases and share directory are not created ahead of time).  For reference, here is the exact file and directory structure that I have when starting a clean run:

Directory of C:\ImageFactory

<DIR> ISOs
<DIR> Resources
<DIR> Share
<DIR> bases
Convert-WindowsImage.ps1
Factory.ps1

Directory of C:\ImageFactory\bases

0 File(s)            

Directory of C:\ImageFactory\ISOs

en_windows_8_1_x64_dvd_2707217.wim
en_windows_8_1_x86_dvd_2707392.wim
en_windows_8_x64_dvd_915440.wim
en_windows_8_x86_dvd_915417.wim
en_windows_server_2012_r2_x64_dvd_2707946.wim
en_windows_server_2012_x64_dvd_915478.wim

Directory of C:\ImageFactory\Resources

<DIR> Bits

Directory of C:\ImageFactory\Resources\Bits

<DIR> PSWindowsUpdate

Directory of C:\ImageFactory\Resources\Bits\PSWindowsUpdate

Add-WUOfflineSync.ps1
Add-WUServiceManager.ps1
Get-WUHistory.ps1
Get-WUInstall.ps1
Get-WUInstallerStatus.ps1
Get-WUList.ps1
Get-WURebootStatus.ps1
Get-WUServiceManager.ps1
Get-WUUninstall.ps1
Hide-WUUpdate.ps1
Invoke-WUInstall.ps1
PSWindowsUpdate.Format.ps1xml
PSWindowsUpdate.psd1
PSWindowsUpdate.psm1
Remove-WUOfflineSync.ps1
Remove-WUServiceManager.ps1
Update-WUModule.ps1

Directory of C:\ImageFactory\Share

0 File(s) 

Before jumping into the code - let me give you a short code analysis:

  • You need to update all the variables at the top with the right paths, passwords, product keys and location of WMI / ISO files.
  • UnattendSource -  is the XML template that I modify for all unattended files.
  • CSVLogger - is a function that creates / updates a CSV file in the Share directory - so you can easily tell when the factory last checked for updates, and when it last updated images.
  • Logger -  is the common function I use for outputting logging messages.  If you want to change the way logging is done - this is the only place in the script that I output any information.
  • cleanupFile -  this is a simple little function that checks if a file exists, and deletes it if it does.  I do this frequently, so it made sense to put it in a common routine
  • GetUnattendChunk - this is just some minor code clean up, and wraps a bit of clunky XML parsing in a nicer function.  I only call this from makeUnattendFile.
  • makeUnattendFile -  is a function that performs any modifications needed to the unnattend template to create a real unattend file. 
  • createRunAndWaitVM - A common pattern that I have is to create a virtual machine, start it, and then wait for it to shut itself down.  That is what this function does. 
  • MountVHDandRunBlock - this is a handy helper function that does what it says.  It takes a VHDX path and a script block, then it mounts the VHDX and runs the script block.
  • updateCheckScriptBlocksysprepScriptBlock and postSysprepScriptBlock- a set of script blocks that I turn into a PS1 files which are then injected into a virtual hard disk at the right point in time.
  • RunTheFactory - this is the primary function that co-ordinates all the work

With all that said - here is the code (note - it is also attached as a .zip at the bottom of this post):

$workingDir = "C:\ImageFactory"

$logFile = "$($workingDir)\Share\Details.csv"

$factoryVMName = "Factory VM"

$virtualSwitchName = "Virtual Switch"

$ResourceDirectory = "$($workingDir)\Resources\Bits"

$Organization = "The Power Elite"

$Owner = "Ben Armstrong"

$Timezone = "Pacific Standard Time"

$adminPassword = "P@ssw0rd"

$userPassword = "P@ssw0rd"

 

# Keys

$Windows81Key = "..."

$Windows2012R2Key = "..."

$Windows8Key = "..."

$Windows2012Key = "..."

 

# ISOs / WIMs

$2012Image = "$($workingDir)\ISOs\en_windows_server_2012_x64_dvd_915478.wim"

$2012R2Image = "$($workingDir)\ISOs\en_windows_server_2012_r2_x64_dvd_2707946.wim"

$8x86Image = "$($workingDir)\ISOs\en_windows_8_x86_dvd_915417.wim"

$8x64Image = "$($workingDir)\ISOs\en_windows_8_x64_dvd_915440.wim"

$81x86Image = "$($workingDir)\ISOs\en_windows_8_1_x86_dvd_2707392.wim"

$81x64Image = "$($workingDir)\ISOs\en_windows_8_1_x64_dvd_2707217.wim"

 

$startTime = get-date

### Load Convert-WindowsImage

. "$($workingDir)\Convert-WindowsImage.ps1" 

### Sysprep unattend XML

$unattendSource = [xml]@"

<?xml version="1.0" encoding="utf-8"?>

<unattend xmlns="urn:schemas-microsoft-com:unattend">

    <servicing></servicing>

    <settings pass="specialize">

        <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="https://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance">

            <ComputerName>*</ComputerName>

            <ProductKey>Key</ProductKey>

        <RegisteredOrganization>Organization</RegisteredOrganization>

            <RegisteredOwner>Owner</RegisteredOwner>

            <TimeZone>TZ</TimeZone>

        </component>

        <component name="Microsoft-Windows-TerminalServices-LocalSessionManager" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="https://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance">

             <fDenyTSConnections>false</fDenyTSConnections>

         </component>

         <component name="Microsoft-Windows-TerminalServices-RDP-WinStationExtensions" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="https://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance">

             <UserAuthentication>0</UserAuthentication>

         </component>

         <component name="Networking-MPSSVC-Svc" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="https://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance">

             <FirewallGroups>

                 <FirewallGroup wcm:action="add" wcm:keyValue="RemoteDesktop">

                     <Active>true</Active>

                     <Profile>all</Profile>

                     <Group>@FirewallAPI.dll,-28752</Group>

                 </FirewallGroup>

             </FirewallGroups>

         </component>

    </settings>

    <settings pass="oobeSystem">

        <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="https://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance">

            <OOBE>

             <HideEULAPage>true</HideEULAPage>

                <HideLocalAccountScreen>true</HideLocalAccountScreen>

                <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>

                <NetworkLocation>Work</NetworkLocation>

                <ProtectYourPC>1</ProtectYourPC>

            </OOBE>

            <UserAccounts>

                <AdministratorPassword>

                    <Value>password</Value>

                    <PlainText>True</PlainText>

                </AdministratorPassword>

        <LocalAccounts>

                   <LocalAccount wcm:action="add">

                       <Password>

                           <Value>password</Value>

                           <PlainText>True</PlainText>

                       </Password>

      <DisplayName>Demo</DisplayName>

                       <Group>Administrators</Group>

                       <Name>demo</Name>

                   </LocalAccount>

               </LocalAccounts>

            </UserAccounts>

            <AutoLogon>

               <Password>

                  <Value>password</Value>

               </Password>

               <Enabled>true</Enabled>

               <LogonCount>1000</LogonCount>

               <Username>Administrator</Username>

   </AutoLogon>

             <LogonCommands>

                 <AsynchronousCommand wcm:action="add">

                     <CommandLine>%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell -NoLogo -NonInteractive -ExecutionPolicy Unrestricted -File %SystemDrive%\Bits\Logon.ps1</CommandLine>

                     <Order>1</Order>

                 </AsynchronousCommand>

             </LogonCommands>

        </component>

        <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="https://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance">

            <InputLocale>en-us</InputLocale>

   <SystemLocale>en-us</SystemLocale>

            <UILanguage>en-us</UILanguage>

            <UILanguageFallback>en-us</UILanguageFallback>

            <UserLocale>en-us</UserLocale>

        </component>

    </settings>

</unattend>

"@

 

function CSVLogger ([string]$vhd, [switch]$sysprepped) {

 

   $createLogFile = $false

   $entryExists = $false

   $logCsv = @()

   $newEntry = $null

 

   # Check if the log file exists

   if (!(test-path $logFile))

      {$createLogFile = $true}

   else

      {$logCsv = import-csv $logFile

       if (($logCsv.Image -eq $null) -or `

           ($logCsv.Created -eq $null) -or `

           ($logCsv.Sysprepped -eq $null) -or `

           ($logCsv.Checked -eq $null))

           {# Something is wrong with the log file

        cleanupFile $logFile

            $createLogFile = $true}

            }

 

   if ($createLogFile) {$logCsv = @()} else {$logCsv = import-csv $logFile}

 

   # If we find an entry for the VHD, update it

   foreach ($entry in $logCsv)

      { if ($entry.Image -eq $vhd)

        {$entryExists = $true

         $entry.Checked = ((get-Date).ToShortDateString() + "::" + (Get-Date).ToShortTimeString())

         if ($sysprepped) {$entry.Sysprepped = ((get-Date).ToShortDateString() + "::" + (Get-Date).ToShortTimeString())}

        }

      }

 

   # if no entry is found, create a new one

   If (!$entryExists)

      {$newEntry = New-Object PSObject -Property @{Image=$vhd; `

                                                   Created=((get-Date).ToShortDateString() + "::" + (Get-Date).ToShortTimeString()); `

                                                   Sysprepped=((get-Date).ToShortDateString() + "::" + (Get-Date).ToShortTimeString()); `

                                                   Checked=((get-Date).ToShortDateString() + "::" + (Get-Date).ToShortTimeString())}}

 

   # Write out the CSV file

   $logCsv | Export-CSV $logFile -notype

   if (!($newEntry -eq $null)) {$newEntry | Export-CSV $logFile -notype -Append}

  

}

 

function Logger ([string]$systemName, [string]$message)

    {# Function for displaying formatted log messages. Also displays time in minutes since the script was started

     write-host (Get-Date).ToShortTimeString() -ForegroundColor Cyan -NoNewline

     write-host " - [" -ForegroundColor White -NoNewline

     write-host $systemName -ForegroundColor Yellow -NoNewline

     write-Host "]::$($message)" -ForegroundColor White}

 

# Helper function for no error file cleanup

Function cleanupFile ([string]$file) {if (test-path $file) {Remove-Item $file}}

 

function GetUnattendChunk ([string]$pass, [string]$component, [xml]$unattend)

    {# Helper function that returns one component chunk from the Unattend XML data structure

     return $Unattend.unattend.settings | ? pass -eq $pass `

                                        | select -ExpandProperty component `

                                        | ? name -eq $component}

 

function makeUnattendFile ([string]$key, [string]$logonCount, [string]$filePath, [bool]$desktop = $false, [bool]$is32bit = $false)

    {# Composes unattend file and writes it to the specified filepath

    

     # Reload template - clone is necessary as PowerShell thinks this is a "complex" object

     $unattend = $unattendSource.Clone()

    

     # Customize unattend XML

     GetUnattendChunk "specialize" "Microsoft-Windows-Shell-Setup" $unattend | %{$_.ProductKey = $key}

     GetUnattendChunk "specialize" "Microsoft-Windows-Shell-Setup" $unattend | %{$_.RegisteredOrganization = $Organization}

     GetUnattendChunk "specialize" "Microsoft-Windows-Shell-Setup" $unattend | %{$_.RegisteredOwner = $Owner}

     GetUnattendChunk "specialize" "Microsoft-Windows-Shell-Setup" $unattend | %{$_.TimeZone = $Timezone}

     GetUnattendChunk "oobeSystem" "Microsoft-Windows-Shell-Setup" $unattend | %{$_.UserAccounts.AdministratorPassword.Value = $adminPassword}

     GetUnattendChunk "oobeSystem" "Microsoft-Windows-Shell-Setup" $unattend | %{$_.AutoLogon.Password.Value = $adminPassword}

     GetUnattendChunk "oobeSystem" "Microsoft-Windows-Shell-Setup" $unattend | %{$_.AutoLogon.LogonCount = $logonCount}

     if ($desktop)

         {

         GetUnattendChunk "oobeSystem" "Microsoft-Windows-Shell-Setup" $unattend | %{$_.UserAccounts.LocalAccounts.LocalAccount.Password.Value = $userPassword}

         }

     else

         {# Desktop needs a user other than "Administrator" to be present

          # This will remove the creation of the other user for server images

          $ns = New-Object System.Xml.XmlNamespaceManager($unattend.NameTable)

          $ns.AddNamespace("ns", $unattend.DocumentElement.NamespaceURI)

          $node = $unattend.SelectSingleNode("//ns:LocalAccounts", $ns)

          $node.ParentNode.RemoveChild($node) | Out-Null}

    

     if ($is32bit) {$unattend.InnerXml = $unattend.InnerXml.Replace('processorArchitecture="amd64"', 'processorArchitecture="x86"')}

 

     # Write it out to disk

     cleanupFile $filePath; $Unattend.Save($filePath)}

 

Function createRunAndWaitVM ([string]$vhd, [string]$gen) {

      # Function for whenever I have a VHD that is ready to run

      new-vm $factoryVMName -MemoryStartupBytes 2048mb -VHDPath $vhd -Generation $Gen `

                            -SwitchName $virtualSwitchName | Out-Null

      set-vm -Name $factoryVMName -ProcessorCount 2

      Start-VM $factoryVMName

 

      # Give the VM a moment to start before we start checking for it to stop

      Sleep -Seconds 10

 

      # Wait for the VM to be stopped for a good solid 5 seconds

     do {$state1 = (Get-VM | ? name -eq $factoryVMName).State; sleep -Seconds 5

          $state2 = (Get-VM | ? name -eq $factoryVMName).State; sleep -Seconds 5}

          until (($state1 -eq "Off") -and ($state2 -eq "Off"))

 

      # Clean up the VM

      Remove-VM $factoryVMName -Force}

 

Function MountVHDandRunBlock ([string]$vhd, [scriptblock]$block) {

      # This function mounts a VHD, runs a script block and unmounts the VHD.

      # Drive letter of the mounted VHD is stored in $driveLetter - can be used by script blocks

      $driveLetter = (Mount-VHD $vhd –passthru | Get-Disk | Get-Partition | Get-Volume).DriveLetter

      &$block

      dismount-vhd $vhd

 

      # Wait 2 seconds for activity to clean up

      Start-Sleep -Seconds 2

      }

 

### Update script block

$updateCheckScriptBlock = {

     # Clean up unattend file if it is there

     if (test-path "$ENV:SystemDrive\Unattend.xml") {Remove-Item -Force "$ENV:SystemDrive\Unattend.xml"}

    

     # Check to see if files need to be unblocked - if they do, do it and reboot

     if ((Get-ChildItem $env:SystemDrive\Bits\PSWindowsUpdate | `

          get-item -Stream "Zone.Identifier" -ErrorAction SilentlyContinue).Count -gt 0)

        {Get-ChildItem $env:SystemDrive\Bits\PSWindowsUpdate | Unblock-File

         invoke-expression 'shutdown -r -t 0'}

 

     # To get here - the files are unblocked

     import-module $env:SystemDrive\Bits\PSWindowsUpdate\PSWindowsUpdate

 

     # Check if any updates are needed - leave a marker if there are

     if ((Get-WUList).Count -gt 0)

          {if (!(test-path $env:SystemDrive\Bits\changesMade.txt))

          {New-Item $env:SystemDrive\Bits\changesMade.txt -type file}}

 

     # Apply all the updates

     Get-WUInstall -AcceptAll -IgnoreReboot -IgnoreUserInput -NotCategory "Language packs"

 

     # Reboot if needed - otherwise shutdown because we are done

     if (Get-WURebootStatus -Silent) {invoke-expression 'shutdown -r -t 0'}

     else {invoke-expression 'shutdown -s -t 0'}}

 

### Sysprep script block

$sysprepScriptBlock = {

     # Windows 10 issue - if the tiledatamodelsvc is running, it must be stopped first

     get-service | ? name -eq tiledatamodelsvc | stop-service

        

     $unattendedXmlPath = "$ENV:SystemDrive\Bits\Unattend.xml"

     & "$ENV:SystemRoot\System32\Sysprep\Sysprep.exe" `/generalize `/oobe `/shutdown `/unattend:"$unattendedXmlPath"}

 

### Post Sysprep script block

$postSysprepScriptBlock = {

     Remove-Item -Force "$ENV:SystemDrive\Unattend.xml"

     # Put any code you want to run Post sysprep here

     }

 

# This is the main function of this script

Function RunTheFactory([string]$FriendlyName, `

                       [string]$ISOFile, `

                       [string]$ProductKey, `

                       [string]$SKUEdition, `

                       [bool]$desktop = $false, `

                       [bool]$is32bit = $false, `

                       [switch]$Generation2) {

 

   logger $FriendlyName "Starting a new cycle!"

 

   # Setup a bunch of variables

   $sysprepNeeded = $true

   $baseVHD = "$($workingDir)\bases\$($FriendlyName)-base.vhdx"

   $updateVHD = "$($workingDir)\$($FriendlyName)-update.vhdx"

   $sysprepVHD = "$($workingDir)\$($FriendlyName)-sysprep.vhdx"

   $finalVHD = "$($workingDir)\share\$($FriendlyName).vhdx"

   if ($Generation2) {$VHDPartitionStyle = "GPT"; $Gen = 2} else {$VHDPartitionStyle = "MBR"; $Gen = 1}

 

   logger $FriendlyName "Checking for existing Factory VM"

 

   # Check if there is already a factory VM - and kill it if there is

   If ((Get-VM | ? name -eq $factoryVMName).Count -gt 0)

      {stop-vm $factoryVMName -TurnOff -Confirm:$false -Passthru | Remove-VM -Force}

 

   # Check for a base VHD

   if (!(test-path $baseVHD)) {

      # No base VHD - we need to create one

      logger $FriendlyName "No base VHD!"

 

      # Make unattend file

      logger $FriendlyName "Creating unattend file for base VHD"

      # Logon count is just "large number"

      makeUnattendFile -key $ProductKey -logonCount "1000" -filePath "$($workingDir)\unattend.xml" -desktop $desktop -is32bit $is32bit

     

      # Time to create the base VHD

      logger $FriendlyName "Create base VHD using Convert-WindowsImage.ps1"

      $ConvertCommand = "Convert-WindowsImage"

      $ConvertCommand = $ConvertCommand + " -SourcePath `"$ISOFile`" -VHDPath `"$baseVHD`""

      $ConvertCommand = $ConvertCommand + " -SizeBytes 80GB -VHDFormat VHDX -UnattendPath `"$($workingDir)\unattend.xml`""

      $ConvertCommand = $ConvertCommand + " -Edition $SKUEdition -VHDPartitionStyle $VHDPartitionStyle"

 

      Invoke-Expression "& $ConvertCommand"

 

      # Clean up unattend file - we don't need it any more

      logger $FriendlyName "Remove unattend file now that that is done"

      cleanupFile "$($workingDir)\unattend.xml"

 

      logger $FriendlyName "Mount VHD and copy bits in, also set startup file"

      MountVHDandRunBlock $baseVHD {

                          # Copy ResourceDirectory in

                          copy-item ($ResourceDirectory) -Destination ($driveLetter + ":\") -Recurse

                          # Create first logon script

                          $updateCheckScriptBlock | Out-String | Out-File -FilePath "$($driveLetter):\Bits\Logon.ps1" -Width 4096}

 

      logger $FriendlyName "Create virtual machine, start it and wait for it to stop..."

      createRunAndWaitVM $baseVHD $Gen

 

      # Remove Page file

      logger $FriendlyName "Removing the page file"

      MountVHDandRunBlock $baseVHD {attrib -s -h "$($driveLetter):\pagefile.sys"

                                    cleanupFile "$($driveLetter):\pagefile.sys"}

 

      # Compact the base file

      logger $FriendlyName "Compacting the base file"

      optimize-vhd -Path $baseVHD -Mode Full}

   else

      {# The base VHD existed - time to check if it needs an update

       logger $FriendlyName "Base VHD exists - need to check for updates"

 

       # create new diff to check for updates

       logger $FriendlyName "Create new differencing disk to check for updates"

       cleanupFile $updateVHD; new-vhd -Path $updateVHD -ParentPath $baseVHD | Out-Null

 

       logger $FriendlyName "Copy login file for update check, also make sure flag file is cleared"

       MountVHDandRunBlock $updateVHD {

                           # Make the UpdateCheck script the logon script, make sure update flag file is deleted before we start

               cleanupFile "$($driveLetter):\Bits\changesMade.txt"

                           cleanupFile "$($driveLetter):\Bits\Logon.ps1"

                           $updateCheckScriptBlock | Out-String | Out-File -FilePath "$($driveLetter):\Bits\Logon.ps1" -Width 4096}

 

       logger $FriendlyName "Create virtual machine, start it and wait for it to stop..."

       createRunAndWaitVM $updateVHD $Gen

 

       # Mount the VHD

       logger $FriendlyName "Mount the differencing disk"

       $driveLetter = (Mount-VHD $updateVHD –passthru | Get-Disk | Get-Partition | Get-Volume).DriveLetter

      

       # Check to see if changes were made

       logger $FriendlyName "Check to see if there were any updates"

       if (test-path "$($driveLetter):\Bits\changesMade.txt") {cleanupFile "$($driveLetter):\Bits\changesMade.txt"; logger $FriendlyName "Updates were found"}

       else {logger $FriendlyName "No updates were found"; $sysprepNeeded = $false}

 

       # Dismount

       logger $FriendlyName "Dismount the differencing disk"

       dismount-vhd $updateVHD

 

       # Wait 2 seconds for activity to clean up

      Start-Sleep -Seconds 2

 

       # If changes were made - merge them in. If not, throw it away

       if ($sysprepNeeded) {logger $FriendlyName "Merge the differencing disk"; Merge-VHD -Path $updateVHD -DestinationPath $baseVHD}

       else {logger $FriendlyName "Delete the differencing disk"; CSVLogger $finalVHD; cleanupFile $updateVHD}

       }

 

   # Final Check - if the final VHD is missing - we need to sysprep and make it

   if (!(test-path $finalVHD)) {$sysprepNeeded = $true}

 

   if ($sysprepNeeded)

      {# create new diff to sysprep

       logger $FriendlyName "Need to run Sysprep"

       logger $FriendlyName "Creating differencing disk"

       cleanupFile $sysprepVHD; new-vhd -Path $sysprepVHD -ParentPath $baseVHD | Out-Null

 

       logger $FriendlyName "Mount the differencing disk and copy in files"

       MountVHDandRunBlock $sysprepVHD {

                           # Make unattend file

                           makeUnattendFile -key $ProductKey -logonCount "1" -filePath "$($driveLetter):\Bits\unattend.xml" -desktop $desktop -is32bit $is32bit

                           # Make the logon script

                           cleanupFile "$($driveLetter):\Bits\Logon.ps1"

                           $sysprepScriptBlock | Out-String | Out-File -FilePath "$($driveLetter):\Bits\Logon.ps1" -Width 4096}

 

       logger $FriendlyName "Create virtual machine, start it and wait for it to stop..."

       createRunAndWaitVM $sysprepVHD $Gen

 

       logger $FriendlyName "Mount the differencing disk and cleanup files"

       MountVHDandRunBlock $sysprepVHD {

                           cleanupFile "$($driveLetter):\Bits\unattend.xml"

                           # Make the logon script

                           cleanupFile "$($driveLetter):\Bits\Logon.ps1"

                           $postSysprepScriptBlock | Out-String | Out-File -FilePath "$($driveLetter):\Bits\Logon.ps1" -Width 4096}

 

       # Remove Page file

       logger $FriendlyName "Removing the page file"

       MountVHDandRunBlock $sysprepVHD {attrib -s -h "$($driveLetter):\pagefile.sys"

                                        cleanupFile "$($driveLetter):\pagefile.sys"}

 

       # Produce the final disk

       cleanupFile $finalVHD

       logger $FriendlyName "Convert differencing disk into pristine base image"

       Convert-VHD -Path $sysprepVHD -DestinationPath $finalVHD -VHDType Dynamic

       logger $FriendlyName "Delete differencing disk"

       CSVLogger $finalVHD -sysprepped

       cleanupFile $sysprepVHD

      }

   }

 

RunTheFactory -FriendlyName "Windows Server 2012 R2 DataCenter with GUI" -ISOFile $2012R2Image -ProductKey $Windows2012R2Key -SKUEdition "ServerDataCenter"

RunTheFactory -FriendlyName "Windows Server 2012 R2 DataCenter Core" -ISOFile $2012R2Image -ProductKey $Windows2012R2Key -SKUEdition "ServerDataCenterCore"

RunTheFactory -FriendlyName "Windows Server 2012 R2 DataCenter with GUI - Gen 2" -ISOFile $2012R2Image -ProductKey $Windows2012R2Key -SKUEdition "ServerDataCenter" -Generation2

RunTheFactory -FriendlyName "Windows Server 2012 R2 DataCenter Core - Gen 2" -ISOFile $2012R2Image -ProductKey $Windows2012R2Key -SKUEdition "ServerDataCenterCore" -Generation2

RunTheFactory -FriendlyName "Windows Server 2012 DataCenter with GUI" -ISOFile $2012Image -ProductKey $Windows2012Key -SKUEdition "ServerDataCenter"

RunTheFactory -FriendlyName "Windows Server 2012 DataCenter Core" -ISOFile $2012Image -ProductKey $Windows2012Key -SKUEdition "ServerDataCenterCore"

RunTheFactory -FriendlyName "Windows Server 2012 DataCenter with GUI - Gen 2" -ISOFile $2012Image -ProductKey $Windows2012Key -SKUEdition "ServerDataCenter" -Generation2

RunTheFactory -FriendlyName "Windows Server 2012 DataCenter Core - Gen 2" -ISOFile $2012Image -ProductKey $Windows2012Key -SKUEdition "ServerDataCenterCore" -Generation2

RunTheFactory -FriendlyName "Windows 8.1 Professional" -ISOFile $81x64Image -ProductKey $Windows81Key -SKUEdition "Professional" -desktop $true

RunTheFactory -FriendlyName "Windows 8.1 Professional - Gen 2" -ISOFile $81x64Image -ProductKey $Windows81Key -SKUEdition "Professional" -Generation2 -desktop $true

RunTheFactory -FriendlyName "Windows 8.1 Professional - 32 bit" -ISOFile $81x86Image -ProductKey $Windows81Key -SKUEdition "Professional" -desktop $true -is32bit $true

RunTheFactory -FriendlyName "Windows 8 Professional" -ISOFile $8x64Image -ProductKey $Windows8Key -SKUEdition "Professional" -desktop $true

RunTheFactory -FriendlyName "Windows 8 Professional - Gen 2" -ISOFile $8x64Image -ProductKey $Windows8Key -SKUEdition "Professional" -Generation2 -desktop $true

RunTheFactory -FriendlyName "Windows 8 Professional - 32 bit" -ISOFile $8x86Image -ProductKey $Windows8Key -SKUEdition "Professional" -desktop $true -is32bit $true

 

I hope this is useful!

Cheers,
Ben

Comments

  • Anonymous
    June 16, 2015
    The comment has been removed

  • Anonymous
    June 16, 2015
    Thanks Florin! - I have updated the script & blog post.

  • Anonymous
    June 16, 2015
    Great post! You'll find later on that putting some try-catch error traps will benefit the logging in your script to help document what happened should any of the processes exit unexpectedly. Manning has a couple of good chapters on it in their PowerShell Deep Dive book - Ch. 11 and a bit in Ch. 9.

  • Anonymous
    June 17, 2015
    Justin - Where do you believe extra error handling is required?

  • Anonymous
    June 17, 2015
    Awesome script Ben, indeed! I've added/Replaced this small part in the UpdateCheckScripBlock to make it go for Microsoft Update if availble instead of the default Windows Update. That way, you can setup BaseImages which have extra software like Office and still get that updated automagically aswell in the same run. I'm still trying to get the same up and running in my Domain where I try to add WSUS to have them check and download even much faster aswell, but haven't quite figured that one out yet exactly. Add-WUServiceManager -ServiceID 7971f918-a847-4430-9279-4a52d1efe18d -Confirm:$false If ($?) { $SID="7971f918-a847-4430-9279-4a52d1efe18d" } else { $SID="9482f4b4-e343-43b6-b170-9a65bc822c77" }     # Check if any updates are needed - leave a marker if there are     if ((Get-WUList -ServiceID $SID -Verbose).Count -gt 0)          {if (!(test-path $env:SystemDriveBitschangesMade.txt))          {New-Item $env:SystemDriveBitschangesMade.txt -type file}}     # Apply all the updates     Get-WUInstall -ServiceID $SID -AcceptAll -IgnoreReboot -IgnoreUserInput -NotCategory "Language packs" -Verbose

  • Anonymous
    June 17, 2015
    Hi Ben, have you considered putting this in a source repository somewhere (eg. Github). Would make it easier to track updates etc. David

  • Anonymous
    June 17, 2015
    Great sommer Job Ben, thanks! Two questions about it: (1) Does your script work with foreign-language original images (like german) or is it language independent? (2) Is it possible to use the MS preinstalling OEM keys to create the golden master? This would be interesting, if one gives out the golden master for external use.

  • Anonymous
    June 18, 2015
    Agreed with he GITHub (or Similar) comment. Also why don't you have a set-up portion that created the directories for you if they don't exist test-path | new-tiem

  • Anonymous
    June 18, 2015
    The comment has been removed

  • Anonymous
    June 18, 2015
    Second question: how you make WIM files? You manually extract from original ISO or use specific tool?

  • Anonymous
    June 21, 2015
    The comment has been removed

  • Anonymous
    June 23, 2015
    The comment has been removed

  • Anonymous
    June 23, 2015
    Ah forgot to mention, I tried this on a 2008 R2 iso, but I assume it will be the same for 2012 R2. Changing the language to en-GB breaks sysprep. I think its because International-Core-WinPE needs to be added and set to en-GB also. So if someone figures out how to add the necessary XML in and still have it work with GetUnattendChunk... that would be awesome. Otherwise I'll try and do it when I can find some time.

  • Anonymous
    June 26, 2015
    Hi Ben, great script!!! In my case the sysprep is failing from within the script, if I run it manually all goes well. Still looking into why this is happening. One other thing I noticed, you're missing the BITS folder in the Remove-Item part (see below) Thanks again for this great script and I'm letting you know if I fix the Sysprep part.

Post Sysprep script block

$postSysprepScriptBlock = {     Remove-Item -Force "$ENV:SystemDriveUnattend.xml"     # Put any code you want to run Post sysprep here     }

  • Anonymous
    June 28, 2015
    @Wouter I'm guessing the reason for the Remote-Item is to make sure that the unattend file is cleaned up, as there is a chance that a previous delete can fail, leaving a unattend file on the system with potentially important information. Did you make any changes to the sysprep script or are you running it as is?

  • Anonymous
    June 30, 2015
    @AftabHussainUK I've ran it as is and with the extra directory in it. The Sysprep from base(after updates etc) to final error I couldn't reproduce. So I that's probably a user error :)

  • Anonymous
    July 02, 2015
    Ben, this looks really good, just wanted to say thanks - look forward to trying it.

  • Anonymous
    July 04, 2015
    @Wouter Glad you got it sorted, it took me a few runs to get it working for, although that was mainly because I didn't pay attention to filling in all the variables. Incidentally is anyone seeing a warning error when mount the vhdx files via Explorer, by this I mean double clicking on the vhdx file. It still mounts the vhdx file properly and the mounted vhdx is accessible via Explorer, but it gives an error when I mount it, see this image: http://imgur.com/OypC43z.

  • Anonymous
    July 08, 2015
    (This comment has been deleted per user request)

  • Anonymous
    July 20, 2015
    Thanks for the feedback everyone - this is now up on GitHub so that people can contribute changes

  • Anonymous
    August 26, 2015
    Is there a forum dedicated to this script and the entire process? I need some help regarding customization of this script and solutions to errors. Asking here is not appropriate, I believe.

  • Anonymous
    September 07, 2015
    For whatever reason the VM's don't shut down or update on their own. Any ideas what I can do to fix it?

  • Anonymous
    October 25, 2015
    Brilliant Ben, Ben, Brilliant! You have just made a chunk of my work so much easier.. wow.. Ok now I have a few questions about how to apply this to my environment.  I run a Hyper-V cluster with cluster shared volumes. Is the idea to run this on the C: or either of the Hyper-V hosts, and then just "COPY" the vhdx shared files to the CSV and appropriate folders for my VM? Secondly can someone help me understand the process and reason behind creating and deleting the differencing disk? And lastly how can I apply this "thinking" or "method" to use in desktop deployment with MDT..  In MDT I need to import a WIM file to create my Operating System/s.  Can I somehow use the shared VHDX files to create MDT OSes?

  • Anonymous
    November 18, 2015
    The comment has been removed

    • Anonymous
      May 23, 2016
      For those who are getting the error that Coolrunning82 and Konsti_ are or were seeing... Do the following:From the machine you are running Image Factory - Modify the properties of your PowerShell and ensure your font is selected ( I selected 14 ) and also that the width of the console is wide enough to prevent wrapping of the text.Hope this helps someone.
      • Anonymous
        July 11, 2016
        Hi Patrick,thanks a lot! Fixed my issue. Everything works now :)
      • Anonymous
        August 17, 2016
        On occasion I would still have issues with the final image coming up with the Logon.ps1 having a couple of lines truncated after the -Path. Which of course caused the script to always fail. With the help of one of my resident code monkeys we were able to come up with a slight tweak to the code.... Literal Here-Strings. That's it.### Sysprep script block$sysprepScriptBlock = @' # Literal Here-String 1 # Run pre-sysprep script if it exists if (Test-Path "$env:SystemDrive\Bits\PreSysprepScript.ps1") { & "$env:SystemDrive\Bits\PreSysprepScript.ps1" } # Remove Unattend entries from the autorun key if they exist foreach ($regvalue in (Get-Item -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run").Property) { if ($regvalue -like "Unattend*") { # could be multiple unattend* entries foreach ($unattendvalue in $regvalue) { Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -name $unattendvalue } } } $unattendedXmlPath = "$ENV:SystemDrive\Bits\Unattend.xml"; & "$ENV:SystemRoot\System32\Sysprep\Sysprep.exe" `/generalize `/oobe `/shutdown `/unattend:"$unattendedXmlPath";'@;### Post Sysprep script block$postSysprepScriptBlock = @' # Literal Here-String 2 # Remove Unattend entries from the autorun key if they exist foreach ($regvalue in (Get-Item -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run").Property) { if ($regvalue -like "Unattend*") { # could be multiple unattend* entries foreach ($unattendvalue in $regvalue) { Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -name $unattendvalue } } } # Run post-sysprep script if it exists if (Test-Path "$env:SystemDrive\Bits\PostSysprepScript.ps1") { & "$env:SystemDrive\Bits\PostSysprepScript.ps1" } # Clean up unattend file if it is there if (Test-Path "$ENV:SystemDrive\Unattend.xml") { Remove-Item -Force "$ENV:SystemDrive\Unattend.xml"; } # Clean up bits if(Test-Path "$ENV:SystemDrive\Bits") { Remove-Item -Force -Recurse "$ENV:SystemDrive\Bits"; } # Clean up temp if(Test-Path "$ENV:SystemDrive\Temp") { Remove-Item -Force -Recurse "$ENV:SystemDrive\Temp"; } # Remove Demo user $computer = $env:computername $user = "Demo" if ([ADSI]::Exists("WinNT://$computer/$user")) { [ADSI]$server = "WinNT://$computer" $server.delete("user",$user) } # Put any code you want to run Post sysprep here Invoke-Expression 'shutdown -r -t 0';'@;# This is the main function of this scriptfunction Start-ImageFactory
  • Anonymous
    February 10, 2016
    If you get errors in the powershellscript inside the VM try right-click, properties, unblock file on ALL ps1-scripts in the C:\ImageFactory\Resources folder before running factory.ps1

  • Anonymous
    October 25, 2016
    The comment has been removed

  • Anonymous
    March 28, 2017
    Hi there!How can I set a static ip address for Factory VM???