Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Installing KB Updates on Remote System, 5 day dev journal

KB Installs with the kbupdate module

Here's my 5 day journal of the work I was doing on KBUpdate. It was not initially written for a super technical audience, so please pardon non-specific words like "metadata". Also, these changes have not yet been merged to kbupdate repo, but will be soon.

Intro

The ultimate goal of this patching project is to easily view, download and install files from Microsoft's Patch repo. The goal of the installer I'm working on today is to allow the easy installation of patches on a multitude of remote systems from a central workstation in an offline network. This will prevent the requirement of interactive (RDP ->= Click ->= Click ->= Next) installs, saving countless hours across offline networks. Making patching easier and faster also increases the likelihood of proactive patching.

Installing Windows Patches remotely using PowerShell is challenging but not impossible. I wanted an easy way to:

  • Install KB Updates, including Windows, SQL Server, .NET, Office and whatever else is in the Windows Update Catalog
  • Provide support for all update file types: MSI, MSU, EXE, whatever else.
  • Easily install to remote computers from one admin station
  • Not reach out ot the internet for any information, as the installer must work on air-gapped networks

There are a number of security mechanisms that intentionally make this hard. Off the top of my head, these include (don't quote me):

  • Installing updates can only be performed by the SYSTEM account
  • There's no "built-in" way to do this in a straightforward manner but you can use psexec and you can create Schedule Tasks to run as SYSTEM. Since psexec isn't available on all systems, you'd have to copy it over then execute which isn't ideal. Using Scheduled Tasks is my least favorite solution.
  • One option that's built into Windows (for many years now) is Invoke-DscResource. Thank you for the hints Matthew Hitchcock, Jess Pomfret and Kirill Kravtsov! This appears to initiate some process that is runs as SYSTEM. There's one potential downside in that the changes may not be recorded(?) with something in DSC called an LCM but I figure if someone is using DSC in their org, they won't use my module.
  • Installing remote updates from a file share can introduce Kerberos double-hop issues.

It was pretty easy getting Invoke-DscResource going with MSU and MSI files, and very specific SQL EXE files. Getting other EXE files to install is problematic, as they don't work off the same predictable MSI subsystem.

Research suggests that, in order to install an EXE file elegantly (ie. without using psexec or scheduled tasks), some additional metadata is required for Invoke-DscResource. Getting this metadata (specifically, a GUID) is challenging, but not impossible, using PowerShell.

Monday

  • Once a sample KB file from Microsoft's KB respository was obtained, I searched for ways to find the GUID that I would need to install EXEs remotely and was able to extract the information using PowerShell, though nothing seemed overtly useful.
  • Using the sample .NET SDK Update file, I installed and inspected not only the file's metadata, but also the resulting metadata that showed up in the Windows registry. Unfortunately, nothing aligned with GUID I was looking for.
  • From there, I moved on to see how other products such as chocolately install files remotely and discovered through a source code review, that I could likely specify command line arguments in lieu of searching for the GUID within metadata
  • After uninstalling the .NET SDK using the Windows GUI, I attempted to install the patch using command line a proof of concept: ./dotnet-sdk-5.0.404-win-x64_a943fac999a30b3eb83580112b793d37de0c0700.exe /install /quiet /notrestart and this worked. Now to make it work with Invoke-DscResource or directly from the command line while bypassing Kerberos concerns.

Tuesday

Today's challenge was building an elegant, approved solution for finding a package's GUID. This GUID is required for simplified remote install methods that play nice with Kerberos.

The easiest way to find this is to use 7zip to unzip the exe, but 7zip is not allowed on the network.

  • Turns out extract is not good enough - it extracts a whole compiled file w/o the GUID
  • lessmsi doesn't work either, it cannot read an exe
  • extrac32 can't read an exe
  • dark.exe extracts and decompiles too much
  • Reading the source code from Carbon from webmd shows a use of WindowsInstaller.Installer, which also can't open an updated stored in exe format
  • [System.IO.Compression.ZipFile]::OpenRead($Path) doesn't work, as it considers the exe corrupted
  • I explored native ways to work with MSZIP compressed files but refused to work with bytes and dig through the algorithm's "piquant history"
  • 7Zip isn't allowed but I'm tempted to triple check with the org to see if 7zip can't be used lol
  • msiexec and using the /extract flag installs in addition to extracting contents that do not provide the GUID
  • The PowerShell-compliant solution was finally found within the WIX Library, which is an open source project available from Microsoft. Just gotta load Microsoft.Deployment.Compression.Cab.dll and Microsoft.Deployment.Compression.dll.

After the WIX solution was found, I began to integrate it into the code for the installer but realized that I needed to test other EXEs within the Patch Repository. I downloaded twenty sample executables and explored their internal file structure. Handling different file structures will help ensure that the installer can handle as many patch types as possible without human intervention. So far, I've found one outlier and will work to ensure this installer type is supported as well.

Wednesday

Now that all known pre-requisites have been discovered, today's goal was to install the 5 different Windows Patch types (MSI, MSU, EXE, cab + SQL EXE) to a remote Windows server within a lab.

This will make installing patches to servers as easy as:

Get-ChildItem C:\updates | Install-KbUpdate -ComputerName server01, server02, server03

The above hypothetical command will install a directory of patches to remote servers.

  • I successfully installed both an EXE and a SQL EXE on a remote Windows Server 2022 machine running SQL Server! Excellent! But the output was not correct.
  • While attempting to fix the output, I realized that the approach I used to discover the GUID was valid (extracting XML to disk), but didn't overwrite metadata files if they already existed and this possiblity needed to be handled.
  • Reading a stream of data in memory instead of writing the data to disk is preferable anyway, so I began to explore the OpenText() and OpenRead() methods of the CabInfo class instead of writing to a temp directory using UnpackFile()
  • Both methods result in error Cabinet file does not have the correct format and there's not much about this on the web or in their documents. Because the cab's format is valid for extracting/unpacking the file and writing to disk but not reading directly, I'm leaning to this being a bug within the WIX library. Worst case, I'll return back to writing and checking.

Thursday

  • Continued to try a number of different ways to avoid writing to disk, like unpacking to stream, but nothing worked, so I accepted that I'd have to write a temporary file disk
  • Next up, choosing a unique name for XML file, which I've decided will be $filename.exe.xml
  • Next, I need a universal way to find the specific files I need, which ended up being GetFiles()
  • Now that I know how I'll extract the internal XML files, I sorted the 20 samples into the the type of uniquely idenitifable XML attributes they presented
  • Before I begin my targeteted extraction, I need to reorganize my code to accommodate this updated technique
  • Oops, the code reorganization killed the command's ability to install a SQL Server patch
  • Wait, how did it ever install SQL Server updates in the first place?
  • Incredible! It's because I was accidentally reusing a GUID because of the file exists bug I ran into yesterday. Turns out, I don't even need the patch's GUID. I can fake it with a CIM-Compatible GUID such as DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F. Uncharted waters! Thanks /PowerShell/PSDscResources
  • Confirmed that even though I used a fake DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F GUID, the package installs properly and knows its own GUID. The DEADBEEF-80C6-41E6-A1B9-8BDB8A05027F GUID appears to be used to check if something is already installed, so I do try my best to get the right GUID.
  • If something is installed that uses the fake guid, it fails nicely but a good GUID would have saved the time it took to attempt an install
  • Next up, I will
    • Try the previous techniques to get the real GUID which helps the installer fail faster when the patch already exists
    • If no GUID has been found (which is true of files like SQL Server 2019 CU 1), I can use Get-KbInstalledSoftware to see if the patch is already installed. Then, if not, I can use the fabricated GUID. Very exciting.

Friday

  • Tested to see if it was possible to speed up the query for Get-KbInstalledUpdate, but ultimately, getting just one takes as long as getting all kbs because it can only be filtered after the fact. To address this, a per-server cache of KBs will be used.
  • Encountered and troubleshot the error "Cannot invoke the Invoke-DscResource cmdlet. The Invoke-DscResource cmdlet is in progress and must return before Invoke-DscResource can be invoked. Use -Force option if that is available to cancel the current operation." Unfortunately, I could not find a resolution other than to restart the server and rename the corrupted file it was repeatedly attempting to install. This appears to have cancelled the process, though a clean, repeatable resolution would have been preferred.
  • Fixed remote module detection that caused modules to reinstall with each run, even if they already existed.
  • Added auto-detection for the update Title, through by using (Get-ChildItem $file).VersionInfo.ProductName. This helps make the output human readable, as the adminsitrator doesn't have to rely on a filename or KB number without context.
  • Added retry for copying files after failures with unknown causes were encountered. Looked for details as to why the failure occurred but no information was provided.
  • Added auto-skip for KBs which have already been installed
  • Added auto-delete for installer files that have been copied to the remote computer

Thanks for reading! I'll keep this updated if anything exciting changes.

# THIS HASN'T BEEN ADDED TO THE KBUPDATE REPO YET BUT WILL BE WHEN I GET A MOMENT
function Install-KbPatch {
<#
.SYNOPSIS
Installs KBs on local and remote servers on Windows-based systems
.DESCRIPTION
Installs KBs on local and remote servers on Windows-based systems
PowerShell 5.1 must be installed and enabled on the target machine and the target machine must be Windows-based
Note that if you use a DSC Pull server, this may impact your LCM
.PARAMETER ComputerName
Used to connect to a remote host
.PARAMETER Credential
The optional alternative credential to be used when connecting to ComputerName
.PARAMETER PSDscRunAsCredential
Run the install as a specific user (other than SYSTEM) on the target node
.PARAMETER FilePath
The filepath of the patch. Not required - if you don't have it, we can grab it from the internet
Note this does place the hotfix files in your local and remote Downloads directories
.PARAMETER ArgumentList
This is an advanced parameter for those of you who need special argumentlists for your platform-specific update.
The argument list required by SQL updates are already accounted for.
.PARAMETER EnableException
By default, when something goes wrong we try to catch it, interpret it and give you a friendly warning message.
This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting.
Using this switch turns this "nice by default" feature off and enables you to catch exceptions with your own try/catch.
.EXAMPLE
PS C:\> Install-KbPatch -ComputerName sql2017 -FilePath C:\temp\windows10.0-kb4534273-x64_74bf76bc5a941bbbd0052caf5c3f956867e1de38.msu
Installs KB4534273 from the C:\temp directory on sql2017
.EXAMPLE
PS C:\> Install-KbPatch -ComputerName sql2017 -FilePath \\nas\sql\windows10.0-kb4532947-x64_20103b70445e230e5994dc2a89dc639cd5756a66.msu
Installs KB4534273 from the \\nas\sql\ directory on sql2017
.EXAMPLE
PS> $params = @{
ComputerName = "sql2017"
FilePath = "C:\temp\sqlserver2017-kb4498951-x64_b143d28a48204eb6ebab62394ce45df53d73f286.exe"
Verbose = $true
}
PS> Install-KbPatch @params
PS> Uninstall-KbPatch -ComputerName sql2017 -HotfixId KB4498951
Installs KB4498951 on sql2017 then uninstalls it ✔
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = "Medium")]
param (
[PSFComputer[]]$ComputerName = $env:ComputerName,
[PSCredential]$Credential,
[PSCredential]$PSDscRunAsCredential,
[Alias("Path", "FullName")]
[ValidateScript( { Test-Path -Path $_ } )]
[Parameter(ValueFromPipeline, Mandatory)]
[System.IO.FileInfo]$FilePath,
[string]$ArgumentList,
[switch]$EnableException
)
process {
if ($IsLinux -or $IsMacOs) {
Stop-PSFFunction -Message "This command using remoting and only supports Windows at this time" -EnableException:$EnableException
return
}
foreach ($computer in $ComputerName) {
Write-PSFMessage -Level Verbose -Message "Processing $computer"
# null out a couple things to be safe
$remotehome = $remotesession = $null
if (-not $computer.IsLocalhost) {
# a lot of the file copy work will be done in the remote $home dir
$remotehome = Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ScriptBlock { $home }
Write-PSFMessage -Level Verbose -Message "Remote home: $remotehome"
if (-not $remotesession) {
$remotesession = Get-PSSession -ComputerName $computer | Where-Object { $PsItem.Availability -eq 'Available' -and ($PsItem.Name -match 'WinRM' -or $PsItem.Name -match 'Runspace') } | Select-Object -First 1
}
if (-not $remotesession) {
$remotesession = Get-PSSession -ComputerName $computer | Where-Object { $PsItem.Availability -eq 'Available' } | Select-Object -First 1
}
if (-not $remotesession) {
Stop-PSFFunction -EnableException:$EnableException -Message "Session for $computer can't be found or no runspaces are available. Please file an issue on the GitHub repo at https://github.com/potatoqualitee/disarepotools/issues" -Continue
}
}
# See if remote server has xWindowsUpdate installed, if not install it because it'll likely be needed
# but store the test results in a script variable to prevent it from running over again because it takes 1 second
if (-not $script:hashotfixmodule[$computer.ComputerName]) {
$script:hashotfixmodule[$computer.ComputerName] = Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ScriptBlock {
if ((Get-Module -ListAvailable xWindowsUpdate).Name) {
$true
} else {
$false
}
}
}
if ($script:hashotfixmodule[$computer.ComputerName]) {
Write-PSFMessage -Level Verbose -Message "xWindowsUpdate found on $computer"
} else {
Write-PSFMessage -Level Verbose -Message "xWindowsUpdate not found on $computer, attempting to install it"
try {
# Copy xWindowsUpdate to Program Files. The module is pretty much required to be in the PS Modules directory.
$oldpref = $ProgressPreference
$ProgressPreference = "SilentlyContinue"
$programfiles = Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ArgumentList "$env:ProgramFiles\WindowsPowerShell\Modules" -ScriptBlock {
$env:ProgramFiles
}
$null = Copy-Item -Path "$script:ModuleRoot\library\xWindowsUpdate" -Destination "$programfiles\WindowsPowerShell\Modules\xWindowsUpdate" -ToSession $remotesession -Recurse -Force
$ProgressPreference = $oldpref
Write-PSFMessage -Level Verbose -Message "xWindowsUpdate installed successfully on $computer"
$script:hashotfixmodule["$computer"] = $true
} catch {
Stop-PSFFunction -EnableException:$EnableException -Message "Couldn't auto-install xHotfix on $computer. Please Install-Module xWindowsUpdate on $computer to continue." -Continue
}
}
if (-not $script:installedsoftware["$computer"]) {
# Get a list of installed software to do comparisons later
# This takes 1s so store it so that it does't run with each piped in file
$script:installedsoftware["$computer"] = Get-KbInstalledSoftware -ComputerName $computer -Credential $Credential
}
foreach ($file in $FilePath) {
$hotfixid = $guid = $null
$updatefile = Get-ChildItem -Path $file -ErrorAction SilentlyContinue
$Title = $updatefile.VersionInfo.ProductName
if ($computer.IsLocalhost) {
$remotefile = $updatefile
} else {
$remotefile = "$remotehome\Downloads\$(Split-Path -Leaf $updateFile)"
}
# copy over to destination server unless
# it's local or it's on a network share
if (-not "$($PSBoundParameters.FilePath)".StartsWith("\\") -and -not $computer.IsLocalhost) {
Write-PSFMessage -Level Verbose -Message "Update is not located on a file server and not local, copying over the remote server"
try {
$exists = Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ArgumentList $remotefile -ScriptBlock {
Get-ChildItem -Path $args -ErrorAction SilentlyContinue
}
if (-not $exists) {
$null = Copy-Item -Path $updatefile -Destination $remotefile -ToSession $remotesession -ErrorAction Stop
$deleteremotefile = $remotefile
}
} catch {
$null = Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ArgumentList $remotefile -ScriptBlock {
Remove-Item $args -Force -ErrorAction SilentlyContinue
}
try {
Write-PSFMessage -Level Warning -Message "Copy failed, trying again"
$null = Copy-Item -Path $updatefile -Destination $remotefile -ToSession $remotesession -ErrorAction Stop
$deleteremotefile = $remotefile
} catch {
$null = Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ArgumentList $remotefile -ScriptBlock {
Remove-Item $args -Force -ErrorAction SilentlyContinue
}
Stop-PSFFunction -EnableException:$EnableException -Message "Could not copy $updatefile to $remotefile" -ErrorRecord $PSItem -Continue
}
}
} else {
Stop-PSFFunction -EnableException:$EnableException -Message "Could not find $HotfixId and no file was specified" -Continue
}
# if user doesnt add kb, try to find it for them from the provided filename
$HotfixId = $file.ToString().ToUpper() -split "\-" | Where-Object { $psitem.Startswith("KB") }
if ($HotfixId) {
Write-PSFMessage -Level Verbose -Message "Hotfix ID found: $HotfixId"
$hotfixinstalled = ($script:installedsoftware["$computer"] | Where-Object Name -match $HotfixId).Name
if ($hotfixinstalled) {
Stop-PSFFunction -EnableException:$EnableException -Message "$hotfixinstalled is already installed on $computer" -Continue
}
}
if ($file.ToString().EndsWith("exe")) {
if (-not $PSBoundParameters.ArgumentList) {
if ($file -match "sql") {
# kb has already been checked, use generic GUID
$guid = "DAADB00F-DAAD-B00F-B00F-DAADB00FB00F"
$ArgumentList = "/action=patch /AllInstances /quiet /IAcceptSQLServerLicenseTerms"
} else {
$ArgumentList = "/install /quiet /notrestart"
}
}
}
# first, if hotfix can be determiend, see if it's been installed
# If hotfix AND NOT SQL, do hotfix
# if exe at all, do this below, but
if (-not $HotfixId -and $file -notmatch "sql") {
try {
Write-PSFMessage -Level Verbose -Message "Trying to get GUID from $($updatefile.FullName)"
<#
It's better to just read from memory but I can't get this to work
$cab = New-Object Microsoft.Deployment.Compression.Cab.Cabinfo "C:\path\path.exe"
$file = New-Object Microsoft.Deployment.Compression.Cab.CabFileInfo($cab, "0")
$content = $file.OpenRead()
#>
$cab = New-Object Microsoft.Deployment.Compression.Cab.Cabinfo $updatefile.FullName
$files = $cab.GetFiles("*")
$index = $files | Where-Object Name -eq 0
if (-not $index) {
$index = $files | Where-Object Name -match "none.xml|ParameterInfo.xml" #KB.*.xml|mediainfo.xml|PSFX.*.xml
}
$temp = Get-PSFPath -Name Temp
$indexfilename = $index.Name
$xmlfile = Join-Path -Path $temp -ChildPath "$($updatefile.BaseName).xml"
$null = $cab.UnpackFile($indexfilename, $xmlfile)
$xml = [xml](Get-Content -Path $xmlfile)
$tempguid = $xml.BurnManifest.Registration.Id
if (-not $tempguid -and $xml.MsiPatch.PatchGUID) {
$tempguid = $xml.MsiPatch.PatchGUID
}
if (-not $tempguid -and $xml.Setup.Items.Patches.MSP.PatchCode) {
$tempguid = $xml.Setup.Items.Patches.MSP.PatchCode
}
Get-ChildItem -Path $xmlfile -ErrorAction SilentlyContinue | Remove-Item -Confirm:$false -ErrorAction SilentlyContinue
if (-not $tempguid) {
$tempguid = "DAADB00F-DAAD-B00F-B00F-DAADB00FB00F"
}
$guid = ([guid]$tempguid).Guid
} catch {
$guid = "DAADB00F-DAAD-B00F-B00F-DAADB00FB00F"
}
Write-PSFMessage -Level Verbose -Message "GUID is $guid"
}
if (-not $HotfixId -and -not $Guid) {
Stop-PSFFunction -EnableException:$EnableException -Message "Could not determine KB from $file. Looked for '-kbnumber-'. Please provide a HotfixId." -Continue
}
if ($HotfixId -and $updatefile -notmatch 'sql') {
Write-PSFMessage -Level Verbose -Message "It's a Hotfix"
# this takes care of WSU files
$hotfix = @{
Name = 'xHotFix'
ModuleName = 'xWindowsUpdate'
Property = @{
Ensure = 'Present'
Id = $HotfixId
Path = $remotefile
}
}
if ($PSDscRunAsCredential) {
$hotfix.Property.PSDscRunAsCredential = $PSDscRunAsCredential
}
$verbosemessage = "Installing Hotfix $HotfixId from $file"
} else {
Write-PSFMessage -Level Verbose -Message "It's a GUID"
$hotfix = @{
Name = 'Package'
ModuleName = 'PSDesiredStateConfiguration'
Property = @{
Ensure = 'Present'
ProductId = $guid
Name = $Title
Path = $remotefile
Arguments = $ArgumentList
ReturnCode = 0, 3010
}
}
$verbosemessage = "Installing $Title ($Guid) from $file"
}
if ($PSCmdlet.ShouldProcess($computer, $verbosemessage)) {
try {
Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ScriptBlock {
param (
$Hotfix,
$VerbosePreference,
$ManualFileName
)
$PSDefaultParameterValues['*:ErrorAction'] = 'SilentlyContinue'
$ErrorActionPreference = "Stop"
$null = Import-Module PSDesiredStateConfiguration -Verbose:$false
if (-not (Get-Command Invoke-DscResource)) {
throw "Invoke-DscResource not found on $env:ComputerName"
}
$null = Import-Module xWindowsUpdate -Force -Verbose:$false
Write-Verbose -Message "Performing installation of $ManualFileName on $env:ComputerName"
try {
if (-not (Invoke-DscResource @hotfix -Method Test)) {
$ProgressPreference = 'SilentlyContinue'
Invoke-DscResource @hotfix -Method Set -ErrorAction Stop
}
} catch {
switch ($message = "$_") {
# some things can be ignored like XML is nested too deeply
# seems somehow related to Kerberos but it basically maybe means
# that a restart is now required
{ $message -match "Serialized XML is nested too deeply" -or $message -match "Name does not match package details" } {
$null = 1
}
{ $message -match "2359302" } {
throw "Error 2359302: update is already installed on $env:ComputerName"
}
{ $message -match "2042429437" } {
throw "Error -2042429437. Configuration is likely not correct. The requested features may not be installed or features are already at a higher patch level."
}
{ $message -match "2068709375" } {
throw "Error -2068709375. The exit code suggests that something is corrupt. See if this tutorial helps: http://www.sqlcoffee.com/Tips0026.htm"
}
{ $message -match "2067919934" } {
throw "Error -2067919934 You likely need to reboot $env:ComputerName."
}
{ $message -match "2147942402" } {
throw "System can't find the file specified for some reason."
}
default {
throw
}
}
}
} -ArgumentList $hotfix, $VerbosePreference, $updatefile -ErrorAction Stop
if ($deleteremotefile) {
Write-PSFMessage -Level Verbose -Message "Deleting $deleteremotefile"
$null = Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ArgumentList $deleteremotefile -ScriptBlock {
Get-ChildItem -ErrorAction SilentlyContinue $args | Remove-Item -Force -ErrorAction SilentlyContinue -Confirm:$false
}
}
Write-Verbose -Message "Finished installing, checking status"
$exists = Get-KbInstalledSoftware -ComputerName $computer -Credential $Credential -Pattern $hotfixid, $guid -IncludeHidden
if ($exists.Summary -match "restart") {
$status = "This update requires a restart"
} else {
$status = "Install successful"
}
if ($guid) {
$id = $guid
} else {
$id = $HotfixId
}
[pscustomobject]@{
ComputerName = $computer
Title = $Title
ID = $id
Status = $Status
} | Select-DefaultView -Property ComputerName, Title, Status
} catch {
if ($deleteremotefile) {
Write-PSFMessage -Level Verbose -Message "Deleting $deleteremotefile"
$null = Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ArgumentList $deleteremotefile -ScriptBlock {
Get-ChildItem -ErrorAction SilentlyContinue $args | Remove-Item -Force -ErrorAction SilentlyContinue
}
}
if ("$PSItem" -match "Serialized XML is nested too deeply") {
Write-PSFMessage -Level Verbose -Message "Serialized XML is nested too deeply. Forcing output."
$exists = Get-KbInstalledSoftware -ComputerName $computer -Credential $credential -HotfixId $hotfix.property.id
if ($exists) {
[pscustomobject]@{
ComputerName = $computer
Title = $Title
Id = $id
Status = "Successfully installed. A restart is now required."
} | Select-DefaultView -Property ComputerName, Title, Status
} else {
Stop-PSFFunction -Message "Failure on $computer" -ErrorRecord $_ -EnableException:$EnableException -Continue
}
} else {
Stop-PSFFunction -Message "Failure on $computer" -ErrorRecord $_ -EnableException:$EnableException -Continue
}
}
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment