diff --git a/kbupdate.psm1 b/kbupdate.psm1 index 5b1278f..b9a7d97 100644 --- a/kbupdate.psm1 +++ b/kbupdate.psm1 @@ -103,4 +103,17 @@ if ($internet) { Write-PSFMessage -Level Verbose -Message "Internet connection not detected. Setting source for Get-KbUpdate to Database." $PSDefaultParameterValues['Get-KbUpdate:Source'] = "Database" $PSDefaultParameterValues['Save-KbUpdate:Source'] = "Database" -} \ No newline at end of file +} + +# Disables session caching +Set-PSFConfig -FullName PSRemoting.Sessions.Enable -Value $true -Initialize -Validation bool -Handler { } -Description 'Globally enables session caching for PowerShell remoting' + +# New-PSSessionOption +Set-PSFConfig -FullName PSRemoting.PsSessionOption.IncludePortInSPN -Value $false -Initialize -Validation bool -Description 'Changes the value of -IncludePortInSPN parameter used by New-PsSessionOption which is used for kbupdate internally when working with PSRemoting.' +Set-PSFConfig -FullName PSRemoting.PsSessionOption.SkipCACheck -Value $false -Initialize -Validation bool -Description 'Changes the value of -SkipCACheck parameter used by New-PsSessionOption which is used for kbupdate internally when working with PSRemoting.' +Set-PSFConfig -FullName PSRemoting.PsSessionOption.SkipCNCheck -Value $false -Initialize -Validation bool -Description 'Changes the value of -SkipCNCheck parameter used by New-PsSessionOption which is used for kbupdate internally when working with PSRemoting.' +Set-PSFConfig -FullName PSRemoting.PsSessionOption.SkipRevocationCheck -Value $false -Initialize -Validation bool -Description 'Changes the value of -SkipRevocationCheck parameter used by New-PsSessionOption which is used for kbupdate internally when working with PSRemoting.' + +# New-PSSession +Set-PSFConfig -FullName PSRemoting.PsSession.UseSSL -Value $false -Initialize -Validation bool -Description 'Changes the value of -UseSSL parameter used by New-PsSession which is used for kbupdate internally when working with PSRemoting.' +Set-PSFConfig -FullName PSRemoting.PsSession.Port -Value $null -Initialize -Validation integerpositive -Description 'Changes the -Port parameter value used by New-PsSession which is used for kbupdate internally when working with PSRemoting. Use it when you don''t work with default port number. To reset, use Set-PSFConfig -FullName PSRemoting.PsSession.Port -Value $null' \ No newline at end of file diff --git a/private/Invoke-Command2.ps1 b/private/Invoke-Command2.ps1 new file mode 100644 index 0000000..7e62c05 --- /dev/null +++ b/private/Invoke-Command2.ps1 @@ -0,0 +1,93 @@ +function Invoke-Command2 { + [cmdletbinding()] + param( + [Parameter(Mandatory)] + [string]$ComputerName, + [Parameter(Mandatory)] + [scriptblock]$ScriptBlock, + [object[]]$ArgumentList, + [PSCredential]$Credential, + [switch]$HideComputerName, + [int]$ThrottleLimit = 32, + [switch]$EnableException + ) + if (-not (Get-Module PSFramework)) { + Import-Module PSFramework 4>$null + } + $computer = [PSFComputer]$ComputerName + + Write-PSFMessage -Level Verbose -Message "Adding ErrorActon Stop to Invoke-Command and Invoke-PSFCommand" + $PSDefaultParameterValues['*:ErrorAction'] = "Stop" + $PSDefaultParameterValues['*:ErrorAction'] = "Stop" + + if ($EnableException) { + $null = $PSDefaultParameterValues.Remove('*:EnableException') + $PSDefaultParameterValues['*:EnableException'] = $true + } else { + $null = $PSDefaultParameterValues.Remove('*:EnableException') + $PSDefaultParameterValues['*:EnableException'] = $false + } + if (-not $computer.IsLocalhost) { + Write-PSFMessage -Level Verbose -Message "Computer is not localhost, adding $ComputerName to PSDefaultParameterValues" + $PSDefaultParameterValues['Invoke-Command:ComputerName'] = $ComputerName + } + if ($Credential) { + Write-PSFMessage -Level Verbose -Message "Adding Credential to Invoke-Command and Invoke-PSFCommand" + $PSDefaultParameterValues['Invoke-Command:Credential'] = $Credential + $PSDefaultParameterValues['Invoke-PSFCommand:Credential'] = $Credential + } + + try { + if (-not (Get-PSFConfigValue -Name PSRemoting.Sessions.Enable) -or $computer.IsLocalhost) { + Write-PSFMessage -Level Verbose -Message "Sessions disabled, just using Invoke-Command" + Invoke-Command -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList + } else { + Write-PSFMessage -Level Verbose -Message "Sessions enabled, using Invoke-PSFCommand" + $null = Get-PSSession | Where-Object { $PSItem.Name -eq "kbupdate-$ComputerName" -and $PSItem.State -eq "Broken" } | Remove-PSSession + $session = Get-PSSession | Where-Object Name -eq "kbupdate-$ComputerName" + + if ($session.State -eq "Disconnected") { + Write-PSFMessage -Level Verbose -Message "Session is disconnected, reconnecting" + $null = $session | Connect-PSSession -ErrorAction Stop + } + + if (-not $session) { + Write-PSFMessage -Level Verbose -Message "Creating session objects" + $sessionoptions = @{ + IncludePortInSPN = Get-PSFConfigValue -FullName PSRemoting.PsSessionOption.IncludePortInSPN + SkipCACheck = Get-PSFConfigValue -FullName PSRemoting.PsSessionOption.SkipCACheck + SkipCNCheck = Get-PSFConfigValue -FullName PSRemoting.PsSessionOption.SkipCNCheck + SkipRevocationCheck = Get-PSFConfigValue -FullName PSRemoting.PsSessionOption.SkipRevocationCheck + } + $sessionOption = New-PSSessionOption @sessionoptions + + $sessionparm = @{ + ComputerName = $ComputerName + Name = "kbupdate-$ComputerName" + SessionOption = $sessionOption + ErrorAction = "Stop" + } + if (Get-PSFConfigValue -FullName PSRemoting.PsSession.UseSSL) { + $null = $sessionparm.Add("UseSSL", (Get-PSFConfigValue -FullName PSRemoting.PsSession.UseSSL)) + } + if (Get-PSFConfigValue -FullName PSRemoting.PsSession.Port) { + $null = $sessionparm.Add("Port", (Get-PSFConfigValue -FullName PSRemoting.PsSession.Port)) + } + + Write-PSFMessage -Level Verbose -Message "Creating new session" + $session = New-PSSession @sessionparm + } + Write-PSFMessage -Level Verbose -Message "Connecting to session using Invoke-PSFCommand" + + $commandparm = @{ + ComputerName = $session + ScriptBlock = $ScriptBlock + ArgumentList = $ArgumentList + } + + Invoke-PSFCommand @commandparm + } + } catch { + Stop-PSFFunction -Message "Failure on $ComputerName" -ErrorRecord $PSItem + } +} \ No newline at end of file diff --git a/private/Start-DscUpdate.ps1 b/private/Start-DscUpdate.ps1 index 283e60a..367e3b7 100644 --- a/private/Start-DscUpdate.ps1 +++ b/private/Start-DscUpdate.ps1 @@ -1,9 +1,8 @@ - function Start-DscUpdate { [CmdletBinding()] param ( [Parameter(Mandatory)] - [psobject]$Computer, + [psobject]$ComputerName, [PSCredential]$Credential, [PSCredential]$PSDscRunAsCredential, [Parameter(ValueFromPipelineByPropertyName)] @@ -22,73 +21,91 @@ function Start-DscUpdate { [pscustomobject[]]$InputObject, [switch]$AllNeeded, [switch]$NoMultithreading, - [switch]$DoException, - [bool]$IsLocalHost + [switch]$EnableException, + [bool]$IsLocalHost, + [string]$VerbosePreference ) begin { - # No idea why this happens - if ($Computer -is [hashtable]) { - $hashtable = $Computer.PsObject.Copy() - $null = Remove-Variable -Name Computer + # Ignore this + # function Invoke-Command2 + + # load up if a job + if (-not (Get-Module kbupdate)) { + $null = Import-Module PSSQLite -RequiredVersion 1.1.0 4>$null + $null = Import-Module PSFramework -RequiredVersion 1.7.227 4>$null + $null = Import-Module kbupdate 4>$null + } + + # No idea why this sometimes happens + if ($ComputerName -is [hashtable]) { + $hashtable = $ComputerName.PsObject.Copy() + $null = Remove-Variable -Name ComputerName foreach ($key in $hashtable.keys) { Set-Variable -Name $key -Value $hashtable[$key] } } + if ($EnableException) { + $PSDefaultParameterValues["*:EnableException"] = $true + } else { + $PSDefaultParameterValues["*:EnableException"] = $false + } + + if ($ComputerName.ComputerName) { + $hostname = $ComputerName.ComputerName + } else { + $hostname = $ComputerName + } + if ($AllNeeded) { - $InputObject = Get-KbNeededUpdate -ComputerName $Computer -EnableException:$EnableException + $InputObject = Get-KbNeededUpdate -ComputerName $ComputerName } if ($FilePath) { $InputObject += Get-ChildItem -Path $FilePath } - if ($DoException) { - $PSDefaultParameterValues["*:EnableException"] = $true - } else { - $PSDefaultParameterValues["*:EnableException"] = $false - } - $script:ModuleRoot = Split-Path -Path ((Get-Module -ListAvailable -Name kbupdate | Sort-Object Version -Descending).Path | Select-Object -First 1) # null out a couple things to be safe $remotefileexists = $programhome = $remotesession = $null # Method is DSC - if ($PSDefaultParameterValues["Invoke-PSFCommand:ComputerName"]) { - $null = $PSDefaultParameterValues.Remove("Invoke-PSFCommand:ComputerName") + if ($PSDefaultParameterValues["Invoke-Command2:ComputerName"]) { + $null = $PSDefaultParameterValues.Remove("Invoke-Command2:ComputerName") } + $PSDefaultParameterValues["Invoke-Command2:ComputerName"] = $ComputerName if ($IsLocalHost) { # a lot of the file copy work will be done in the $home dir - $programhome = Invoke-PSFCommand -ScriptBlock { $home } + $programhome = Invoke-Command2 -ScriptBlock { $home } } else { - Write-PSFMessage -Level Verbose -Message "Adding $Computer to PSDefaultParameterValues for Invoke-PSFCommand:ComputerName" - $PSDefaultParameterValues["Invoke-PSFCommand:ComputerName"] = $Computer + Write-PSFMessage -Level Verbose -Message "Adding $hostname to PSDefaultParameterValues for Invoke-Command2:ComputerName" + $PSDefaultParameterValues["Invoke-Command2:ComputerName"] = $ComputerName - Write-PSFMessage -Level Verbose -Message "Initializing remote session to $Computer and also getting the remote home directory" - $programhome = Invoke-PSFCommand -ScriptBlock { $home } + Write-PSFMessage -Level Verbose -Message "Initializing remote session to $hostname and also getting the remote home directory" + $programhome = Invoke-Command2 -ScriptBlock { $home } if (-not $remotesession) { - $remotesession = Get-PSSession -ComputerName $Computer -Verbose | Where-Object { $PsItem.Availability -eq 'Available' -and ($PsItem.Name -match 'WinRM' -or $PsItem.Name -match 'Runspace') } | Select-Object -First 1 + $remotesession = Get-PSSession -ComputerName $ComputerName -Verbose | 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 + $remotesession = Get-PSSession -ComputerName $ComputerName | Where-Object { $PsItem.Availability -eq 'Available' } | Select-Object -First 1 } if (-not $remotesession) { - Stop-PSFFunction -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/kbupdate/issues" -Continue + Stop-PSFFunction -Message "Session for $hostname can't be found or no runspaces are available. Please file an issue on the GitHub repo at https://github.com/potatoqualitee/kbupdate/issues" -Continue } } # fix for SYSTEM which doesn't have a downloads directory by default Write-PSFMessage -Level Verbose -Message "Checking for home downloads directory" - Invoke-PSFCommand -ScriptBlock { + Invoke-Command2 -ScriptBlock { if (-not (Test-Path -Path "$home\Downloads")) { Write-Warning "Creating Downloads directory at $home\Downloads" $null = New-Item -ItemType Directory -Force -Path "$home\Downloads" } } - $hasxhotfix = Invoke-PSFCommand -ScriptBlock { + $hasxhotfix = Invoke-Command2 -ScriptBlock { Get-Module -ListAvailable xWindowsUpdate -ErrorAction Ignore | Where-Object Version -eq 3.0.0 } @@ -97,51 +114,54 @@ function Start-DscUpdate { # 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 -ScriptBlock { + $programfiles = Invoke-Command2 -ScriptBlock { $env:ProgramFiles } if ($IsLocalHost) { - Write-PSFMessage -Level Verbose -Message "Copying xWindowsUpdate to $Computer (local to $programfiles\WindowsPowerShell\Modules\xWindowsUpdate)" + Write-PSFMessage -Level Verbose -Message "Copying xWindowsUpdate to $hostname (local to $programfiles\WindowsPowerShell\Modules\xWindowsUpdate)" $null = Copy-Item -Path "$script:ModuleRoot\library\xWindowsUpdate" -Destination "$programfiles\WindowsPowerShell\Modules" -Recurse -Force } else { - Write-PSFMessage -Level Verbose -Message "Copying xWindowsUpdate to $Computer (remote to $programfiles\WindowsPowerShell\Modules\xWindowsUpdate)" + Write-PSFMessage -Level Verbose -Message "Copying xWindowsUpdate to $hostname (remote to $programfiles\WindowsPowerShell\Modules\xWindowsUpdate)" $null = Copy-Item -Path "$script:ModuleRoot\library\xWindowsUpdate" -Destination "$programfiles\WindowsPowerShell\Modules" -ToSession $remotesession -Recurse -Force } $ProgressPreference = $oldpref } catch { - Stop-PSFFunction -Message "Couldn't auto-install xHotfix on $Computer. Please Install-Module xWindowsUpdate on $Computer to continue." -Continue + Stop-PSFFunction -Message "Couldn't auto-install xHotfix on $hostname. Please Install-Module xWindowsUpdate on $hostname to continue." -Continue } } - $hasxdsc = Invoke-PSFCommand -ScriptBlock { + $hasxdsc = Invoke-Command2 -ScriptBlock { Get-Module -ListAvailable xPSDesiredStateConfiguration -ErrorAction Ignore | Where-Object Version -eq 9.2.0 } if (-not $hasxdsc) { try { - Write-PSFMessage -Level Verbose -Message "Adding xPSDesiredStateConfiguration to $Computer" + Write-PSFMessage -Level Verbose -Message "Adding xPSDesiredStateConfiguration to $hostname" # 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 -ScriptBlock { + $programfiles = Invoke-Command2 -ScriptBlock { $env:ProgramFiles } if ($IsLocalHost) { - Write-PSFMessage -Level Verbose -Message "Copying xPSDesiredStateConfiguration to $Computer (local to $programfiles\WindowsPowerShell\Modules\xPSDesiredStateConfiguration)" + Write-PSFMessage -Level Verbose -Message "Copying xPSDesiredStateConfiguration to $hostname (local to $programfiles\WindowsPowerShell\Modules\xPSDesiredStateConfiguration)" $null = Copy-Item -Path "$script:ModuleRoot\library\xPSDesiredStateConfiguration" -Destination "$programfiles\WindowsPowerShell\Modules" -Recurse -Force } else { - Write-PSFMessage -Level Verbose -Message "Copying xPSDesiredStateConfiguration to $Computer (remote)" + Write-PSFMessage -Level Verbose -Message "Copying xPSDesiredStateConfiguration to $hostname (remote)" $null = Copy-Item -Path "$script:ModuleRoot\library\xPSDesiredStateConfiguration" -Destination "$programfiles\WindowsPowerShell\Modules" -ToSession $remotesession -Recurse -Force } $ProgressPreference = $oldpref } catch { - Stop-PSFFunction -Message "Couldn't auto-install newer DSC resources on $Computer. Please Install-Module xPSDesiredStateConfiguration version 9.2.0 on $Computer to continue." -Continue + Stop-PSFFunction -Message "Couldn't auto-install newer DSC resources on $hostname. Please Install-Module xPSDesiredStateConfiguration version 9.2.0 on $hostname to continue." -Continue } } } process { + if (-not $InputObject) { + Write-PSFMessage -Level Verbose -Message "Nothing to install on $hostname, moving on" + } foreach ($object in $InputObject) { if ($object.Link -and $RepositoryPath) { $filename = Split-Path -Path $object.Link -Leaf @@ -152,7 +172,7 @@ function Start-DscUpdate { } if ($FilePath) { - $remotefileexists = $updatefile = Invoke-PSFCommand -ArgumentList $FilePath -ScriptBlock { + $remotefileexists = $updatefile = Invoke-Command2 -ArgumentList $FilePath -ScriptBlock { Get-ChildItem -Path $args -ErrorAction SilentlyContinue } } @@ -192,7 +212,7 @@ function Start-DscUpdate { # try to automatically download it for them if (-not $object -and $Pattern) { - $object = Get-KbUpdate -ComputerName $Computer -Pattern $Pattern | Where-Object { $PSItem.Link -and $PSItem.Title -match $Pattern } + $object = Get-KbUpdate -ComputerName $ComputerName -Pattern $Pattern | Where-Object { $PSItem.Link -and $PSItem.Title -match $Pattern } } # note to reader: if this picks the wrong one, please download the required file manually. @@ -203,13 +223,13 @@ function Start-DscUpdate { $file = Split-Path $object.Link -Leaf | Select-Object -Last 1 } } else { - Stop-PSFFunction -Message "Could not find file on $Computer and couldn't find it online. Try piping in exactly what you'd like from Get-KbUpdate." -Continue + Stop-PSFFunction -Message "Could not find file on $hostname and couldn't find it online. Try piping in exactly what you'd like from Get-KbUpdate." -Continue } if ((Test-Path -Path "$home\Downloads\$file")) { $updatefile = Get-ChildItem -Path "$home\Downloads\$file" } else { - Write-PSFMessage -Level Verbose -Message "File not detected on $Computer, downloading now to $home\Downloads and copying to remote computer" + Write-PSFMessage -Level Verbose -Message "File not detected on $hostname, downloading now to $home\Downloads and copying to remote computer" $warnatbottom = $true @@ -239,7 +259,7 @@ function Start-DscUpdate { if (-not "$($FilePath)".StartsWith("\\") -and -not $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 -ArgumentList $remotefile -ScriptBlock { + $exists = Invoke-Command2 -ComputerName $ComputerName -ArgumentList $remotefile -ScriptBlock { Get-ChildItem -Path $args -ErrorAction SilentlyContinue } if (-not $exists) { @@ -247,7 +267,7 @@ function Start-DscUpdate { $deleteremotefile = $remotefile } } catch { - $null = Invoke-PSFCommand -ComputerName $Computer -ArgumentList $remotefile -ScriptBlock { + $null = Invoke-Command2 -ComputerName $ComputerName -ArgumentList $remotefile -ScriptBlock { Remove-Item $args -Force -ErrorAction SilentlyContinue } try { @@ -255,7 +275,7 @@ function Start-DscUpdate { $null = Copy-Item -Path $updatefile -Destination $remotefile -ToSession $remotesession -ErrorAction Stop $deleteremotefile = $remotefile } catch { - $null = Invoke-PSFCommand -ComputerName $Computer -ArgumentList $remotefile -ScriptBlock { + $null = Invoke-Command2 -ComputerName $ComputerName -ArgumentList $remotefile -ScriptBlock { Remove-Item $args -Force -ErrorAction SilentlyContinue } Stop-PSFFunction -Message "Could not copy $updatefile to $remotefile" -ErrorRecord $PSItem -Continue @@ -420,14 +440,21 @@ function Start-DscUpdate { } } try { - $null = Invoke-PSFCommand -ScriptBlock { + $parms = @{ + ArgumentList = $hotfix, $VerbosePreference, $FileName + EnableException = $true + WarningAction = "SilentlyContinue" + WarningVariable = "dscwarnings" + } + $null = Invoke-Command2 @parms -ScriptBlock { param ( $Hotfix, $VerbosePreference, $ManualFileName ) - Import-Module xPSDesiredStateConfiguration -RequiredVersion 9.2.0 -Force - Import-Module xWindowsUpdate -RequiredVersion 3.0.0 -Force + Import-Module PSDesiredStateConfiguration 4>$null + Import-Module xPSDesiredStateConfiguration -RequiredVersion 9.2.0 4>$null + Import-Module xWindowsUpdate -RequiredVersion 3.0.0 4>$null $PSDefaultParameterValues.Remove("Invoke-WebRequest:ErrorAction") $PSDefaultParameterValues['*:ErrorAction'] = 'SilentlyContinue' $ErrorActionPreference = "Stop" @@ -436,11 +463,32 @@ function Start-DscUpdate { if (-not (Get-Command Invoke-DscResource)) { throw "Invoke-DscResource not found on $env:ComputerName" } - $null = Import-Module xWindowsUpdate -Force - Write-Verbose -Message "Installing $($hotfix.property.id) from $($hotfix.property.path)" + $null = Import-Module xWindowsUpdate -Force 4>$null + + $hotfixpath = $hotfix.property.path + if (-not $hotfixpath) { + $hotfixpath = $hotfix.property.sourcepath + } + $hotfixnameid = $hotfix.property.name + if (-not $hotfixnameid) { + $hotfixnameid = $hotfix.property.id + } + + Write-Verbose -Message "Installing $hotfixnameid from $hotfixpath" try { - if (-not (Invoke-DscResource @hotfix -Method Test)) { - Invoke-DscResource @hotfix -Method Set -ErrorAction Stop + $ProgressPreference = "SilentlyContinue" + if (-not (Invoke-DscResource @hotfix -Method Test 4>$null)) { + $msgs = Invoke-DscResource @hotfix -Method Set -ErrorAction Stop 4>&1 + + if ($msgs) { + foreach ($msg in $msgs) { + # too many extra spaces, baw + while ("$msg" -match " ") { + $msg = "$msg" -replace " ", " " + } + $msg | Write-Verbose + } + } } $ProgressPreference = $oldpref } catch { @@ -451,7 +499,18 @@ function Start-DscUpdate { try { Write-Verbose -Message "Retrying install with /quit parameter" $hotfix.Property.Arguments = "/quiet" - Invoke-DscResource @hotfix -Method Set -ErrorAction Stop + $msgs = Invoke-DscResource @hotfix -Method Set -ErrorAction Stop 4>&1 + + if ($msgs) { + write-warning HELLO + foreach ($msg in $msgs) { + # too many extra spaces, baw + while ("$msg" -match " ") { + $msg = "$msg" -replace " ", " " + } + $msg | Write-Verbose + } + } } catch { $message = "$_".TrimStart().TrimEnd().Trim() } @@ -481,21 +540,31 @@ function Start-DscUpdate { throw "System can't find the file specified for some reason." } default { - throw + throw $message } } } - } -ArgumentList $hotfix, $VerbosePreference, $FileName -ErrorAction Stop + } + + if ($dscwarnings) { + foreach ($warning in $dscwarnings) { + # too many extra spaces, baw + while ("$warning" -match " ") { + $warning = "$warning" -replace " ", " " + } + Write-PSFMessage -Level Warning -Message $warning + } + } if ($deleteremotefile) { Write-PSFMessage -Level Verbose -Message "Deleting $deleteremotefile" - $null = Invoke-PSFCommand -ComputerName $Computer -ArgumentList $deleteremotefile -ScriptBlock { + $null = Invoke-Command2 -ComputerName $ComputerName -ArgumentList $deleteremotefile -ScriptBlock { Get-ChildItem -ErrorAction SilentlyContinue $args | Remove-Item -Force -ErrorAction SilentlyContinue -Confirm:$false } } - Write-Verbose -Message "Finished installing, checking status" - $exists = Get-KbInstalledUpdate -ComputerName $Computer -Pattern $hotfix.property.id -IncludeHidden + Write-PSFMessage -Level Verbose -Message "Finished installing, checking status" + $exists = Get-KbInstalledUpdate -ComputerName $ComputerName -Pattern $hotfix.property.id -IncludeHidden if ($exists.Summary -match "restart") { $status = "This update requires a restart" @@ -517,17 +586,20 @@ function Start-DscUpdate { $filetitle = $updatefile.VersionInfo.ProductName } + if ($message) { + $status = "sucks" + } [pscustomobject]@{ - ComputerName = $Computer + ComputerName = $hostname Title = $filetitle ID = $id - Status = $Status + Status = $status FileName = $updatefile.Name } } catch { if ("$PSItem" -match "Serialized XML is nested too deeply") { Write-PSFMessage -Level Verbose -Message "Serialized XML is nested too deeply. Forcing output." - $exists = Get-KbInstalledUpdate -ComputerName $Computer -HotfixId $hotfix.property.id + $exists = Get-KbInstalledUpdate -ComputerName $ComputerName -HotfixId $hotfix.property.id if ($exists.Summary -match "restart") { $status = "This update requires a restart" @@ -551,14 +623,14 @@ function Start-DscUpdate { } [pscustomobject]@{ - ComputerName = $Computer + ComputerName = $hostname Title = $filetitle ID = $id Status = $Status FileName = $updatefile.Name } } else { - Stop-PSFFunction -Message "Failure on $Computer" -ErrorRecord $_ + Stop-PSFFunction -Message "Failure on $hostname" -ErrorRecord $PSitem -Continue -EnableException:$EnableException } } } diff --git a/private/Start-WindowsUpdate.ps1 b/private/Start-WindowsUpdate.ps1 index e48ff70..bcf101a 100644 --- a/private/Start-WindowsUpdate.ps1 +++ b/private/Start-WindowsUpdate.ps1 @@ -3,7 +3,7 @@ function Start-WindowsUpdate { [CmdletBinding()] param ( [Parameter(Mandatory)] - [psobject]$Computer, + [psobject]$ComputerName, [PSCredential]$Credential, [PSCredential]$PSDscRunAsCredential, [Parameter(ValueFromPipelineByPropertyName)] @@ -22,19 +22,27 @@ function Start-WindowsUpdate { [string]$ArgumentList, [Parameter(ValueFromPipeline)] [pscustomobject[]]$InputObject, - [switch]$DoException, + [switch]$EnableException, [switch]$AllNeeded, - [bool]$IsLocalHost + [bool]$IsLocalHost, + [string]$VerbosePreference ) try { # No idea why this happens sometimes - if ($Computer -is [hashtable]) { - $hashtable = $Computer.PsObject.Copy() - $null = Remove-Variable -Name Computer + if ($ComputerName -is [hashtable]) { + $hashtable = $ComputerName.PsObject.Copy() + $null = Remove-Variable -Name ComputerName foreach ($key in $hashtable.keys) { Set-Variable -Name $key -Value $hashtable[$key] } } + + if ($ComputerName.ComputerName) { + $hostname = $ComputerName.ComputerName + } else { + $hostname = $ComputerName + } + Write-PSFMessage -Level Verbose -Message "Using the Windows Update method" $sessiontype = [type]::GetTypeFromProgID("Microsoft.Update.Session") $session = [activator]::CreateInstance($sessiontype) @@ -51,7 +59,7 @@ function Start-WindowsUpdate { Stop-PSFFunction -EnableException:$EnableException -Message "Failed to create update searcher" -ErrorRecord $_ -Continue } if ($searchresult.Updates.Count -eq 0) { - Stop-PSFFunction -Message "No updates found on $env:computername" -Continue + Stop-PSFFunction -Message "No updates needed on $env:computername" -Continue } # iterate the updates in searchresult @@ -136,7 +144,7 @@ function Start-WindowsUpdate { } [pscustomobject]@{ - ComputerName = $Computer + ComputerName = $hostname Title = $update.Title ID = $update.Identity.UpdateID Status = $status diff --git a/public/Get-KbInstalledUpdate.ps1 b/public/Get-KbInstalledUpdate.ps1 index 8da3452..7bc8390 100644 --- a/public/Get-KbInstalledUpdate.ps1 +++ b/public/Get-KbInstalledUpdate.ps1 @@ -341,7 +341,7 @@ function Get-KbInstalledUpdate { try { foreach ($computer in $ComputerName) { - Invoke-PSFCommand -ComputerName $computer -Credential $Credential -ErrorAction Stop -ScriptBlock $scriptblock -ArgumentList @($Pattern), $IncludeHidden, $VerbosePreference | Sort-Object -Property Name | + Invoke-Command2 -ComputerName $computer -Credential $Credential -ErrorAction Stop -ScriptBlock $scriptblock -ArgumentList @($Pattern), $IncludeHidden, $VerbosePreference | Sort-Object -Property Name | Select-Object -Property * -ExcludeProperty PSComputerName, RunspaceId | Select-DefaultView -ExcludeProperty InstallFile } } catch { diff --git a/public/Get-KbNeededUpdate.ps1 b/public/Get-KbNeededUpdate.ps1 index 02372db..464443c 100644 --- a/public/Get-KbNeededUpdate.ps1 +++ b/public/Get-KbNeededUpdate.ps1 @@ -219,7 +219,7 @@ function Get-KbNeededUpdate { 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/kbupdate/issues" -Continue } - Write-PSFMessage -Level Verbose "Copying $ScanFilePath to $temp on $computer" + Write-PSFMessage -Level Verbose -Message "Copying $ScanFilePath to $temp on $computer" $null = Copy-Item -Path $ScanFilePath -Destination $temp -ToSession $remotesession -Force } } else { @@ -236,7 +236,7 @@ function Get-KbNeededUpdate { Select-Object -Property * -ExcludeProperty PSComputerName, RunspaceId | Select-DefaultView -Property ComputerName, Title, KBUpdate, UpdateId, Description, LastModified, RebootBehavior, RequestsUserInput, NetworkRequired, Link)) { if (-not $result.Link) { - Write-PSFMessage -Level Verbose "No link found for $($result.KBUpdate.Trim()). Looking it up." + Write-PSFMessage -Level Verbose -Message "No link found for $($result.KBUpdate.Trim()). Looking it up." $link = (Get-KbUpdate -Pattern "$($result.KBUpdate.Trim())" -Simple -Computer $computer | Where-Object Title -match $result.KBUpdate).Link if ($link) { $result.Link = $link diff --git a/public/Get-KbUpdate.ps1 b/public/Get-KbUpdate.ps1 index 2676c69..1b94f8a 100644 --- a/public/Get-KbUpdate.ps1 +++ b/public/Get-KbUpdate.ps1 @@ -389,7 +389,7 @@ function Get-KbUpdate { Write-PSFMessage -Level Verbose -Message "$kb" if ($kb -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { - Write-Verbose -Message "Guid passed in, skipping initial web search" + Write-PSFMessage -Level Verbose -Message "Guid passed in, skipping initial web search" $guids = @() $guids += [PSCustomObject]@{ Guid = $kb @@ -531,8 +531,9 @@ function Get-KbUpdate { $completed++ $guid = $psitem.Guid $itemtitle = $psitem.Title - Write-Verbose -Message "Downloading information for $itemtitle" - Write-ProgressHelper -TotalSteps $guids.Count -StepNumber $completed -Activity "Searching catalog" -Message "Downloading information for $itemtitle" + Write-PSFMessage -Level Verbose -Message "Downloading information for $itemtitle" + $total = ($guids.Count) + 2 + Write-ProgressHelper -TotalSteps $total -StepNumber $completed -Activity "Searching catalog" -Message "Downloading information for $itemtitle" $post = @{ size = 0; updateID = $guid; uidInfo = $guid } | ConvertTo-Json -Compress $body = @{ updateIDs = "[$post]" } Invoke-TlsWebRequest -Uri 'https://www.catalog.update.microsoft.com/DownloadDialog.aspx' -Method Post -Body $body | Select-Object -ExpandProperty Content @@ -550,7 +551,8 @@ function Get-KbUpdate { foreach ($downloaddialog in $downloaddialogs) { $completed++ $title = Get-Info -Text $downloaddialog -Pattern 'enTitle =' - Write-ProgressHelper -TotalSteps $downloaddialogs.Count -StepNumber $completed -Activity "Downloading details" -Message "Getting details for $title" + $total = ($downloaddialogs.Count) + 2 + Write-ProgressHelper -TotalSteps $total -StepNumber $completed -Activity "Downloading details" -Message "Getting details for $title" $arch = $null $longlang = Get-Info -Text $downloaddialog -Pattern 'longLanguages =' if ($Pattern -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { diff --git a/public/Install-KbUpdate.ps1 b/public/Install-KbUpdate.ps1 index c83556e..54f5da5 100644 --- a/public/Install-KbUpdate.ps1 +++ b/public/Install-KbUpdate.ps1 @@ -98,7 +98,8 @@ function Install-KbUpdate { #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "Medium")] param ( - [PSFComputer[]]$ComputerName = $env:ComputerName, + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [PSFComputer[]]$ComputerName, [PSCredential]$Credential, [PSCredential]$PSDscRunAsCredential, [Parameter(ValueFromPipelineByPropertyName)] @@ -120,9 +121,10 @@ function Install-KbUpdate { [switch]$EnableException ) begin { - # create code blocks for jobs + # create code blocks for jobs + $cmd2 = $((Get-Command Invoke-Command2).Definition) $wublock = [scriptblock]::Create($((Get-Command Start-WindowsUpdate).Definition)) - $dscblock = [scriptblock]::Create($((Get-Command Start-DscUpdate).Definition)) + $dscblock = [scriptblock]::Create($((Get-Command Start-DscUpdate).Definition).Replace("# function Invoke-Command2", "function Invoke-Command2 { $cmd2 }")) # cleanup $null = Get-Job -ChildJobState Completed | Where-Object Name -in $ComputerName.ComputerName | Remove-Job -Force } @@ -146,13 +148,12 @@ function Install-KbUpdate { } if (-not $PSBoundParameters.ComputerName -and $InputObject) { - $ComputerName = [PSFComputer]$InputObject.ComputerName + $ComputerName = [PSFComputer[]]$InputObject.ComputerName Write-PSFMessage -Level Verbose -Message "Added $ComputerName" } $jobs = @() $added = 0 - $totalsteps = ($ComputerName.Count * 2) + 1 # The plus one is for pretty foreach ($computer in $ComputerName) { $hostname = $computer.ComputerName @@ -163,107 +164,160 @@ function Install-KbUpdate { if ($computer.IsLocalHost -and -not (Test-ElevationRequirement -ComputerName $hostname)) { Stop-PSFFunction -EnableException:$EnableException -Message "You must be an administrator to run this command on the local host" -Continue } - $parms = @{ - Computer = $hostname - FilePath = $FilePath - HotfixId = $HotfixId - RepositoryPath = $RepositoryPath - Guid = $Guid - Title = $Title - ArgumentList = $ArgumentList - InputObject = $InputObject - DoException = $EnableException - IsLocalHost = $computer.IsLocalHost - AllNeeded = $AllNeeded - } - - $null = $PSDefaultParameterValues["Start-Job:ArgumentList"] = $parms - $null = $PSDefaultParameterValues["Start-Job:Name"] = $hostname - Write-Progress -Activity "Installing updates" -Status "Added $($computer.ComputerName) to queue. Processing $added computers..." -PercentComplete ($added / $totalsteps * 100) + Write-Progress -Activity "Installing updates" -Status "Added $($computer.ComputerName) to queue. Processing $added computers..." -PercentComplete ($added / 100 * 100) Write-PSFMessage -Level Verbose -Message "Processing $($parms.ComputerName)" if ($computer.IsLocalhost) { - if ((Get-Service wuauserv | Where-Object StartType -ne Disabled) -and $InputObject.InputObject) { - + if ((Get-Service wuauserv | Where-Object StartType -ne Disabled)) { Write-PSFMessage -Level Verbose -Message "Setting method to Windows Update $($parms.ComputerName)" $method = "WindowsUpdate" } - if ($AllNeeded -and -not $PSBoundParameters.InputObject.InputObject) { - Write-PSFMessage -Level Verbose -Message "Setting method to Windows Update $($parms.ComputerName) then getting all needed windows updates" - $method = "WindowsUpdate" - $InputObject = @(Get-KbNeededUpdate -ComputerName $computer) - } - } elseif ($AllNeeded -and -not $PSBoundParameters.InputObject.InputObject) { - Write-PSFMessage -Level Verbose -Message "Getting all needed Windows Updates on $($parms.ComputerName)" - $InputObject = Get-KbNeededUpdate -ComputerName $computer - if (-not $InputObject.ComputerName.Count) { - - } } - if ($method -eq "WindowsUpdate") { - Write-PSFMessage -Level Verbose -Message "Method is WindowsUpdate" - if ($ComputerName.Count -eq 1 -or $NoMultithreading) { - Start-WindowsUpdate @parms - } else { - $job = Start-Job -ScriptBlock $wublock + try { + $parms = @{ + ComputerName = $hostname + FilePath = $FilePath + HotfixId = $HotfixId + RepositoryPath = $RepositoryPath + Guid = $Guid + Title = $Title + ArgumentList = $ArgumentList + InputObject = $InputObject + EnableException = $EnableException + IsLocalHost = $computer.IsLocalHost + AllNeeded = $AllNeeded + VerbosePreference = $VerbosePreference } - } else { - Write-PSFMessage -Level Verbose -Message "Method is DSC" - if ($ComputerName.Count -eq 1 -or $NoMultithreading) { - Start-DscUpdate @parms + $null = $PSDefaultParameterValues["Start-Job:ArgumentList"] = $parms + $null = $PSDefaultParameterValues["Start-Job:Name"] = $hostname + + if ($method -eq "WindowsUpdate") { + Write-PSFMessage -Level Verbose -Message "Method is WindowsUpdate" + if ($ComputerName.Count -eq 1 -or $NoMultithreading) { + Write-PSFMessage -Level Verbose -Message "Not using jobs for update to $hostname" + Start-WindowsUpdate @parms + } else { + Write-PSFMessage -Level Verbose -Message "Using jobs for update to $hostname" + $jobs += Start-Job -ScriptBlock $wublock + } } else { - $job = Start-Job -ScriptBlock $dscblock + Write-PSFMessage -Level Verbose -Message "Method is DSC" + if ($ComputerName.Count -eq 1 -or $NoMultithreading) { + Write-PSFMessage -Level Verbose -Message "Not using jobs for update to $hostname" + Start-DscUpdate @parms -ErrorAction Stop + } else { + Write-PSFMessage -Level Verbose -Message "Using jobs for update to $hostname" + $jobs += Start-Job -ScriptBlock $dscblock -ErrorAction Stop + } } + } catch { + Stop-PSFFunction -Message "Failure on $hostname" -ErrorRecord $PSItem -EnableException:$EnableException -Continue } } - $jobs += $job if ($jobs.Name) { - while ($kbjobs = Get-Job | Where-Object Name -in $jobs.Name) { - foreach ($item in $kbjobs) { - try { - $item | Receive-Job -ErrorAction Stop -OutVariable kbjob | Select-Object -Property * -ExcludeProperty RunspaceId - } catch { - Stop-PSFFunction -Message "Failure on $($item.Name)" -ErrorRecord $PSItem -EnableException:$EnableException -Continue + try { + while ($kbjobs = Get-Job | Where-Object Name -in $jobs.Name) { + # People really just want to know that it's still going and DSC doesn't give us a proper status + # Just shoooooooooooooooooow a progress bar + if ($added -eq 100) { + $added = 0 } - - if ($kbjob.Output) { - $kbjob.Output | Write-Output - } - if ($kbjob.Warning) { - $kbjob.Warning | Write-Warning - } - if ($kbjob.Verbose) { - $kbjob.Verbose | Write-Verbose - } - if ($kbjob.Debug) { - $kbjob.Debug | Write-Debug - } - if ($kbjob.Information) { - $kbjob.Information | Write-Information - } - } - $null = Remove-Variable -Name kbjob - foreach ($kbjob in ($kbjobs | Where-Object State -ne 'Running')) { - Write-PSFMessage -Level Verbose -Message "Finished installing updates on $($kbjob.Name)" - $null = $added++ - $done = $kbjobs | Where-Object Name -ne $kbjob.Name + $added++ $progressparms = @{ Activity = "Installing updates" - Status = "Still installing updates on $($done.Name -join ', ')" - PercentComplete = ($added / $totalsteps * 100) + Status = "Still installing updates on $($kbjobs.Name -join ', '). Please enjoy the inaccurate progress bar." + PercentComplete = ($added / 100 * 100) } - Write-Progress @progressparms - $jorbs | Where-Object Name -eq $kbjob.name - $kbjob | Remove-Job + foreach ($item in $kbjobs) { + try { + $item | Receive-Job -OutVariable kbjob 4>$verboseoutput | Select-Object -Property * -ExcludeProperty RunspaceId + } catch { + Stop-PSFFunction -Message "Failure on $($item.Name)" -ErrorRecord $PSItem -EnableException:$EnableException -Continue + } + + if ($kbjob.Output) { + foreach ($msg in $kbjob.Output) { + Write-PSFMessage -Level Debug -Message "$msg" + } + } + if ($kbjob.Warning) { + foreach ($msg in $kbjob.Warning) { + if ($msg) { + # too many extra spaces, baw + while ("$msg" -match " ") { + $msg = "$msg" -replace " ", " " + } + } + } + Write-PSFMessage -Level Warning -Message "$msg" + } + if ($kbjob.Verbose) { + foreach ($msg in $kbjob.Verbose) { + if ($msg) { + # too many extra spaces, baw + while ("$msg" -match " ") { + $msg = "$msg" -replace " ", " " + } + } + } + $verboseoutput + Write-PSFMessage -Level Verbose -Message "$msg" + } + + + if ($verboseoutput) { + foreach ($msg in $verboseoutput) { + if ($msg) { + # too many extra spaces, baw + while ("$msg" -match " ") { + $msg = "$msg" -replace " ", " " + } + } + } + Write-PSFMessage -Level Verbose -Message "$msg" + } + + if ($kbjob.Debug) { + foreach ($msg in $kbjob.Debug) { + Write-PSFMessage -Level Debug -Message "$msg" + } + } + + if ($kbjob.Information) { + foreach ($msg in $kbjob.Information) { + Write-PSFMessage -Level Information -Message "$msg" + } + } + } + $null = Remove-Variable -Name kbjob + foreach ($kbjob in ($kbjobs | Where-Object State -ne 'Running')) { + Write-PSFMessage -Level Verbose -Message "Finished installing updates on $($kbjob.Name)" + if ($added -eq 100) { + $added = 0 + } + $null = $added++ + $done = $kbjobs | Where-Object Name -ne $kbjob.Name + $progressparms = @{ + Activity = "Installing updates" + Status = "Still installing updates on $($done.Name -join ', '). Please enjoy the inaccurate progress bar." + PercentComplete = ($added / 100 * 100) + } + + Write-Progress @progressparms + $jorbs | Where-Object Name -eq $kbjob.name + $kbjob | Remove-Job + } + Start-Sleep -Seconds 1 } - Start-Sleep -Seconds 1 + Write-Progress -Activity "Installing updates" -Completed + } catch { + Stop-PSFFunction -Message "Failure on $hostname" -ErrorRecord $PSItem -EnableException:$EnableException } - Write-Progress -Activity "Installing updates" -Completed } } } \ No newline at end of file diff --git a/public/Save-KbScanFile.ps1 b/public/Save-KbScanFile.ps1 index 2a0faa9..c744acc 100644 --- a/public/Save-KbScanFile.ps1 +++ b/public/Save-KbScanFile.ps1 @@ -37,7 +37,7 @@ function Save-KbScanFile { [switch]$AllowClobber ) process { - Write-PSFMessage -Level Verbose "Grabbing headers from catalog site" + Write-PSFMessage -Level Verbose -Message "Grabbing headers from catalog site" $request = Invoke-TlsWebRequest -Uri $Source -Method HEAD $lastmodified = $Request.Headers['Last-Modified'] diff --git a/public/Uninstall-KbUpdate.ps1 b/public/Uninstall-KbUpdate.ps1 index b9fd43f..8b997f6 100644 --- a/public/Uninstall-KbUpdate.ps1 +++ b/public/Uninstall-KbUpdate.ps1 @@ -165,8 +165,9 @@ function Uninstall-KbUpdate { Results = $output } } - - if (-not $InputObject) { + } + process { + if (-not $InputObject -and $HotfixId) { foreach ($hotfix in $HotfixId) { if (-not $hotfix.ToUpper().StartsWith("KB") -and $PSBoundParameters.HotfixId) { $hotfix = "KB$hotfix" @@ -188,8 +189,6 @@ function Uninstall-KbUpdate { } } } - } - process { if ($IsLinux -or $IsMacOs) { Stop-PSFFunction -Message "This command uses remoting and only supports Windows at this time" -EnableException:$EnableException @@ -232,7 +231,7 @@ function Uninstall-KbUpdate { if (-not $PSBoundParameters.ArgumentList) { $ArgumentList = $update.QuietUninstallString.Replace($program, "") } - } elseif ($update.UninstallString -and $update.ProviderName -eq "Programs") { + } elseif ($update.UninstallString -and $update.ProviderName -eq "Programs" -and $update.UninstallString -notmatch "SetupARP.exe") { $path = $update.UninstallString -match '^(".+") (/.+) (/.+)' if ($matches) { $needuninstallstring = $false @@ -244,8 +243,9 @@ function Uninstall-KbUpdate { if (-not $PSBoundParameters.ArgumentList) { $ArgumentList = $update.UninstallString.Replace($program, "") } - - if ($ArgumentList -notmatch "/quiet" -and -not $NoQuiet -and -not $PSBoundParameters.ArgumentList -and $ArgumentList -ne "/S" -and $ArgumentList -ne "/Q") { + if ($ArgumentList -match "msedge") { + $ArgumentList = "$ArgumentList --force-uninstall" + } elseif ($ArgumentList -notmatch "/quiet" -and -not $NoQuiet -and -not $PSBoundParameters.ArgumentList -and $ArgumentList -ne "/S" -and $ArgumentList -ne "/Q") { $ArgumentList = "$ArgumentList /quiet" } } @@ -297,7 +297,7 @@ function Uninstall-KbUpdate { } if (-not $installname) { - Stop-PSFFunction -EnableException:$EnableException -Message "Couldn't determine a way to uninstall this applicatoin. It may be marked as a permanent install or part of another package that contains the unintaller." -Continue + Stop-PSFFunction -EnableException:$EnableException -Message "Couldn't determine a way to uninstall $($update.Name). It may be marked as a permanent install or part of another package that contains the unintaller." -Continue } $program = "dism" $parms = @("/Online /Remove-Package /quiet /norestart") diff --git a/tests/Integration.Tests.ps1 b/tests/Integration.Tests.ps1 index 1ccb71c..5f7ddcf 100644 --- a/tests/Integration.Tests.ps1 +++ b/tests/Integration.Tests.ps1 @@ -215,7 +215,7 @@ Describe "Integration Tests" -Tag "IntegrationTests" { Context "Install-KbUpdate works" { It "installs a patch" { $update = Get-KbUpdate -Pattern KB4527377 | Save-KbUpdate -Path C:\temp - $results = Install-KbUpdate -Path $update + $results = Install-KbUpdate -ComputerName $env:computername -Path $update $results | Should -Not -BeNullOrEmpty } } @@ -233,7 +233,7 @@ Describe "Integration Tests" -Tag "IntegrationTests" { Context "Uninstall-KbUpdate works" { It -Skip "Uninstalls a patch" { - $results = Uninstall-KbUpdate -HotfixId KB4527377 -Confirm:$false + $results = Uninstall-KbUpdate -ComputerName $env:computername -HotfixId KB4527377 -Confirm:$false $results | Should -Not -BeNullOrEmpty } It -Skip "Uninstalls a patch" {