In several articles and other works, I make the claim that backing up a Hyper-V host is largely a waste of time. A separate practice is to maintain templates and other offline images of systems for easy deployment of new systems. What these two topics have in common is the need (or at least the desire) to keep the images current with Windows patches. It doesn’t help much to save ten or fifteen minutes deploying Windows from a template or ISO if you then need to spend two hours installing updates. In the past, we would have use “slipstreamed” ISOs. That’s no longer possible with modern iterations of Windows. However, what we lack now is not the capability to update these systems, but the proper tools. Of course, if you’re willing (and able) to spend a lot of money on System Center, that suite can help you a great deal. For the rest of us, we have to resort to other options, usually homegrown.
To address that problem, I have crafted a script that will automatically update both VHDX files and WIM files. If you’re thinking that this doesn’t apply to you because you only deploy from physical media, think again! The issue of publicly-available Windows Server ISOs never being updated by Microsoft is the primary driver behind the creation of this script.
Script Features
Scripts to update WIMs and VHDs are numerous, so I’d like to take a few moments to enumerate the features in this script, especially the parts that set it apart from others that I’ve found.
- Updates WIMs and VHDXs interchangeably
- Updates multiple images with a single execution
- Ability to scope updates; no more trying to apply Office 2013 patches to your Hyper-V Server image (unless that’s what you want to do; I’m not judging)
- Subsequent runs against the same image will not try to re-apply previously installed updates
- Designed to be run on a schedule, but can also be run interactively
- Can update every image in a multi-pack WIM (if that’s what you want)
Prerequisites
There are a few things that you’ll need to provide for this script to function.
- A willingness to put away your DVDs
- A USB flash device that is recognized on the physical systems that you use with a capacity of at least 8 GB (not applicable if you’re only here to update VHDXs)
- An installation of Windows Server Update Services (WSUS)
- Space to store WIMs and/or VHDXs (varies, but anywhere from 5 to 20GB per)
- Spare space for the update operation (varies depending on the total size of all updates to be applied, but usually no more than a few gigabytes per operation)
- On the system where you wish to run the script, the 2012 R2 or later Windows Server Update Services console must be installed. It includes the UpdateServices PowerShell module. For Windows 8.1+, download the Remote Server Administration Tools. I don’t believe that this module is available for Windows 7 or Server 2008 R2 or earlier, but it would be in the WSUS 3.0 SP2 download if it were. The following screenshot shows where the option appears on Windows 10. For Server, it is in the same location on the Features page of the Add Roles and Features wizard.
- PowerShell 4.0 or later on the system that will run the script.
Something I want to make clear right from the beginning is that I don’t know how to update a Windows ISO image or use this to create an ISO image that can then be burned to DVD. I have moved to USB flash deployments for any physical systems where PXE is not an option. DVD has had its day but it will soon be following the floppy into the museum. If you haven’t tried loading an operating system from a USB stick, today is a great day to learn.
Deploying a Physical Machine from a WIM and a USB Stick
If you’re only going to be using this script to update VHDX files, skip this entire section.
You may not have realized it, especially if you’re like me and have been around since the pre-WIM days, but any time you use a Microsoft-pressed DVD or a burned ISO to install Windows, you are deploying from a WIM. Check your installation media’s file structure for a file named install.wim in the Sources folder. That’s where your Windows installation comes from. The trick is to get that WIM updated with the latest patches before using it again. There are a few inefficiencies with the method that I’ve discovered, but it works perfectly without requiring any paid tools.
- Acquire an ISO of the Windows, Windows Server, or Hyper-V Server operating system. If you only have physical media to work from and you don’t already have a tool, I use ImgBurn. It’s tough to get to through all the adwalls but it’s free and does the job.
- Acquire a copy of the Windows USB/DVD Download Tool.
- Insert your USB stick into the computer. If it’s not empty, whatever is on it will be destroyed.
- Run the tool that you downloaded. It’s still branded as “Windows 7” but it will work with whatever Windows ISO you give it.
- Browse to the ISO image from step 1 that you want to convert for USB.
- Choose USB Device.
- Ensure that the correct USB device from step 3 is selected. Press Begin Copying.
- You will get a small popup box warning you that your device will be erased. Click Erase USB Device. You’ll get yet another dialog telling you the essentially the same thing that the previous dialog said. I guess they really want to make certain that no one can say they didn’t know that their drive was going to be erased. Click Yes.
- Wait for the process to complete.
- When it’s finished, you can just close the window or click Start Over to craft another USB drive.
- Copy the sourcesinstall.wim from the USB device to a location where it can be updated — I prefer having it on my WSUS host. Because every single Windows media uses the name install.wim, I would either set up a folder structure that clearly indicates exactly where the file came from or I would rename the file.
You’re now ready to begin. The procedure going forward will be:
- Update the WIM.
- Copy the WIM back to the USB device.
- Use the USB device to install.
If you have more images than USB keys, that’s a workable problem. You can always rebuild the USB device from the ISO image and then copy over the latest copy of the WIM. However, now that you’ve come this far, I strongly recommend that you research deployment from a WDS server with WIM. It’s not that tough and it’s nice to never worry about installation media.
Script Usage
This script is slow. Very slow. The strongest control on speed is your hardware, but it’s still going to be slow. Part of the delay is scanning for applicable updates, so I’ve set that so that the scan of the WSUS server only occurs once per iteration, no matter how many VHDXs/WIMs you specify to update. To make it even better, it will record which updates were successfully applied to the WIM. As long as you don’t move or rename the log, additional runs will skip updates that have already been applied. This means that the first run against any given image will likely take hours, but subsequent runs might only require minutes.
You can run the script by hand, if you wish. If you only update any given Windows Server 2012 R2 one time, that will literally save you at least a years’ worth of updates each time that you deploy from it. My recommendation is to schedule the update to run once a month over the weekend so that you’re always up-to-date. To make that easier, I’ll show you how to build a supporting script to call this one with your images.
There are two parameter sets. One is to run against a single image file, the other is for multiple image files. Because of the way that WIMs work, you can’t just supply a list of file names. The parameter sets are otherwise identical.
Single Image File Parameter Set
Update-WindowsImage.ps1 -Path <String> [-Index <Int32>] [-WsusServerName <String>] [-WsusServerPort <UInt16>] [-WsusUsesSSL] -WsusContentFolder <String> [-TargetProduct <String[]>] [-MinimumAgeInDays <UInt16>] [-OfflineMountFolder <String>] [-IgnoreDeclinedStatus] [<CommonParameters>]
Multiple Image File Parameter Set
Update-WindowsImage.ps1 -Images <Array> [-WsusServerName <String>] [-WsusServerPort <UInt16>] [-WsusUsesSSL] -WsusContentFolder <String> [-TargetProduct <String[]>] [-MinimumAgeInDays <UInt16>] [-OfflineMountFolder <String>] [-IgnoreDeclinedStatus] [<CommonParameters>]
There are only two required parameters: the image(s) and the WSUS system’s content folder. The hardest part is the image(s), so we’ll start there.
Specifying Image File(s)
The basic issue with specifying image files is that a single WIM can contain multiple images. If you’ve ever started an installation and been asked to choose between Standard and Datacenter and Standard Core and Datacenter Core or something similar, every single line that you see is a separate image in one WIM. When you update a WIM, you must select which image to work with. VHDX files, on the other hand, only have a single item so you don’t need to worry about specifying an index.
Specifying a Single VHDX
This is the easiest usage. Just use the full path of the VHDX with the WSUS content folder:
Update-WindowsImage.ps1 -Path D:Templatesw2k12r2template.vhdx -WsusContentFolder 'D:WSUSWsusContent'
This assumes that you are running the script locally on the WSUS server.
Specifying a Single WIM
You must specify the index of an image within a WIM to update. If you don’t know, or just want to update all of them, specify -1. Updating every image will take a very long time!
Update-WindowsImage.ps1 -Path D:FromISO2k12r2install.wim -Index -1 -WsusContentFolder 'D:WSUSWsusContent'
If you’d like to narrow it down to a specific image but you don’t know what image to choose, you can interactively and locally run the script and you’ll be prompted:
This list is pulled directly from Get-WindowsImage. You can look at the available indexes yourself in advance with Get-WindowsImage D:FromISO2k12r2install.wim. If you do not specify -1 or a valid index when running Update-WindowsImage either from a scheduled task or in a remote PowerShell session, the script will fail.
Specifying Multiple Target Images
In order to update multiple images at once, you must supply an array of hash tables. If you’re new to PowerShell, take heart; it sounds much worse than it is.
First, make an empty array:
$Images = @()
Then, make a hash table. This must have at least one component, a Path. For a WIM, it must also contain an Index. VHDX files can also have an index but they’ll be ignored.
$Image1 = @{'Path'='D:FromISOw2k12r2install.wim'; 'Index'='4')
Insert the hash table into the array:
$Images += $Image1
Finally, submit your array to the script:
Update-WindowsImage.ps1 -Images $Images -WsusContentFolder 'D:WSUSWSUSContent'
Easy, right? Now, let’s do a bunch in one shot:
$Images = @( @{'Path'='D:FromISOw2k12r2install.wim'; 'Index' = 1}, @('Path'='D:FromISOw2k12r2install.wim'; 'Index' = 2), @{'Path'='D:Templatesw2k12r2.vhdx'}, @{'Path'='D:FromISOhs2k12r2install.wim'; 'Index' = 1} ) Update-WindowsImage.ps1 -Images $Images -WsusContentFolder 'D:WSUSWsusContent'
Any image that can’t be found will simply be skipped. It will not impact the success or failure of the others.
Specifying the Target Product(s)
To reduce the amount of time spent attempting to apply patches, I added a filter for specific products. By default, the only scanned product is Windows Server 2012 R2 (which will include Hyper-V Server 2012 R2). You can specify what products to search for by using the TargetProduct parameter:
-TargetProduct 'Windows Server 2012 R2', 'Microsoft SQL Server 2014'
The items you enter here must match their names in WSUS verbatim or the updates will not be scanned (and there will be no error). To see that list, use Get-WsusProduct. Unfortunately, the PowerShell cmdlets for WSUS leave a great deal to be desired and there’s no simple way to narrow down which products that your host is receiving in synchronization.
Understanding how Available Updates will be Selected
I’ve never been the biggest fan of WSUS for a number of reasons, and you’re about to encounter one. I can easily determine if an update has been Approved in at least one place on the server and if it has been Declined in at least one place on the server. Finding out which computer groups that it has been Approved or Declined for is much harder. So, the default rule is: if an update has been approved on at least one group and has not been declined on any groups, it will be eligible. If you specify the IgnoreDeclinedStatus parameter, then the rule will change to: if an update has been approved on at least one group, it will be eligible. There is also a MinimumPatchAgeInDays parameter.
Other Parameters
Let’s step through the other, more self-explanatory parameters quickly:
- WsusServerName: this is the name (short or FQDN) or the IP address of the WSUS server to connect to. If not specified, the cmdlet will assume WSUS is running locally.
- WsusServerPort: the port that WSUS runs on. By default, this is 8530, because that’s the default WSUS port.
- WsusUsesSSL: this is a switch parameter. Include it if your WSUS server is using SSL. Leave it off otherwise.
- MinimumPatchAgeInDays: this is a numeric parameter that indicates the minimum number of days that a patch must have been on the WSUS server before it can be eligible for your images.
- OfflineMountFolder: by default, the script will create an Offline folder on the system’s system drive (usually C:) for its working space. If this folder already exists, it must be empty. The folder is not removed at the end of the cycle. Use this parameter to override the name of the folder.
Scripting the Script
My vision is that you’ll set this script to run on a schedule. To work with multiple items, I’d make a script that calls the update script. So, save something like the following and call it every Friday at 7 PM:
$Images = @( @{'Path'='D:FromISOw2k12r2install.wim'; 'Index' = 1}, @('Path'='D:FromISOw2k12r2install.wim'; 'Index' = 2), @{'Path'='D:Templatesw2k12r2.vhdx'}, @{'Path'='D:FromISOhs2k12r2install.wim'; 'Index' = 1} ) C:ScriptsUpdate-WindowsImage.ps1 -Images $Images -WsusContentFolder 'D:WSUSWsusContent'
Depending on your scripting skills, you could make this far more elaborate. Just remember that each image is going to take quite some time to update, especially on the first run.
The Script Source
As included here, you simply run the script on demand. If you’d like to dot-source it or use it in your profile, uncomment the function definition lines right after the help section and at the end. They are clearly marked.
<# .SYNOPSIS Updates an offline WIM or VHDX from WSUS contents. .DESCRIPTION Updates an offline WIM or VHDX from WSUS contents. Can update one or all indexes in a WIM. By default, stores a log file next to the file to be updated. On subsequent runs against that file, it will not apply any items previously applied. .PARAMETER Path The path to the WIM or VHDX to be updated. .PARAMETER Index For WIM files only, selects which contained image will be updated. Enter -1 to update all. If not specified in a non-interactive session, ALL will be updated. If not specified in an interactive session, you will be prompted. If supplied with a VHDX file, will be ignored. .PARAMETER Images An array of hash tables that contain the images to be updated. Entries must be in the format: @{'Path' = 'c:imagepathimagefilename'; 'Index' = 4 } The index item does not need to be present for .vhdx files and will be ignored. .PARAMETER WsusServerName A resolvable name or IP address of the computer that runs WSUS. Uses the local system by default. .PARAMETER WsusServerPort The port that WSUS responds on. Defaults to 8530. Ignored when WsusServerName is not specified. .PARAMETER $WsusUsesSSL Flag if you should connect to WSUS using SSL. Default is to not use SSL. Ignored when WsusServerName is not specified. .PARAMETER WsusContentFolder The path of the WSUS system's WsusContent folder. Must be resolvable and accessible from the location that the script is executed. .PARAMETER TargetProduct The target product(s) to limit available product updates to. Use Get-WsusProduct on your WSUS server for a list of available products. The default is 'Windows Server 2012 R2' (will also apply to Hyper-V Server 2012 R2). Use an empty string or array to select all products. WARNING: This will take an EXTREMELY long time if you have many products on your WSUS server. .PARAMETER MinimumPatchAgeInDays The minimum number of days since a patch appeared on the WSUS host before it can be applied. Default is 0. .PARAMETER OfflineMountFolder The temporary mount location for the WIM/VHDX. If this folder does not exist, it will be created. If this folder exists and is not empty, execution will halt. The location will not be removed at the end of execution, but it will be empty. The default is Offline on the system volume. .PARAMETER IgnoreDeclinedStatus If specified, updates that appear as both Approved and Declined will be applied (meaning the update is approved in at least one location even though it is declined in another). If not specified, an update that is declined anywhere on the WSUS host will be not be applied. .NOTES Written by Eric Siron (c) 2016 Altaro Software Version 1.6. October 29th, 2016 - 1.6 - ------- * If the target mount folder has remnants from a previous failed run, it is cleaned up. Suggested by commenter Steve. * Inserted more Verbose write points to aid in troubleshooting. * Minor bug fixes, typo corrections, and expansions to status displays. - 1.5 - ------- * Added verbosity to patch application. Use -Verbose. * Adjustment to log reset behavior between target files. - 1.4 - ------- * .a: Typos * Image logging mechanism reworked to include more information. - 1.3 - ------- * Patch log will no longer contain duplicates. * An empty patch list will bypass the mount/unmount process. * Clarified some error messages. * Discrepancy between documentation and configuration for "MinimumPatchAgeInDays". Documentation said default of 0, script said 30. Both are now 0. - 1.2 - ------- * Corrected behavior when a single VHDX is submitted (for real this time) * Adjusted matching pattern for previous patches - 1.1 - ------- * Corrected variable naming mismatch for MinimumPatchAgeInDays * Corrected behavior when a single VHDX is submitted .EXAMPLE Update-WindowsImage.ps1 -Path D:Templatesw2k12r2template.vhdx -WsusContentFolder 'D:WSUSWsusContent' Updates the specified VHDX using the local WSUS server. .EXAMPLE Update-WindowsImage.ps1 -Path D:FromISO2k12r2install.wim -Index -1 -WsusContentFolder 'D:WSUSWsusContent' Updates the first image within the specified WIM using the local WSUS server. .EXAMPLE $Images = @( @{'Path'='D:FromISOw2k12r2install.wim'; 'Index' = 1}, @('Path'='D:FromISOw2k12r2install.wim'; 'Index' = 2), @{'Path'='D:Templatesw2k12r2.vhdx'}, @{'Path'='D:FromISOhs2k12r2install.wim'; 'Index' = 1} ) Update-WindowsImages -Images $Images -WsusContentFolder 'D:WSUSWsusContent' Updates all of the specified images using the local WSUS server. .EXAMPLE Update-WindowsImage.ps1 -Path '\storage.domain.localTemplatesw2k12r2template.vhdx' -WsusServerName 'wsus.domain.local' -WsusContentFolder '\wsus.domain.locald$WSUSWsusContent' Updates the specified remote image using the specified remote WSUS server, which is running on port 8530. #> #requires -RunAsAdministrator #requires -Version 4 #requires -Modules Dism, UpdateServices #function Update-WindowsImage #Uncomment this line to use this script dot-sourced or in a profile. Also the next line and the very last line. #{ #uncomment this line to use this script dot-sourced or in a profile. Also the previous line and the very last line. [CmdletBinding(DefaultParameterSetName='Single Item')] Param( [Alias('ImagePath')] [ValidateNotNullOrEmpty()] [Parameter(Mandatory=$true, ParameterSetName='Single Item', Position=1)] [String]$Path, [Parameter(ParameterSetName='Single Item', Position=2)] [Int]$Index, [Parameter(Mandatory=$true, ParameterSetName='Multiple Items', Position=1)] [Array]$Images, [Parameter()] [String]$WsusServerName, [Alias('Port')] [Parameter()] [UInt16]$WsusServerPort = 8530, [Alias('SSL', 'WithSSL')] [Parameter()] [Switch]$WsusUsesSSL, [Parameter(Mandatory=$true)] [String]$WsusContentFolder, [Parameter()] [String[]]$TargetProduct = @('Windows Server 2012 R2'), [Parameter()] [UInt16]$MinimumPatchAgeInDays = 0, [Parameter()] [String]$OfflineMountFolder = "$env:SystemDriveOffline", [Parameter()] [Switch]$IgnoreDeclinedStatus ) Write-Progress -Activity 'Validating environment' -Status 'Checking image information' -PercentComplete 25 $ImageList = @() if($PSCmdlet.ParameterSetName -eq 'Single Item') { try { Write-Verbose -Message 'Locating specified image file' $WindowsImage = Get-WindowsImage -ImagePath $Path -ErrorAction Stop } catch { throw('Specified image file "{0}" is not valid' -f $Path) } if(Test-Path -Path $Path) { $SelectedIndexes = @() if($Path -imatch 'wim$') { if(-not $Index -or -not ($WindowsImage.ImageIndex -contains $Index)) { if([Environment]::UserInteractive) { $ValidOptions = @(-1) $CurrentSelection = -999 while($ValidOptions -notcontains $CurrentSelection) { Write-Host -Object 'You must specify an index in the image to apply updates to. Choose one of the following' -ForegroundColor Cyan -BackgroundColor DarkMagenta Write-Host -Object '-1: Update All (this will take an EXTREMELY long time' $WindowsImage | foreach { $ValidOptions += $_.ImageIndex Write-Host -Object "$($_.ImageIndex): $($_.ImageName)" } Write-Host $CurrentSelection = Read-Host -Prompt 'Enter a numerical selection from above list or [CTRL+C] to cancel.' if($CurrentSelection -eq -1) { foreach($Option in $ValidOptions) { if($Option -gt 0) { $SelectedIndexes += $Option } } } else { $SelectedIndexes = @($CurrentSelection) } } } else { throw('No index was selected for "{0}" or the index is invalid' -f $Path) } } else { $SelectedIndexes += $Index } } else { $SelectedIndexes += 1 } $SelectedIndexes | foreach { Write-Verbose ('Adding image file "{0}", index {1} to the patching list.' -f $Path, $_) $ImageList += @{'Path' = $Path; 'Index' = $_ } } } } else { foreach($SpecifiedImage in $Images) { try { $Index = 1 if($SpecifiedImage.Path -imatch 'wim$') { $Index = $SpecifiedImage.Index } $WindowsImage = Get-WindowsImage -ImagePath $SpecifiedImage.Path -Index $Index -ErrorAction Stop Write-Verbose ('Adding image file "{0}", index {1} to the patching list.' -f $SpecifiedImage.Path, $Index) $ImageList += @{'Path' = $SpecifiedImage.Path; 'Index' = $Index} } catch { Write-Warning -Message ('Invalid file({0}) or index ({1}) specified. This entry will be ignored.' -f $SpecifiedImage.Path, $SpecifiedImage.Index) } } } Write-Progress -Activity 'Validating environment' -Status 'Verifying WSUS server' -PercentComplete 50 $GetWsusServerParameters = @{} if(-not [String]::IsNullOrEmpty($WsusServerName)) { $GetWsusServerParameters.Add('Name', $WsusServerName) $GetWsusServerParameters.Add('PortNumber', $WsusServerPort) $GetWsusServerParameters.Add('UseSsl', $WsusUsesSSL) } try { $WsusServer = Get-WsusServer @GetWsusServerParameters -ErrorAction Stop } catch { throw("Unable to contact the specified WSUS host`r`n$($_.Message)") } Write-Progress -Activity 'Validating environment' -Status 'Verifying WSUS content folder' -PercentComplete 75 try { if(-not (Get-ChildItem -Path $WsusContentFolder -Directory -ErrorAction Stop | sort | foreach { if($_.Name -match '^[A-Z0-9]{2}$') { $true } } )) { throw('Folder exists but does not contain any of the expected content sub-folders.') } } catch { throw("Specified WSUS content folder cannot be reached or does not contain expected content files") } Write-Progress -Activity 'Validating environment' -Status 'Verifying offline mount folder' -PercentComplete 99 if(Test-Path -Path $OfflineMountFolder) { if(Get-ChildItem -Path $OfflineMountFolder) { Dismount-WindowsImage -Path $OfflineMountFolder -Discard -ErrorAction SilentlyContinue if(Get-ChildItem -Path $OfflineMountFolder) { throw("$OfflineMountFolder is not empty.") } } } else { try { New-Item -Path $OfflineMountFolder -ItemType Directory -ErrorAction Stop } catch { throw("Unable to locate or create $OfflineMountFolder") } } Write-Progress -Activity 'Validating environment' -Completed Write-Progress -Activity 'Loading updates' -Status 'Scanning for applicable updates' -PercentComplete -1 $WSUSUpdates = Get-WsusUpdate -UpdateServer $WsusServer -Approval Approved | where { -not $_.Update.IsSuperseded ` -and ($IgnoreDeclinedStatus -or -not $_.Update.IsDeclined) ` -and (Compare-Object -DifferenceObject $_.Products -ReferenceObject $TargetProduct -ExcludeDifferent -IncludeEqual) ` -and $_.Update.ArrivalDate.ToLocalTime().AddDays($MinimumPatchAgeInDays) -le [datetime]::Now } $UpdateFiles = @() $CurrentFile = 0 foreach ($WSUSUpdate in $WSUSUpdates) { $CurrentFile += 1 $CurrentFilePercent = 100 - ((($WSUSUpdates.Count - $CurrentFile) / $WSUSUpdates.Count) * 100) Write-Progress -Activity 'Loading updates' -Status 'Finding downloaded files for selected updates' -CurrentOperation "Checking $($WSUSUpdate.Update.Title)" -PercentComplete $CurrentFilePercent $WSUSUpdate.Update.GetInstallableItems().Files | foreach { if ($_.Type -eq [Microsoft.UpdateServices.Administration.FileType]::SelfContained -and ($_.FileUri -match '[cab|msu]$')) { $LocalFileName = ($_.FileUri -replace '.*/Content', $WsusContentFolder) -replace '/', '' if(Test-Path -Path $LocalFileName) { Write-Verbose ('Adding "{0}" to the list of available patches.' -f $WSUSUpdate.Update.Title) $UpdateFiles += @{'Path' = $LocalFileName; 'Title' = $WSUSUpdate.Update.Title } } } else { Write-Verbose -Message ('{0} not added to patches list. Files must end in .cab or .msu and must be of type "SelfContained"' -f $WSUSUpdate.Update.Title) Write-Verbose -Message ('|--> File name: {0}' -f $_.FileUri) Write-Verbose -Message ('---> Patch type: {0}' -f $_.Type.ToString()) } } } Write-Verbose -Message ('Eligible patches: {0}' -f $UpdateFiles.Count) Write-Progress -Activity 'Loading updates' -Completed foreach ($ImageToUpdate in $ImageList) { $TargetRoot = Split-Path -Path $ImageToUpdate.Path $TargetFileName = Split-Path -Path $ImageToUpdate.Path -Leaf $LogFile = Join-Path -Path $TargetRoot -ChildPath "$TargetFileName.wulog.txt" $CurrentImageLog = @() $PermanentLog = @() if(Test-Path -Path $LogFile) { $PermanentLog = Get-Content -Path $LogFile } try { $Test = $PermanentLog.Count } catch { $PermanentLog = @() } $PermanentLog += ('------- Patch Cycle Initiated {0} -------' -f (Get-Date)) try { # the Mount-WindowsImage cmdlet has its own progress display $OutNull = Mount-WindowsImage -ImagePath $ImageToUpdate.Path -Index $ImageToUpdate.Index -Path $OfflineMountFolder -ErrorAction Stop } catch { $CurrentMessage = "Could not mount $($ImageToUpdate.Path)`r`n$($_.Message)" Write-Error -Message $CurrentMessage $PermanentLog += $CurrentMessage break } if($UpdateFiles.Count) { $CurrentFile = 0 foreach($UpdateFile in $UpdateFiles) { $CurrentFile += 1 $CurrentFilePercent = 100 - ((($UpdateFiles.Count - $CurrentFile) / $UpdateFiles.Count) * 100) $CurrentFileLogEntry = '{0}:{1}' -f $ImageToUpdate.Index, $UpdateFile.Title if(-not ($CurrentFileLogEntry -in $PermanentLog)) { Write-Progress -Activity 'Updating image' -CurrentOperation "Applying $($UpdateFile.Title)" -Status "Applying images to $($ImageToUpdate.Path)" -PercentComplete $CurrentFilePercent try { Write-Verbose -Message ('Applying {0} to {1}' -f $UpdateFile.Path, $ImageToUpdate.Path) $AddWindowsPackageOut = Add-WindowsPackage -PackagePath $UpdateFile.Path -Path $OfflineMountFolder -ErrorAction Stop -WarningAction Stop Write-Verbose -Message ("Applied {0} to {1} index {2}:`r`n{3}" -f $UpdateFile.Title, $ImageToUpdate.Path, $ImageToUpdate.Index, $AddWindowsPackageOut) $CurrentImageLog += "$CurrentFileLogEntry`r`n" } catch { Write-Warning -Message ('Add-WindowsPackage failed for {0} on {1}: {2}' -f $UpdateFile.Title, $ImageToUpdate.Path, $_) } } else { Write-Verbose -Message ('{0} has already been applied to image file {1} index {2}' -f $UpdateFile.Title, $ImageToUpdate.Path, $ImageToUpdate.Index) } } Write-Progress -Activity 'Updating image' -Completed try { $OutNull = Dismount-WindowsImage -Path $OfflineMountFolder -Save -ErrorAction Stop } catch { $CurrentMessage = "Unable to save changes to $($ImageToUpdate.Path): $($_.Message)" Write-Error -Message $CurrentMessage $CurrentImageLog = @("$CurrentMessage`r`n") #note the re-assignment; the updates will not be logged because they never really happened $OutNull = Dismount-WindowsImage -Path $OfflineMountFolder -Discard } } else { $CurrentMessage = 'No updates were selected' Write-Warning -Message $CurrentMessage $PermanentLog += $CurrentMessage } $PermanentLog = $PermanentLog | select -Unique $PermanentLog += $CurrentImageLog $PermanentLog += ('------- Patch Cycle Completed {0} -------' -f (Get-Date)) Set-Content -Path $LogFile -Value $PermanentLog } #} #uncomment this line to use this script dot-sourced or in a profile. Also the function definition lines at the beginning.